@rtorcato/repo-tooling 3.5.0 → 3.6.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 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 and Perl are 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 is audit-only for now. See `src/languages/` — one directory per language module, `src/base/` for what's shared.
10
10
 
11
11
  ## CLI surface (agent-friendly)
12
12
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Committed git hooks for languages that can't use Husky (#309).
3
+ *
4
+ * Husky is an npm package, so a repo whose toolchain has no node can't use it.
5
+ * The node-free equivalent is a committed hooks directory that git is pointed at
6
+ * with `core.hooksPath` — identical mechanics whatever the language; only the
7
+ * hook bodies differ. Swift and Python both land here rather than each carrying
8
+ * their own copy of the write-then-chmod-then-git-config dance.
9
+ */
10
+ import { execFile } from 'node:child_process';
11
+ import path from 'node:path';
12
+ import fs from 'fs-extra';
13
+ /** Best-effort `git config` — a missing git or a non-repo dir is not a failure. */
14
+ function gitConfig(cwd, key, value) {
15
+ return new Promise((resolve) => {
16
+ execFile('git', ['-C', cwd, 'config', key, value], (err) => resolve(!err));
17
+ });
18
+ }
19
+ /**
20
+ * Writes both hooks and points git at them. Returns the files written —
21
+ * `core.hooksPath` is per-clone local config, not a file, so it's reported
22
+ * separately rather than pretending to be a repo change.
23
+ */
24
+ export async function installGitHooks(targetDir, hooksDirName, scripts) {
25
+ const hooksDir = path.join(targetDir, hooksDirName);
26
+ await fs.ensureDir(hooksDir);
27
+ const filesWritten = [];
28
+ for (const [name, content] of [
29
+ ['pre-commit', scripts.preCommit],
30
+ ['pre-push', scripts.prePush],
31
+ ]) {
32
+ const hookPath = path.join(hooksDir, name);
33
+ await fs.writeFile(hookPath, content);
34
+ await fs.chmod(hookPath, 0o755);
35
+ filesWritten.push(`${hooksDirName}/${name}`);
36
+ }
37
+ // Guard on .git: `fix --diff` shadow-runs fixers in a temp copy that excludes
38
+ // .git, and that copy can land inside another repo — an unguarded `git config`
39
+ // there would rewrite the *parent* repo's hooksPath during a mere preview.
40
+ const isRepo = await fs.pathExists(path.join(targetDir, '.git'));
41
+ const hooksPathSet = isRepo ? await gitConfig(targetDir, 'core.hooksPath', hooksDirName) : false;
42
+ return { filesWritten, hooksPathSet };
43
+ }
@@ -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 { PYTHON_GIT_HOOKS, runPythonChecks } from '../../languages/python/checks.js';
8
+ import { readPyproject, renderPythonWorkflow } from '../../languages/python/ci.js';
7
9
  import { resolveLanguageModule } from '../../languages/registry.js';
8
10
  import { SWIFT_GIT_HOOKS, runSwiftChecks } from '../../languages/swift/checks.js';
9
11
  import { readSwiftPackage, renderSwiftWorkflow } from '../../languages/swift/ci.js';
@@ -172,10 +174,10 @@ export async function runDoctor(dir) {
172
174
  const lock = await readLockfile(targetDir);
173
175
  // Per-module dispatch (#285): the base checks (repo hygiene, CI, security,
174
176
  // GitHub settings) apply to any repo and run for every language. A supported
175
- // module layers its own checks on top; an unsupported one (Swift/Perl/Python
176
- // until their modules land) still gets the full base suite instead of the old
177
- // wholesale skip. 'unknown' (bare dir mid-setup) resolves to JS so a fresh
178
- // repo runs the full suite.
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
181
  const language = await detectLanguage(targetDir);
180
182
  const languageModule = resolveLanguageModule(language);
181
183
  if (!languageModule.supported) {
@@ -185,9 +187,9 @@ export async function runDoctor(dir) {
185
187
  status: 'ok',
186
188
  detail: `detected ${languageModule.label} — running language-agnostic checks; ${languageModule.label}-specific checks land with its module (#139)`,
187
189
  },
188
- // hooks: null — nothing here encodes a Python/Perl hook convention yet,
189
- // and guessing one would nag every repo with a fix target that doesn't
190
- // exist. #289/#290 fill it in.
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.
191
193
  ...(await runBaseChecks(targetDir, lock, {
192
194
  hooks: null,
193
195
  badges: { audience: 'public', fixTarget: null },
@@ -219,6 +221,28 @@ export async function runDoctor(dir) {
219
221
  ];
220
222
  return demoteDeclined(results, lock);
221
223
  }
224
+ // Python suite (#290): same shape as Swift — base checks plus the module's
225
+ // own, and nothing JS-shaped, because a Python repo has no package.json.
226
+ if (languageModule.id === 'python') {
227
+ const results = [
228
+ {
229
+ check: 'language',
230
+ status: 'ok',
231
+ detail: 'detected Python (pyproject.toml / setup.py)',
232
+ },
233
+ ...(await runBaseChecks(targetDir, lock, {
234
+ hooks: PYTHON_GIT_HOOKS,
235
+ // A Python package on PyPI is public by default, so badges apply. No
236
+ // fixer: `fix badges` derives the block from package.json name and
237
+ // repository, which a Python repo hasn't got.
238
+ badges: { audience: 'public', fixTarget: null },
239
+ presetWorkflow: renderPythonWorkflow(await readPyproject(targetDir)),
240
+ language,
241
+ })),
242
+ ...(await runPythonChecks(targetDir)),
243
+ ];
244
+ return demoteDeclined(results, lock);
245
+ }
222
246
  // JS suite: the module's own checks, then the shared base ones. Only the
223
247
  // JS-shaped checks are listed here — re-listing the base suite is what made
224
248
  // any new base check silently skip JS repos (#309).
@@ -64,11 +64,28 @@ const SWIFT_FIX_TARGETS = {
64
64
  // (no `.testTarget(`) there's nothing to run, and rewriting Package.swift
65
65
  // isn't safe. The check's own hint covers both halves.
66
66
  };
67
+ /** The same shadowing for the Python module (#290). */
68
+ const PYTHON_FIX_TARGETS = {
69
+ 'Git hooks': 'python-git-hooks',
70
+ 'Pre-push hook': 'python-git-hooks',
71
+ 'GitHub Actions': 'python-ci',
72
+ 'GitLab CI': 'python-gitlab-ci',
73
+ Ruff: 'ruff',
74
+ mypy: 'mypy',
75
+ pytest: 'pytest',
76
+ 'Python .gitignore': 'python-gitignore',
77
+ // `pyproject.toml` and `Python tests` are deliberately absent, for the same
78
+ // reason `Swift tests` is: the fix is content only the project can write
79
+ // (package metadata, actual tests). Their own hints say what to add.
80
+ // `lockfile` too — see the note at the top of src/languages/python/fixers.ts.
81
+ };
82
+ const FIX_TARGETS_BY_LANGUAGE = {
83
+ swift: SWIFT_FIX_TARGETS,
84
+ python: PYTHON_FIX_TARGETS,
85
+ };
67
86
  export function getFixTargetForCheck(checkName, language) {
68
- if (language === 'swift' && SWIFT_FIX_TARGETS[checkName]) {
69
- return SWIFT_FIX_TARGETS[checkName];
70
- }
71
- return FIX_TARGETS[checkName] ?? null;
87
+ const overrides = language ? FIX_TARGETS_BY_LANGUAGE[language] : undefined;
88
+ return overrides?.[checkName] ?? FIX_TARGETS[checkName] ?? null;
72
89
  }
73
90
  /**
74
91
  * For a given doctor check name, returns true when the lockfile records that
@@ -168,6 +185,7 @@ export function lockfilePatchForTarget(target, lock) {
168
185
  return c.commitLint ? null : { commitLint: true };
169
186
  case 'husky':
170
187
  case 'swift-git-hooks':
188
+ case 'python-git-hooks':
171
189
  return c.gitHooks ? null : { gitHooks: true };
172
190
  case 'semantic-release':
173
191
  case 'swift-release':
@@ -11,19 +11,24 @@ 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 { PYTHON_FIXERS } from '../../languages/python/fixers.js';
14
15
  import { SWIFT_FIXERS } from '../../languages/swift/fixers.js';
15
16
  import { detectLanguage } from '../utils/detect-language.js';
17
+ /** The module-specific fixer set per detected language (#286, #290, #303). */
18
+ const LANGUAGE_FIXERS = {
19
+ swift: SWIFT_FIXERS,
20
+ python: PYTHON_FIXERS,
21
+ };
16
22
  /**
17
- * The fixers that apply to a repo, by detected language (#286, #303): the
18
- * language-agnostic base set plus the module's own. Swift repos get the Swift
19
- * module; everything else (including a bare dir mid-setup) gets JS, the
20
- * historical default.
23
+ * The fixers that apply to a repo, by detected language: the language-agnostic
24
+ * base set plus the module's own. Anything without a module of its own
25
+ * (including a bare dir mid-setup) gets JS, the historical default.
21
26
  */
22
27
  function fixersForLanguage(language) {
23
- return [...BASE_FIXERS, ...(language === 'swift' ? SWIFT_FIXERS : FIXERS)];
28
+ return [...BASE_FIXERS, ...(LANGUAGE_FIXERS[language] ?? FIXERS)];
24
29
  }
25
30
  /** Every fixer across every language — for `--list` and the unknown-target hint. */
26
- const ALL_FIXERS = [...BASE_FIXERS, ...FIXERS, ...SWIFT_FIXERS];
31
+ const ALL_FIXERS = [...BASE_FIXERS, ...FIXERS, ...SWIFT_FIXERS, ...PYTHON_FIXERS];
27
32
  export function getFixers() {
28
33
  return ALL_FIXERS;
29
34
  }
package/dist/cli/index.js CHANGED
@@ -251,6 +251,24 @@ const TOOL_CATALOG = [
251
251
  exports: [],
252
252
  fixTarget: 'periphery',
253
253
  },
254
+ {
255
+ name: 'Ruff',
256
+ description: 'Python linter and formatter in one tool (replaces flake8, isort, pyupgrade, black)',
257
+ exports: [],
258
+ fixTarget: 'ruff',
259
+ },
260
+ {
261
+ name: 'mypy',
262
+ description: 'Python static type checker configuration (strict on your code, lenient on untyped deps)',
263
+ exports: [],
264
+ fixTarget: 'mypy',
265
+ },
266
+ {
267
+ name: 'pytest',
268
+ description: 'Python test runner configuration (strict markers and config, warnings as errors)',
269
+ exports: [],
270
+ fixTarget: 'pytest',
271
+ },
254
272
  {
255
273
  name: 'AI Agent Setup',
256
274
  description: 'Scaffold AGENTS.md, CLAUDE.md, Cursor/Copilot rules, Claude skill, MCP example',
@@ -51,6 +51,21 @@ export const PRESETS = {
51
51
  target: '.periphery.yml',
52
52
  desc: 'Periphery dead-code scan configuration (retains public API)',
53
53
  },
54
+ ruff: {
55
+ source: 'tooling/python/ruff.toml',
56
+ target: 'ruff.toml',
57
+ desc: 'Ruff configuration (lint + format — replaces flake8, isort, pyupgrade and black)',
58
+ },
59
+ mypy: {
60
+ source: 'tooling/python/mypy.ini',
61
+ target: 'mypy.ini',
62
+ desc: 'mypy configuration (strict on your code, lenient on untyped dependencies)',
63
+ },
64
+ pytest: {
65
+ source: 'tooling/python/pytest.ini',
66
+ target: 'pytest.ini',
67
+ desc: 'pytest configuration (strict markers and config, warnings as errors)',
68
+ },
54
69
  'claude-skill': {
55
70
  source: 'tooling/claude/repo-tooling.md',
56
71
  target: '.claude/skills/repo-tooling.md',
@@ -225,13 +225,13 @@ export const FIXERS = [
225
225
  async run({ targetDir, pkg }) {
226
226
  const pkgPath = path.join(targetDir, 'package.json');
227
227
  if (!pkg) {
228
- console.log(chalk.yellow(' no package.json found — skipping'));
228
+ console.error(chalk.yellow(' no package.json found — skipping'));
229
229
  return { filesWritten: [] };
230
230
  }
231
231
  const includeTreeshake = await fs.pathExists(path.join(targetDir, 'apps', 'treeshake-check', 'check.mjs'));
232
232
  const verify = composeVerifyScriptFromPkg(pkg, { includeTreeshake });
233
233
  if (!verify) {
234
- console.log(chalk.gray(' not enough tools enabled to compose a verify chain — skipping (need 2+ of typecheck/lint/tests)'));
234
+ console.error(chalk.gray(' not enough tools enabled to compose a verify chain — skipping (need 2+ of typecheck/lint/tests)'));
235
235
  return { filesWritten: [] };
236
236
  }
237
237
  const updated = { ...pkg };
@@ -256,7 +256,7 @@ export const FIXERS = [
256
256
  canFixDrift: true,
257
257
  async run({ targetDir, pkg }) {
258
258
  if (pkg?.private === true) {
259
- console.log(chalk.gray(' skipping — package is private'));
259
+ console.error(chalk.gray(' skipping — package is private'));
260
260
  return { filesWritten: [] };
261
261
  }
262
262
  const filesWritten = await generateSemanticReleaseConfig(targetDir);
@@ -315,7 +315,7 @@ export const FIXERS = [
315
315
  overwrite: result.check === 'GitHub Actions',
316
316
  });
317
317
  if (!filesWritten.includes(CI_WORKFLOW)) {
318
- console.log(chalk.yellow(` ${CI_WORKFLOW} differs from the preset — left as-is; run \`fix github-actions --diff\` to see the delta`));
318
+ console.error(chalk.yellow(` ${CI_WORKFLOW} differs from the preset — left as-is; run \`fix github-actions --diff\` to see the delta`));
319
319
  }
320
320
  return { filesWritten };
321
321
  },
@@ -518,17 +518,17 @@ export const FIXERS = [
518
518
  canFixDrift: false,
519
519
  async run({ targetDir, pkg }) {
520
520
  if (!pkg) {
521
- console.log(chalk.yellow(' no package.json found — skipping'));
521
+ console.error(chalk.yellow(' no package.json found — skipping'));
522
522
  return { filesWritten: [] };
523
523
  }
524
524
  const workspaceName = pkg.name ?? null;
525
525
  if (!workspaceName) {
526
- console.log(chalk.yellow(' package.json has no `name` — skipping'));
526
+ console.error(chalk.yellow(' package.json has no `name` — skipping'));
527
527
  return { filesWritten: [] };
528
528
  }
529
529
  const { allCandidates, defaultAllowed } = inferSubpathsFromExports(pkg);
530
530
  if (allCandidates.length < 2 || !defaultAllowed) {
531
- console.log(chalk.yellow(' package.json does not expose ≥2 subpath exports — tree-shake check needs multiple subpaths to be meaningful. Skipping.'));
531
+ console.error(chalk.yellow(' package.json does not expose ≥2 subpath exports — tree-shake check needs multiple subpaths to be meaningful. Skipping.'));
532
532
  return { filesWritten: [] };
533
533
  }
534
534
  const allowedSubpath = defaultAllowed;
@@ -538,7 +538,7 @@ export const FIXERS = [
538
538
  allowedSubpath,
539
539
  forbiddenSubpaths,
540
540
  });
541
- console.log(chalk.dim(` Wired '${allowedSubpath}' as allowed; forbidden = [${forbiddenSubpaths.join(', ')}]. Edit apps/treeshake-check/check.mjs to tune.`));
541
+ console.error(chalk.dim(` Wired '${allowedSubpath}' as allowed; forbidden = [${forbiddenSubpaths.join(', ')}]. Edit apps/treeshake-check/check.mjs to tune.`));
542
542
  return { filesWritten: written };
543
543
  },
544
544
  },
@@ -615,7 +615,7 @@ export const FIXERS = [
615
615
  async run({ targetDir, pkg }) {
616
616
  const pkgPath = path.join(targetDir, 'package.json');
617
617
  if (!pkg) {
618
- console.log(chalk.yellow(' no package.json found — skipping'));
618
+ console.error(chalk.yellow(' no package.json found — skipping'));
619
619
  return { filesWritten: [] };
620
620
  }
621
621
  const updated = { ...pkg };
@@ -645,7 +645,7 @@ export const FIXERS = [
645
645
  async run({ targetDir, pkg }) {
646
646
  const pkgPath = path.join(targetDir, 'package.json');
647
647
  if (!pkg) {
648
- console.log(chalk.yellow(' no package.json found — skipping'));
648
+ console.error(chalk.yellow(' no package.json found — skipping'));
649
649
  return { filesWritten: [] };
650
650
  }
651
651
  const updated = { ...pkg };
@@ -674,7 +674,7 @@ export const FIXERS = [
674
674
  canFixDrift: true,
675
675
  async run({ targetDir, pkg }) {
676
676
  if (!pkg) {
677
- console.log(chalk.yellow(' no package.json found — skipping'));
677
+ console.error(chalk.yellow(' no package.json found — skipping'));
678
678
  return { filesWritten: [] };
679
679
  }
680
680
  const p = pkg;
@@ -687,7 +687,7 @@ export const FIXERS = [
687
687
  isPrivate: p.private === true,
688
688
  });
689
689
  if (!block) {
690
- console.log(chalk.yellow(' package.json has no name/repository to build badges from — skipping'));
690
+ console.error(chalk.yellow(' package.json has no name/repository to build badges from — skipping'));
691
691
  return { filesWritten: [] };
692
692
  }
693
693
  const readmePath = path.join(targetDir, 'README.md');
@@ -708,7 +708,7 @@ export const FIXERS = [
708
708
  async run({ targetDir, pkg }) {
709
709
  const pkgPath = path.join(targetDir, 'package.json');
710
710
  if (!pkg) {
711
- console.log(chalk.yellow(' no package.json found — skipping'));
711
+ console.error(chalk.yellow(' no package.json found — skipping'));
712
712
  return { filesWritten: [] };
713
713
  }
714
714
  const updated = { ...pkg };
@@ -718,7 +718,7 @@ export const FIXERS = [
718
718
  devDeps['@rtorcato/repo-tooling'] = 'latest';
719
719
  updated.devDependencies = devDeps;
720
720
  await fs.writeJson(pkgPath, updated, { spaces: 2 });
721
- console.log(chalk.dim(' reminder: run `pnpm install` to install the new dep'));
721
+ console.error(chalk.dim(' reminder: run `pnpm install` to install the new dep'));
722
722
  return { filesWritten: ['package.json'] };
723
723
  },
724
724
  },
@@ -731,7 +731,7 @@ export const FIXERS = [
731
731
  canFixDrift: false,
732
732
  async run({ targetDir, pkg }) {
733
733
  if (!pkg) {
734
- console.log(chalk.yellow(' no package.json found — skipping'));
734
+ console.error(chalk.yellow(' no package.json found — skipping'));
735
735
  return { filesWritten: [] };
736
736
  }
737
737
  const config = inferProjectConfig(pkg);
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Python language module — checks (#290), on the same template as Swift (#286).
3
+ *
4
+ * The standard encoded here is ruff (lint *and* format), mypy and pytest.
5
+ * Deliberately *not* checked:
6
+ *
7
+ * - **black** — ruff's formatter is black-compatible and already runs in the
8
+ * pre-commit hook and the lint job. A second *rewriting* formatter fights the
9
+ * first, which is the same reason the Swift module checks SwiftLint's `--fix`
10
+ * rather than SwiftFormat. If a repo wants black instead, it configures black
11
+ * in its own pyproject.toml — nothing here objects.
12
+ * - **flake8 / isort / pyupgrade** — the `select` list in the shipped ruff.toml
13
+ * covers all three (E/W, I, UP), so a separate config would be a second place
14
+ * to keep the same rules.
15
+ * - **A pinned interpreter file** (`.python-version`) — the supported range is
16
+ * declared once in pyproject's `requires-python`, and CI derives its matrix
17
+ * from that. A root file would be a second thing 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 { PYTHON_GITIGNORE_SENTINELS } from './gitignore.js';
23
+ import { PYTHON_HOOKS_DIR } from './git-hooks.js';
24
+ const PYTHON_TOOL_CHECKS = [
25
+ {
26
+ dedicated: {
27
+ check: 'Ruff',
28
+ candidates: ['ruff.toml', '.ruff.toml'],
29
+ expected: 'is a valid Ruff configuration',
30
+ // `[tool.ruff...]` is the pyproject form; the rest are ruff.toml's own
31
+ // top-level keys and sections.
32
+ matcher: /^\[tool\.ruff|^\[(lint|format)\]|^(line-length|target-version)\s*=/m,
33
+ hint: 'Run `npx @rtorcato/repo-tooling fix ruff` to scaffold (or add a `[tool.ruff]` table to pyproject.toml)',
34
+ },
35
+ shared: ['pyproject.toml'],
36
+ },
37
+ {
38
+ dedicated: {
39
+ check: 'mypy',
40
+ candidates: ['mypy.ini', '.mypy.ini'],
41
+ expected: 'is a valid mypy configuration',
42
+ // `[mypy]` in mypy.ini/setup.cfg, `[tool.mypy]` in pyproject.
43
+ matcher: /^\[(tool\.)?mypy\]/m,
44
+ hint: 'Run `npx @rtorcato/repo-tooling fix mypy` to scaffold (or add a `[tool.mypy]` table to pyproject.toml)',
45
+ },
46
+ shared: ['setup.cfg', 'pyproject.toml'],
47
+ },
48
+ {
49
+ dedicated: {
50
+ check: 'pytest',
51
+ candidates: ['pytest.ini'],
52
+ expected: 'is a valid pytest configuration',
53
+ // `[pytest]` in pytest.ini/tox.ini, `[tool:pytest]` in setup.cfg,
54
+ // `[tool.pytest.ini_options]` in pyproject.
55
+ matcher: /^\[(pytest|tool:pytest|tool\.pytest\.ini_options)\]/m,
56
+ hint: 'Run `npx @rtorcato/repo-tooling fix pytest` to scaffold (or add a `[tool.pytest.ini_options]` table to pyproject.toml)',
57
+ },
58
+ shared: ['tox.ini', 'setup.cfg', 'pyproject.toml'],
59
+ },
60
+ ];
61
+ async function checkPythonTool(dir, spec) {
62
+ const result = await checkFile(dir, spec.dedicated);
63
+ // A dedicated file that exists takes precedence over any shared table — all
64
+ // three tools read it first — so `ok` and `drift` are both final answers.
65
+ if (result.status !== 'missing')
66
+ return result;
67
+ for (const candidate of spec.shared) {
68
+ const filepath = path.join(dir, candidate);
69
+ if (!(await fs.pathExists(filepath)))
70
+ continue;
71
+ if (spec.dedicated.matcher.test(await fs.readFile(filepath, 'utf-8'))) {
72
+ return {
73
+ check: spec.dedicated.check,
74
+ status: 'ok',
75
+ detail: `${candidate} ${spec.dedicated.expected}`,
76
+ };
77
+ }
78
+ }
79
+ return result;
80
+ }
81
+ export async function checkPythonGitignore(dir) {
82
+ const check = 'Python .gitignore';
83
+ const filepath = path.join(dir, '.gitignore');
84
+ if (!(await fs.pathExists(filepath))) {
85
+ return {
86
+ check,
87
+ status: 'missing',
88
+ detail: 'no .gitignore',
89
+ hint: 'Run `npx @rtorcato/repo-tooling fix python-gitignore` to scaffold the Python template',
90
+ };
91
+ }
92
+ const contents = await fs.readFile(filepath, 'utf-8');
93
+ const missing = PYTHON_GITIGNORE_SENTINELS.filter((entry) => !contents.includes(entry));
94
+ if (missing.length > 0) {
95
+ return {
96
+ check,
97
+ status: 'drift',
98
+ detail: `.gitignore missing Python artefacts: ${missing.join(', ')}`,
99
+ hint: 'Run `npx @rtorcato/repo-tooling fix python-gitignore` to append the missing entries',
100
+ };
101
+ }
102
+ return {
103
+ check,
104
+ status: 'ok',
105
+ detail: `.gitignore covers ${PYTHON_GITIGNORE_SENTINELS.join(', ')}`,
106
+ };
107
+ }
108
+ /**
109
+ * pyproject.toml hygiene, the Python shape of the `Package.swift` check. Both
110
+ * signals are things no tool infers for you: without a metadata table there is
111
+ * nothing to build or publish, and without a `requires-python` floor pip will
112
+ * happily install the package on an interpreter it cannot run on — the failure
113
+ * lands on the user, at import time.
114
+ */
115
+ export async function checkPyproject(dir) {
116
+ const check = 'pyproject.toml';
117
+ const filepath = path.join(dir, 'pyproject.toml');
118
+ if (!(await fs.pathExists(filepath))) {
119
+ // detect-language also accepts setup.py, so a repo can reach this module
120
+ // without a pyproject at all. That's drift rather than "not a Python repo".
121
+ const legacy = await fs.pathExists(path.join(dir, 'setup.py'));
122
+ return {
123
+ check,
124
+ status: 'missing',
125
+ detail: legacy ? 'setup.py only — no pyproject.toml' : 'no pyproject.toml',
126
+ hint: 'Add a PEP 621 pyproject.toml with a `[project]` table — setup.py is legacy and no longer the packaging standard',
127
+ };
128
+ }
129
+ const contents = await fs.readFile(filepath, 'utf-8');
130
+ // PEP 621 `[project]` or Poetry's `[tool.poetry]` — the two forms in the wild.
131
+ const poetry = /^\[tool\.poetry\]/m.test(contents);
132
+ if (!/^\[project\]/m.test(contents) && !poetry) {
133
+ return {
134
+ check,
135
+ status: 'drift',
136
+ detail: 'pyproject.toml declares no `[project]` (or `[tool.poetry]`) metadata',
137
+ hint: 'Add a `[project]` table with `name`, `version` and `requires-python` — see PEP 621',
138
+ };
139
+ }
140
+ // Poetry spells the same constraint `python = "^3.11"` under its dependencies.
141
+ const hasFloor = poetry
142
+ ? /^\s*python\s*=/m.test(contents)
143
+ : /^requires-python\s*=/m.test(contents);
144
+ if (!hasFloor) {
145
+ return {
146
+ check,
147
+ status: 'drift',
148
+ detail: `pyproject.toml declares no ${poetry ? '`python` constraint' : '`requires-python`'}`,
149
+ hint: 'Add `requires-python = ">=3.10"` — without it pip installs the package on interpreters it cannot run on',
150
+ };
151
+ }
152
+ return {
153
+ check,
154
+ status: 'ok',
155
+ detail: `pyproject.toml declares ${poetry ? 'Poetry' : 'PEP 621'} metadata and a Python floor`,
156
+ };
157
+ }
158
+ /** CI files that could be running the test suite, most likely first. */
159
+ async function ciFiles(dir) {
160
+ const candidates = ['.gitlab-ci.yml', '.gitlab-ci.yaml'];
161
+ const workflowsDir = path.join(dir, '.github', 'workflows');
162
+ if (await fs.pathExists(workflowsDir)) {
163
+ const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
164
+ candidates.unshift(...files.map((f) => path.join('.github', 'workflows', f)));
165
+ }
166
+ return candidates;
167
+ }
168
+ /** `tests/`, `test/`, or a root-level `test_*.py` — the three layouts pytest finds. */
169
+ async function hasTestFiles(dir) {
170
+ for (const candidate of ['tests', 'test']) {
171
+ if (await fs.pathExists(path.join(dir, candidate)))
172
+ return `${candidate}/`;
173
+ }
174
+ try {
175
+ const found = (await fs.readdir(dir)).find((f) => /^test_.*\.py$/.test(f));
176
+ return found ?? null;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ /**
183
+ * The test setup. Same two halves as the Swift version: a suite has to exist,
184
+ * and CI has to run it. A green pipeline over a repo with no tests proves
185
+ * nothing, and a tests/ directory nothing executes is worse than none at all —
186
+ * it reads as covered.
187
+ */
188
+ export async function checkPythonTests(dir) {
189
+ const check = 'Python tests';
190
+ const tests = await hasTestFiles(dir);
191
+ if (!tests) {
192
+ return {
193
+ check,
194
+ status: 'missing',
195
+ detail: 'no tests/ directory or test_*.py files',
196
+ hint: 'Add a `tests/` directory — the shipped pytest.ini sets `testpaths = tests`',
197
+ };
198
+ }
199
+ for (const candidate of await ciFiles(dir)) {
200
+ const filepath = path.join(dir, candidate);
201
+ if (!(await fs.pathExists(filepath)))
202
+ continue;
203
+ if (/\bpytest\b/.test(await fs.readFile(filepath, 'utf-8'))) {
204
+ return { check, status: 'ok', detail: `${tests} found and run by ${candidate}` };
205
+ }
206
+ }
207
+ return {
208
+ check,
209
+ status: 'drift',
210
+ detail: `${tests} found but no CI job runs pytest`,
211
+ hint: 'Run `npx @rtorcato/repo-tooling fix python-ci` to regenerate a workflow that runs pytest',
212
+ };
213
+ }
214
+ /**
215
+ * The Python shape of the base `Git hooks` / `Pre-push hook` checks (#309).
216
+ * `install` is null for the same reason Swift's is: the wiring —
217
+ * `git config core.hooksPath` — is per-clone local state that nothing commits,
218
+ * so flagging its absence would fail every fresh CI checkout.
219
+ */
220
+ export const PYTHON_GIT_HOOKS = {
221
+ dir: PYTHON_HOOKS_DIR,
222
+ install: null,
223
+ verifyCommand: 'pytest',
224
+ fixTarget: 'python-git-hooks',
225
+ };
226
+ /** The Python module's suite, layered on top of the base checks by doctor. */
227
+ export async function runPythonChecks(dir) {
228
+ return [
229
+ await checkPyproject(dir),
230
+ ...(await Promise.all(PYTHON_TOOL_CHECKS.map((spec) => checkPythonTool(dir, spec)))),
231
+ await checkPythonGitignore(dir),
232
+ await checkPythonTests(dir),
233
+ ];
234
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Python CI generation (#290), built on the language-agnostic skeleton in
3
+ * `src/base/ci.ts`. No `setup-node`, no `pnpm` — the Python path runs on Linux
4
+ * with `actions/setup-python` and pip.
5
+ *
6
+ * Like the Swift path there's no `ProjectConfig` to read: the one fact CI needs
7
+ * beyond the fixed job shapes — which interpreters to test on — is declared in
8
+ * pyproject.toml, so the matrix is derived from the repo itself.
9
+ */
10
+ import path from 'node:path';
11
+ import fs from 'fs-extra';
12
+ import { renderGitHubWorkflow, renderGitLabCI } from '../../base/ci.js';
13
+ /**
14
+ * The newest interpreter the emitted matrix tests against, and the one the
15
+ * single-interpreter jobs (lint, typecheck) run on.
16
+ *
17
+ * ponytail: a constant, bumped by hand once a year when CPython ships a minor.
18
+ * Resolving "latest" at generation time would need a network call from a
19
+ * generator whose whole job is emitting text.
20
+ */
21
+ const LATEST_PYTHON = '3.13';
22
+ /** The oldest interpreter to assume when pyproject declares no floor. */
23
+ const DEFAULT_FLOOR = '3.10';
24
+ const minorOf = (version) => Number.parseInt(version.split('.')[1] ?? '0', 10);
25
+ /**
26
+ * Pull the supported-Python floor out of a pyproject.toml. A regex rather than
27
+ * a TOML parser: this is one key, and a parser is a dependency the CLI would
28
+ * carry solely to read it.
29
+ */
30
+ export function parsePyproject(contents) {
31
+ // PEP 621 `requires-python = ">=3.10"`, or Poetry's
32
+ // `python = "^3.11"` under [tool.poetry.dependencies].
33
+ const constraint = contents.match(/^requires-python\s*=\s*["']([^"']+)["']/m)?.[1] ??
34
+ contents.match(/^\s*python\s*=\s*["']([^"']+)["']/m)?.[1] ??
35
+ '';
36
+ const floor = constraint.match(/(\d+\.\d+)/)?.[1] ?? DEFAULT_FLOOR;
37
+ // A floor at or above the newest release we know about collapses the matrix
38
+ // to one entry rather than emitting a version that doesn't exist yet.
39
+ const versions = minorOf(floor) >= minorOf(LATEST_PYTHON) ? [floor] : [floor, LATEST_PYTHON];
40
+ return { pythonVersions: versions };
41
+ }
42
+ export async function readPyproject(dir) {
43
+ const filepath = path.join(dir, 'pyproject.toml');
44
+ if (!(await fs.pathExists(filepath)))
45
+ return { pythonVersions: [DEFAULT_FLOOR, LATEST_PYTHON] };
46
+ return parsePyproject(await fs.readFile(filepath, 'utf-8'));
47
+ }
48
+ /** Checkout + interpreter. `cache: pip` keys off the manifests pip resolves from. */
49
+ function pythonSetup(version) {
50
+ return ` - name: 📦 Checkout repository
51
+ uses: actions/checkout@v7
52
+
53
+ - name: 🐍 Set up Python ${version}
54
+ uses: actions/setup-python@v6
55
+ with:
56
+ python-version: '${version}'
57
+ cache: pip`;
58
+ }
59
+ export function pythonGithubJobs(project) {
60
+ return [
61
+ {
62
+ id: 'lint',
63
+ steps: `${pythonSetup(LATEST_PYTHON)}
64
+
65
+ - name: 📦 Install Ruff
66
+ run: pip install ruff
67
+
68
+ - name: 🔍 ruff check
69
+ run: ruff check --output-format=github
70
+
71
+ - name: 🎨 ruff format --check
72
+ run: ruff format --check`,
73
+ },
74
+ {
75
+ id: 'typecheck',
76
+ // mypy needs the package's own dependencies importable to resolve their
77
+ // types — running it against bare source reports every third-party import
78
+ // as missing.
79
+ steps: `${pythonSetup(LATEST_PYTHON)}
80
+
81
+ - name: 📦 Install project and mypy
82
+ run: |
83
+ pip install --upgrade pip
84
+ pip install -e .
85
+ pip install mypy
86
+
87
+ - name: 🔎 mypy
88
+ run: mypy .`,
89
+ },
90
+ {
91
+ id: 'test',
92
+ extra: ` name: pytest \${{ matrix.python-version }}
93
+ strategy:
94
+ fail-fast: false
95
+ matrix:
96
+ python-version:
97
+ ${project.pythonVersions.map((v) => ` - '${v}'`).join('\n')}`,
98
+ steps: ` - name: 📦 Checkout repository
99
+ uses: actions/checkout@v7
100
+
101
+ - name: 🐍 Set up Python \${{ matrix.python-version }}
102
+ uses: actions/setup-python@v6
103
+ with:
104
+ python-version: \${{ matrix.python-version }}
105
+ cache: pip
106
+
107
+ - name: 📦 Install project and pytest
108
+ run: |
109
+ pip install --upgrade pip
110
+ pip install -e .
111
+ pip install pytest
112
+
113
+ - name: 🧪 pytest
114
+ run: pytest`,
115
+ },
116
+ ];
117
+ }
118
+ export function renderPythonWorkflow(project) {
119
+ return renderGitHubWorkflow(pythonGithubJobs(project));
120
+ }
121
+ /**
122
+ * GitLab runs the same three commands in the official Python image. The matrix
123
+ * is dropped: GitLab's `parallel:matrix` would need a per-job image override,
124
+ * and a mirrored repo is a secondary pipeline — the version sweep lives on the
125
+ * GitHub side.
126
+ */
127
+ function pythonGitlabSpec(project) {
128
+ const image = `python:${project.pythonVersions.at(-1) ?? LATEST_PYTHON}`;
129
+ return {
130
+ image,
131
+ preamble: `variables:
132
+ PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
133
+
134
+ cache:
135
+ paths:
136
+ - .cache/pip`,
137
+ jobs: [
138
+ {
139
+ id: 'lint',
140
+ stage: 'lint',
141
+ script: ['pip install ruff', 'ruff check', 'ruff format --check'],
142
+ },
143
+ {
144
+ id: 'test',
145
+ stage: 'test',
146
+ script: ['pip install -e .', 'pip install pytest', 'pytest'],
147
+ },
148
+ ],
149
+ };
150
+ }
151
+ export function renderPythonGitLabCI(project) {
152
+ return renderGitLabCI(pythonGitlabSpec(project));
153
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Python language module — fixers (#290). One per check in ./checks.ts.
3
+ *
4
+ * ponytail: no `python-lockfile` fixer. Swift's writes the config that
5
+ * `setup --preset swift-library` would have produced, and there is no
6
+ * `python-library` preset yet (the Swift one landed in its own issue, #288).
7
+ * Inventing a ProjectConfig here just to have something to record would put a
8
+ * fabricated set of JS tool choices in a Python repo's lockfile — worse than
9
+ * doctor saying the lockfile is absent, which is 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 { readPyproject, renderPythonGitLabCI, renderPythonWorkflow } from './ci.js';
16
+ import { PYTHON_HOOKS_DIR, installPythonGitHooks } from './git-hooks.js';
17
+ import { ensurePythonGitignore } from './gitignore.js';
18
+ export const PYTHON_FIXERS = [
19
+ {
20
+ target: 'ruff',
21
+ description: 'Scaffold ruff.toml (lint + format — the linter and the formatter in one tool)',
22
+ appliesTo: ['Ruff'],
23
+ outputs: ['ruff.toml'],
24
+ canFixDrift: true,
25
+ async run({ targetDir }) {
26
+ const result = await copyPreset('ruff', targetDir);
27
+ return { filesWritten: [result.target] };
28
+ },
29
+ },
30
+ {
31
+ target: 'mypy',
32
+ description: 'Scaffold mypy.ini (strict on your code, lenient on untyped dependencies)',
33
+ appliesTo: ['mypy'],
34
+ outputs: ['mypy.ini'],
35
+ canFixDrift: true,
36
+ async run({ targetDir }) {
37
+ const result = await copyPreset('mypy', targetDir);
38
+ return { filesWritten: [result.target] };
39
+ },
40
+ },
41
+ {
42
+ target: 'pytest',
43
+ description: 'Scaffold pytest.ini (strict markers and config, warnings as errors)',
44
+ appliesTo: ['pytest'],
45
+ outputs: ['pytest.ini'],
46
+ canFixDrift: true,
47
+ async run({ targetDir }) {
48
+ const result = await copyPreset('pytest', targetDir);
49
+ return { filesWritten: [result.target] };
50
+ },
51
+ },
52
+ {
53
+ target: 'python-gitignore',
54
+ description: 'Add the Python artefacts (__pycache__, .venv, tool caches, build output) to .gitignore',
55
+ appliesTo: ['Python .gitignore'],
56
+ // Appends what's missing; never clobbers a project's own entries.
57
+ riskLevel: 'safe-merge',
58
+ outputs: ['.gitignore'],
59
+ canFixDrift: true,
60
+ async run({ targetDir }) {
61
+ return { filesWritten: await ensurePythonGitignore(targetDir) };
62
+ },
63
+ },
64
+ {
65
+ target: 'python-git-hooks',
66
+ description: `Scaffold ${PYTHON_HOOKS_DIR}/pre-commit + pre-push (ruff, mypy, pytest) and point git at them via core.hooksPath`,
67
+ appliesTo: ['Git hooks', 'Pre-push hook'],
68
+ outputs: [`${PYTHON_HOOKS_DIR}/pre-commit`, `${PYTHON_HOOKS_DIR}/pre-push`],
69
+ canFixDrift: true,
70
+ async run({ targetDir }) {
71
+ const { filesWritten, hooksPathSet } = await installPythonGitHooks(targetDir);
72
+ console.log(hooksPathSet
73
+ ? chalk.dim(` git config core.hooksPath ${PYTHON_HOOKS_DIR}`)
74
+ : chalk.yellow(` run \`git config core.hooksPath ${PYTHON_HOOKS_DIR}\` once per clone — it's local git config, not a committed file`));
75
+ return { filesWritten };
76
+ },
77
+ },
78
+ {
79
+ target: 'python-ci',
80
+ description: 'Scaffold .github/workflows/ci.yml for Python (ruff, mypy, pytest across the supported interpreters)',
81
+ appliesTo: ['GitHub Actions'],
82
+ outputs: ['.github/workflows/ci.yml'],
83
+ canFixDrift: true,
84
+ async run({ targetDir }) {
85
+ const workflowsDir = path.join(targetDir, '.github', 'workflows');
86
+ await fs.ensureDir(workflowsDir);
87
+ const workflow = renderPythonWorkflow(await readPyproject(targetDir));
88
+ await fs.writeFile(path.join(workflowsDir, 'ci.yml'), workflow);
89
+ return { filesWritten: ['.github/workflows/ci.yml'] };
90
+ },
91
+ },
92
+ {
93
+ target: 'python-gitlab-ci',
94
+ description: 'Scaffold .gitlab-ci.yml for Python (ruff + pytest on the official Python image)',
95
+ appliesTo: ['GitLab CI'],
96
+ outputs: ['.gitlab-ci.yml'],
97
+ canFixDrift: true,
98
+ async run({ targetDir }) {
99
+ const ci = renderPythonGitLabCI(await readPyproject(targetDir));
100
+ await fs.writeFile(path.join(targetDir, '.gitlab-ci.yml'), ci);
101
+ return { filesWritten: ['.gitlab-ci.yml'] };
102
+ },
103
+ },
104
+ ];
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Python git hooks (#290). Same shape as the Swift ones (#309) and for the same
3
+ * reason: Husky is an npm package, and a Python repo has no node on the path to
4
+ * run it. The committed `.githooks/` directory plus `core.hooksPath` is the
5
+ * node-free equivalent; the mechanics live in `src/base/git-hooks.ts`.
6
+ *
7
+ * Deliberately *not* `pre-commit` (the Python framework of that name): it is a
8
+ * second config file, a second pinned-tool source, and a second place the CI
9
+ * commands have to be kept in sync with. The hooks run ruff/mypy/pytest
10
+ * directly, exactly as CI does.
11
+ */
12
+ import { installGitHooks } from '../../base/git-hooks.js';
13
+ export const PYTHON_HOOKS_DIR = '.githooks';
14
+ // `ruff format` before `ruff check --fix` so the lint pass sees final layout.
15
+ // Neither rewrites anything the other undoes — one tool owns both jobs.
16
+ const PYTHON_PRE_COMMIT = `#!/bin/sh
17
+ set -e
18
+ ruff format
19
+ ruff check --fix
20
+ `;
21
+ // mypy before pytest: a type error is cheaper to surface than a test run.
22
+ const PYTHON_PRE_PUSH = `#!/bin/sh
23
+ set -e
24
+ echo "🔍 Running pre-push verify..."
25
+ ruff check
26
+ mypy .
27
+ pytest
28
+ echo "✅ Verify passed — pushing."
29
+ `;
30
+ export function installPythonGitHooks(targetDir) {
31
+ return installGitHooks(targetDir, PYTHON_HOOKS_DIR, {
32
+ preCommit: PYTHON_PRE_COMMIT,
33
+ prePush: PYTHON_PRE_PUSH,
34
+ });
35
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The Python .gitignore block, shared by the `python-gitignore` fixer and the
3
+ * check that reports it. Its own module for the same reason Swift's is (#286):
4
+ * nothing else in the module has to import from `fixers.ts` to reuse it.
5
+ */
6
+ import path from 'node:path';
7
+ import fs from 'fs-extra';
8
+ /**
9
+ * The Python artefacts that must stay out of git. Appended to an existing
10
+ * .gitignore rather than replacing it — a Python repo's ignore file usually
11
+ * carries project-specific entries worth keeping.
12
+ */
13
+ const PYTHON_GITIGNORE_BLOCK = `# Python
14
+ __pycache__/
15
+ *.py[cod]
16
+ *.egg-info/
17
+ .eggs/
18
+ build/
19
+ dist/
20
+
21
+ # Environments
22
+ .venv/
23
+ venv/
24
+ .env
25
+
26
+ # Tool caches
27
+ .pytest_cache/
28
+ .mypy_cache/
29
+ .ruff_cache/
30
+ .coverage
31
+ htmlcov/
32
+ `;
33
+ /**
34
+ * Entries whose presence means the block (or an equivalent) is already there.
35
+ * `__pycache__` and `.venv` are the expensive ones — either committed by
36
+ * accident buries the diff — and a tool cache stands in for the rest.
37
+ */
38
+ export const PYTHON_GITIGNORE_SENTINELS = ['__pycache__', '.venv', '.pytest_cache'];
39
+ export async function ensurePythonGitignore(targetDir) {
40
+ const filepath = path.join(targetDir, '.gitignore');
41
+ if (!(await fs.pathExists(filepath))) {
42
+ await fs.writeFile(filepath, PYTHON_GITIGNORE_BLOCK);
43
+ return ['.gitignore'];
44
+ }
45
+ const existing = await fs.readFile(filepath, 'utf-8');
46
+ const missing = PYTHON_GITIGNORE_SENTINELS.filter((entry) => !existing.includes(entry));
47
+ if (missing.length === 0)
48
+ return [];
49
+ // Append only what's absent, so a repo that already ignores __pycache__
50
+ // doesn't get a duplicate entry for it.
51
+ const additions = PYTHON_GITIGNORE_BLOCK.split('\n').filter((line) => line.length > 0 && !existing.includes(line));
52
+ const separator = existing.endsWith('\n') ? '' : '\n';
53
+ await fs.writeFile(filepath, `${existing}${separator}\n${additions.join('\n')}\n`);
54
+ return ['.gitignore'];
55
+ }
@@ -18,7 +18,7 @@ export const LANGUAGES = {
18
18
  label: 'Python',
19
19
  codeqlLanguages: ['python'],
20
20
  dependabotEcosystem: 'pip',
21
- supported: false,
21
+ supported: true,
22
22
  },
23
23
  perl: {
24
24
  id: 'perl',
@@ -93,7 +93,7 @@ export const SWIFT_FIXERS = [
93
93
  if (await fs.pathExists(path.join(targetDir, file)))
94
94
  return { filesWritten: [] };
95
95
  await fs.outputFile(path.join(targetDir, file), doccLandingPage(target));
96
- console.log(chalk.yellow(' add swift-docc-plugin to Package.swift to build it: .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0")'));
96
+ console.error(chalk.yellow(' add swift-docc-plugin to Package.swift to build it: .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0")'));
97
97
  return { filesWritten: [file] };
98
98
  },
99
99
  },
@@ -117,7 +117,7 @@ export const SWIFT_FIXERS = [
117
117
  canFixDrift: true,
118
118
  async run({ targetDir }) {
119
119
  const { filesWritten, hooksPathSet } = await installSwiftGitHooks(targetDir);
120
- console.log(hooksPathSet
120
+ console.error(hooksPathSet
121
121
  ? chalk.dim(` git config core.hooksPath ${SWIFT_HOOKS_DIR}`)
122
122
  : chalk.yellow(` run \`git config core.hooksPath ${SWIFT_HOOKS_DIR}\` once per clone — it's local git config, not a committed file`));
123
123
  return { filesWritten };
@@ -2,16 +2,15 @@
2
2
  * Swift git hooks (#309). Husky is an npm package, so a SwiftPM repo can't use
3
3
  * it without dragging node into a toolchain that otherwise has none. The
4
4
  * node-free equivalent is a committed `.githooks/` directory that git is pointed
5
- * at with `core.hooksPath`.
5
+ * at with `core.hooksPath` — the mechanics live in `src/base/git-hooks.ts`; only
6
+ * the hook bodies are Swift's.
6
7
  *
7
8
  * The hooks run the tools directly rather than a `verify` indirection: SwiftPM
8
9
  * has no scripts field, and inventing a Makefile target would be a third place
9
10
  * to keep the CI commands in sync (they already live in .github/workflows/ci.yml
10
11
  * and .swiftlint.yml).
11
12
  */
12
- import { execFile } from 'node:child_process';
13
- import path from 'node:path';
14
- import fs from 'fs-extra';
13
+ import { installGitHooks } from '../../base/git-hooks.js';
15
14
  export const SWIFT_HOOKS_DIR = '.githooks';
16
15
  // SwiftLint's --fix is the formatter (see ./checks.ts), so pre-commit formats
17
16
  // then lints. --quiet keeps a clean commit from printing a wall of nothing.
@@ -28,36 +27,9 @@ swift test
28
27
  swiftlint lint --strict
29
28
  echo "✅ Verify passed — pushing."
30
29
  `;
31
- /** Best-effort `git config` — a missing git or a non-repo dir is not a failure. */
32
- function gitConfig(cwd, key, value) {
33
- return new Promise((resolve) => {
34
- execFile('git', ['-C', cwd, 'config', key, value], (err) => resolve(!err));
30
+ export function installSwiftGitHooks(targetDir) {
31
+ return installGitHooks(targetDir, SWIFT_HOOKS_DIR, {
32
+ preCommit: SWIFT_PRE_COMMIT,
33
+ prePush: SWIFT_PRE_PUSH,
35
34
  });
36
35
  }
37
- /**
38
- * Writes both hooks and points git at them. Returns the files written —
39
- * `core.hooksPath` is per-clone local config, not a file, so it's reported
40
- * separately rather than pretending to be a repo change.
41
- */
42
- export async function installSwiftGitHooks(targetDir) {
43
- const hooksDir = path.join(targetDir, SWIFT_HOOKS_DIR);
44
- await fs.ensureDir(hooksDir);
45
- const filesWritten = [];
46
- for (const [name, content] of [
47
- ['pre-commit', SWIFT_PRE_COMMIT],
48
- ['pre-push', SWIFT_PRE_PUSH],
49
- ]) {
50
- const hookPath = path.join(hooksDir, name);
51
- await fs.writeFile(hookPath, content);
52
- await fs.chmod(hookPath, 0o755);
53
- filesWritten.push(`${SWIFT_HOOKS_DIR}/${name}`);
54
- }
55
- // Guard on .git: `fix --diff` shadow-runs fixers in a temp copy that excludes
56
- // .git, and that copy can land inside another repo — an unguarded `git config`
57
- // there would rewrite the *parent* repo's hooksPath during a mere preview.
58
- const isRepo = await fs.pathExists(path.join(targetDir, '.git'));
59
- const hooksPathSet = isRepo
60
- ? await gitConfig(targetDir, 'core.hooksPath', SWIFT_HOOKS_DIR)
61
- : false;
62
- return { filesWritten, hooksPathSet };
63
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -103,6 +103,8 @@
103
103
  "tooling/mcp/mcp.json.example",
104
104
  "tooling/swift/*.yml",
105
105
  "tooling/swift/*.json",
106
+ "tooling/python/*.toml",
107
+ "tooling/python/*.ini",
106
108
  "tooling/github-actions/workflows/*.yml",
107
109
  "README.md",
108
110
  "AGENTS.md"
@@ -0,0 +1,21 @@
1
+ ; mypy.ini — @rtorcato/repo-tooling Python preset
2
+ ;
3
+ ; Strict-by-default on your own code, lenient on third-party packages that ship
4
+ ; no type information. Without the second half, a single untyped dependency
5
+ ; turns every import into an error and the whole run gets ignored.
6
+
7
+ [mypy]
8
+ python_version = 3.10
9
+ strict = True
10
+ warn_unused_configs = True
11
+ warn_unreachable = True
12
+ show_error_codes = True
13
+ exclude = (^|/)(build|dist|\.venv)/
14
+
15
+ ; Tests exercise deliberately-wrong inputs, so full strictness there costs more
16
+ ; than it catches.
17
+ [mypy-tests.*]
18
+ disallow_untyped_defs = False
19
+
20
+ [mypy-tests.*.*]
21
+ disallow_untyped_defs = False
@@ -0,0 +1,15 @@
1
+ ; pytest.ini — @rtorcato/repo-tooling Python preset
2
+ ;
3
+ ; A standalone file rather than [tool.pytest.ini_options] in pyproject.toml:
4
+ ; pyproject is the project's own metadata, and merging a table into it needs a
5
+ ; TOML round-tripper this CLI does not carry. pytest reads pytest.ini first.
6
+
7
+ [pytest]
8
+ testpaths = tests
9
+ ; Fail on an unregistered marker or an unknown config key — both are typos that
10
+ ; otherwise silently skip tests.
11
+ addopts = --strict-markers --strict-config
12
+ ; A warning that nobody ever sees is a bug waiting for a major version bump.
13
+ filterwarnings =
14
+ error
15
+ ignore::DeprecationWarning:pkg_resources.*
@@ -0,0 +1,32 @@
1
+ # ruff.toml — @rtorcato/repo-tooling Python preset
2
+ #
3
+ # Ruff is both the linter and the formatter here. Its formatter is
4
+ # black-compatible, so a separate black config would be a second rewriting
5
+ # formatter fighting the first — see src/languages/python/checks.ts.
6
+
7
+ line-length = 100
8
+ target-version = "py310"
9
+
10
+ exclude = [
11
+ ".venv",
12
+ "build",
13
+ "dist",
14
+ ]
15
+
16
+ [lint]
17
+ # E/W pycodestyle, F pyflakes, I isort, UP pyupgrade, B bugbear,
18
+ # SIM simplify, RUF ruff-specific. The set flake8 + isort + pyupgrade would
19
+ # have given you, minus the four extra dependencies.
20
+ select = ["E", "W", "F", "I", "UP", "B", "SIM", "RUF"]
21
+
22
+ # E501 is the formatter's job — leaving it on makes every long string a lint
23
+ # error the formatter is not allowed to fix.
24
+ ignore = ["E501"]
25
+
26
+ [lint.per-file-ignores]
27
+ # Test files legitimately shadow names and use bare asserts.
28
+ "tests/**" = ["S101"]
29
+
30
+ [format]
31
+ quote-style = "double"
32
+ indent-style = "space"