@rtorcato/repo-tooling 3.6.0 → 3.7.1

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 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 is audit-only for now. See `src/languages/` — one directory per language module, `src/base/` for what's shared.
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
 
@@ -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
- export async function checkCodeQL(dir) {
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 {
@@ -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
  },
@@ -411,17 +411,22 @@ export function buildGhApplyCommands(state) {
411
411
  * on skip (no gh/auth/remote, or already compliant), logging the reason. Mirrors
412
412
  * the "no package.json found — skipping" fixer pattern. Read-only unless a delta
413
413
  * exists, so re-runs (walk-all hits it up to 3×) are safe.
414
+ *
415
+ * Reached from the `github-settings` fixer, so its advisories go to
416
+ * `console.error` for the same reason the fixers' do (#357): stdout carries the
417
+ * `--json` payload. #358 fixed the fixer files but not this one — it isn't a
418
+ * fixer file, it's just called by one.
414
419
  */
415
420
  export async function applyGithubSettings(dir, exec) {
416
421
  if (!(await fs.pathExists(path.join(dir, '.git')))) {
417
- console.log(chalk.gray(' skipped — not a git repository'));
422
+ console.error(chalk.gray(' skipped — not a git repository'));
418
423
  return [];
419
424
  }
420
425
  // Bind gh's cwd to the target dir so its repo resolution honors `-d` (#218).
421
426
  const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
422
427
  const probe = await probeRepo(gh);
423
428
  if ('skip' in probe) {
424
- console.log(chalk.gray(` skipped — ${probe.skip}`));
429
+ console.error(chalk.gray(` skipped — ${probe.skip}`));
425
430
  return [];
426
431
  }
427
432
  const { info } = probe;
@@ -442,7 +447,7 @@ export async function applyGithubSettings(dir, exec) {
442
447
  if (r.ok)
443
448
  applied.push(cmd.label);
444
449
  else
445
- console.log(chalk.yellow(` could not apply ${cmd.label}: ${r.stderr.trim() || 'gh error'}`));
450
+ console.error(chalk.yellow(` could not apply ${cmd.label}: ${r.stderr.trim() || 'gh error'}`));
446
451
  }
447
452
  // Code-scanning ruleset (#269): POST only when CodeQL is on and no active gate
448
453
  // covers the default branch — the check re-read keeps this idempotent.
@@ -453,9 +458,9 @@ export async function applyGithubSettings(dir, exec) {
453
458
  if (r.ok)
454
459
  applied.push(label);
455
460
  else
456
- console.log(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
461
+ console.error(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
457
462
  }
458
463
  if (applied.length === 0)
459
- console.log(chalk.gray(' already configured — nothing to apply'));
464
+ console.error(chalk.gray(' already configured — nothing to apply'));
460
465
  return applied;
461
466
  }
@@ -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; an unsupported one (Perl, until #289)
178
- // still gets the full base suite instead of the old wholesale skip.
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 — nothing here encodes a Perl hook convention yet, and
191
- // guessing one would nag every repo with a fix target that doesn't
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':
@@ -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 = [...BASE_FIXERS, ...FIXERS, ...SWIFT_FIXERS, ...PYTHON_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.log(hooksPathSet
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 };
@@ -25,7 +25,7 @@ export const LANGUAGES = {
25
25
  label: 'Perl',
26
26
  codeqlLanguages: [],
27
27
  dependabotEcosystem: null,
28
- supported: false,
28
+ supported: true,
29
29
  },
30
30
  };
31
31
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.6.0",
3
+ "version": "3.7.1",
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.