@rtorcato/repo-tooling 3.38.1 → 3.38.3

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
@@ -98,6 +98,8 @@ npx @rtorcato/repo-tooling fix claude-skills --yes --json
98
98
 
99
99
  Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `claude-skills`, the one fixer whose blast radius is outside the repo.
100
100
 
101
+ The same goes for every `optional-missing` finding: a bulk `fix` records it `skipped`, because optional tools are often mutually exclusive (Biome / ESLint / Prettier / Oxlint, semantic-release / Changesets / Release Please) and installing them all is never the intent (#630). Name the one you want — `fix editorconfig --yes`.
102
+
101
103
  A fixer may also **refuse** — the target file holds something the generator cannot reproduce, so overwriting would destroy it. Today that is `dependabot` against a config with repo-local `ignore:` rules (#422), and `biome` against a `biome.json` that does not parse, whose settings cannot be merged (#587). A targeted `fix dependabot` then exits 1 with `error: dependabot-ignore-rules`; a bulk `fix --yes` records it `skipped` and carries on with the rest. Neither `--yes` nor `--json` overrides it — resolve the named rules by hand and re-run.
102
104
 
103
105
  ## Source-of-truth files in the repo
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
3
3
  import { BLOCK_START, hasStaleAgentBlock } from '../cli/generators/agent-rules.js';
4
4
  import { BADGE_START, hasPublicOnlyBadges } from '../cli/generators/badges.js';
5
5
  import { claudeSkillStatus, SHIPPED_SKILLS, skillDiffCommand, } from '../cli/generators/claude-skills.js';
6
- import { DEPENDABOT_CONFIG_PATHS, dependabotIgnoreRules } from '../cli/generators/security.js';
6
+ import { DEPENDABOT_CONFIG_PATHS, dependabotConfigDeltas, dependabotEcosystemFor, dependabotIgnoreRules, } from '../cli/generators/security.js';
7
7
  import { detectNestedLanguages } from '../cli/utils/detect-language.js';
8
8
  /**
9
9
  * Root-only detection is the decision (#317), not an oversight — but it used to
@@ -244,12 +244,7 @@ export async function checkDependabot(dir) {
244
244
  // depends on it. Flag any config that predates it so `fix dependabot` can
245
245
  // bring the pair up to standard.
246
246
  const content = await fs.readFile(candidatePath, 'utf8');
247
- const deltas = [];
248
- for (const group of ['production-minor', 'dev-minor', 'major-updates']) {
249
- if (!new RegExp(`^\\s*${group}:`, 'm').test(content)) {
250
- deltas.push(`missing \`${group}\` group`);
251
- }
252
- }
247
+ const deltas = dependabotConfigDeltas(content, await dependabotEcosystemFor(dir));
253
248
  const automergePath = path.join(dir, '.github', 'workflows', 'dependabot-automerge.yml');
254
249
  if (await fs.pathExists(automergePath)) {
255
250
  // Pre-#423 workflows gated on the semver type alone, so production
@@ -14,6 +14,7 @@
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
16
  import chalk from 'chalk';
17
+ import fs from 'fs-extra';
17
18
  import inquirer from 'inquirer';
18
19
  import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
19
20
  import { installClaudeSkill, resolveSkillsDir, SHIPPED_SKILLS, skillDiffCommand, } from '../cli/generators/claude-skills.js';
@@ -21,10 +22,10 @@ import { generateBrand } from '../cli/generators/brand.js';
21
22
  import { generateCommunityHealth } from '../cli/generators/community-health.js';
22
23
  import { generateCommitlintConfig } from '../cli/generators/git.js';
23
24
  import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
24
- import { findDependabotIgnoreRules, generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, } from '../cli/generators/security.js';
25
+ import { DEPENDABOT_AUTOMERGE_WORKFLOW, dependabotConfigDeltas, dependabotEcosystemFor, findDependabotIgnoreRules, generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, readDependabotConfig, } from '../cli/generators/security.js';
25
26
  import { classifyCopiedAssets } from '../cli/utils/copied-assets.js';
26
27
  import { copyPreset } from '../cli/utils/copy-preset.js';
27
- import { detectLanguage } from '../cli/utils/detect-language.js';
28
+ import { detectAuditLanguage } from '../cli/utils/detect-language.js';
28
29
  import { resolveLanguageModule } from '../languages/registry.js';
29
30
  import { applyGithubSettings, applyReleaseEnvironment, RELEASE_ENV_CHECK, RELEASE_GATE_CHECK, } from './github-settings.js';
30
31
  import { applyLoopLabels } from './labels.js';
@@ -46,9 +47,16 @@ export class FixerAbort extends Error {
46
47
  this.name = 'FixerAbort';
47
48
  }
48
49
  }
49
- /** The repo's language module, resolved from its marker files. */
50
+ /**
51
+ * The repo's language module, resolved from its marker files. No marker → no
52
+ * manifest ecosystem and no CodeQL matrix, rather than npm/JS guessed (#632).
53
+ */
50
54
  async function moduleFor(targetDir) {
51
- return resolveLanguageModule(await detectLanguage(targetDir));
55
+ return (resolveLanguageModule(await detectAuditLanguage(targetDir)) ?? {
56
+ label: 'unrecognized-language',
57
+ codeqlLanguages: [],
58
+ dependabotEcosystem: null,
59
+ });
52
60
  }
53
61
  /**
54
62
  * Where to install user-global skills, asking only when nothing resolves. Under
@@ -149,6 +157,15 @@ export const BASE_FIXERS = [
149
157
  outputs: ['.github/dependabot.yml', '.github/workflows/dependabot-automerge.yml'],
150
158
  canFixDrift: true,
151
159
  async run({ targetDir }) {
160
+ const ecosystem = await dependabotEcosystemFor(targetDir);
161
+ // The config is already canonical for this repo's ecosystem, so only the
162
+ // workflow half drifts. Leave the config — and its repo-authored comments
163
+ // — alone rather than regenerate a file with nothing to change (#631).
164
+ const existing = await readDependabotConfig(targetDir);
165
+ if (existing && dependabotConfigDeltas(existing.content, ecosystem).length === 0) {
166
+ await fs.outputFile(path.join(targetDir, '.github', 'workflows', 'dependabot-automerge.yml'), DEPENDABOT_AUTOMERGE_WORKFLOW);
167
+ return { filesWritten: ['.github/workflows/dependabot-automerge.yml'] };
168
+ }
152
169
  // The template owns the whole file but emits no `ignore:` block, so
153
170
  // regenerating deletes any repo-local ignore rule — silently, and with
154
171
  // nothing in `doctor` to report the loss afterwards, because from the
@@ -159,8 +176,7 @@ export const BASE_FIXERS = [
159
176
  if (ignored) {
160
177
  throw new FixerAbort('dependabot-ignore-rules', `refusing to overwrite ${ignored.file} — it has ${ignored.rules.length} \`ignore:\` rule(s) the canonical config does not reproduce: ${ignored.rules.join(', ')}`, `re-add the \`ignore:\` block after regenerating, or delete it from ${ignored.file} to accept the loss — then re-run \`fix dependabot\``);
161
178
  }
162
- const { dependabotEcosystem } = await moduleFor(targetDir);
163
- return { filesWritten: await generateDependabotConfig(targetDir, dependabotEcosystem) };
179
+ return { filesWritten: await generateDependabotConfig(targetDir, ecosystem) };
164
180
  },
165
181
  },
166
182
  {
@@ -11,7 +11,7 @@ import { readPyproject, renderPythonWorkflow } from '../../languages/python/ci.j
11
11
  import { resolveLanguageModule } from '../../languages/registry.js';
12
12
  import { SWIFT_GIT_HOOKS, runSwiftChecks } from '../../languages/swift/checks.js';
13
13
  import { readSwiftPackage, renderSwiftWorkflow } from '../../languages/swift/ci.js';
14
- import { detectLanguage } from '../utils/detect-language.js';
14
+ import { detectAuditLanguage } from '../utils/detect-language.js';
15
15
  import { checkAgentUser } from '../../base/agent-user.js';
16
16
  import { checkGitHubSettings } from '../../base/github-settings.js';
17
17
  import { checkLoopLabels } from '../../base/labels.js';
@@ -253,20 +253,21 @@ export async function runDoctor(dir, skillsDir) {
253
253
  const lock = await readLockfile(targetDir);
254
254
  // Per-module dispatch (#285): the base checks (repo hygiene, CI, security,
255
255
  // GitHub settings) apply to any repo and run for every language. A supported
256
- // module layers its own checks on top. 'unknown' (bare dir mid-setup)
257
- // resolves to JS so a fresh repo runs the full suite.
258
- const language = await detectLanguage(targetDir);
256
+ // module layers its own checks on top. 'unknown' (no marker) gets the base
257
+ // suite only — JS checks against a C++ or Go repo are all noise (#632).
258
+ const language = await detectAuditLanguage(targetDir);
259
259
  const languageModule = resolveLanguageModule(language);
260
- // Every language in the registry has a module as of #289, so nothing reaches
261
- // this today. It stays as the on-ramp: a language is added to the registry
262
- // with `supported: false` first, and until its module lands its repos get the
260
+ // Also the on-ramp for a new language: it is added to the registry with
261
+ // `supported: false` first, and until its module lands its repos get the
263
262
  // full base suite rather than the wholesale skip this replaced.
264
- if (!languageModule.supported) {
263
+ if (!languageModule?.supported) {
265
264
  const results = [
266
265
  {
267
266
  check: 'language',
268
267
  status: 'ok',
269
- detail: `detected ${languageModule.label} — running language-agnostic checks; ${languageModule.label}-specific checks land with its module (#139)`,
268
+ detail: languageModule
269
+ ? `detected ${languageModule.label} — running language-agnostic checks; ${languageModule.label}-specific checks land with its module (#139)`
270
+ : 'no language marker (package.json, Package.swift, pyproject.toml, cpanfile, …) — running language-agnostic checks only',
270
271
  },
271
272
  // hooks: null — guessing a hook convention would nag every repo with a
272
273
  // fix target that doesn't exist.
@@ -275,7 +276,7 @@ export async function runDoctor(dir, skillsDir) {
275
276
  badges: { audience: 'public', fixTarget: null },
276
277
  presetWorkflow: null,
277
278
  language,
278
- codeqlLanguages: languageModule.codeqlLanguages,
279
+ codeqlLanguages: languageModule?.codeqlLanguages ?? [],
279
280
  skillsDir,
280
281
  })),
281
282
  ];
@@ -525,7 +526,7 @@ export async function doctorCommand(options = {}) {
525
526
  const summary = summarize(results);
526
527
  console.log();
527
528
  console.log(` Summary: ${chalk.green(`${summary.ok} ok`)}, ${chalk.yellow(`${summary.drift} drift`)}, ${chalk.red(`${summary.missing} missing`)}, ${chalk.gray(`${summary.optionalMissing} not configured`)}, ${chalk.blue(`${summary.declared} declared`)}\n`);
528
- const suggestions = nextStepSuggestions(results, await detectLanguage(dir));
529
+ const suggestions = nextStepSuggestions(results, await detectAuditLanguage(dir));
529
530
  if (suggestions.length > 0) {
530
531
  console.log(chalk.bold(' Next steps:'));
531
532
  for (const s of suggestions) {
@@ -14,20 +14,21 @@ import { FIXERS, readPackageJson } from '../../languages/js/fixers.js';
14
14
  import { PERL_FIXERS } from '../../languages/perl/fixers.js';
15
15
  import { PYTHON_FIXERS } from '../../languages/python/fixers.js';
16
16
  import { SWIFT_FIXERS } from '../../languages/swift/fixers.js';
17
- import { detectLanguage } from '../utils/detect-language.js';
17
+ import { detectAuditLanguage } from '../utils/detect-language.js';
18
18
  /** The module-specific fixer set per detected language (#286, #289, #290, #303). */
19
19
  const LANGUAGE_FIXERS = {
20
+ js: FIXERS,
20
21
  swift: SWIFT_FIXERS,
21
22
  python: PYTHON_FIXERS,
22
23
  perl: PERL_FIXERS,
23
24
  };
24
25
  /**
25
26
  * The fixers that apply to a repo, by detected language: the language-agnostic
26
- * base set plus the module's own. Anything without a module of its own
27
- * (including a bare dir mid-setup) gets JS, the historical default.
27
+ * base set plus the module's own. A repo with no marker gets the base set only —
28
+ * the JS fixers would run against a missing package.json (#632).
28
29
  */
29
30
  function fixersForLanguage(language) {
30
- return [...BASE_FIXERS, ...(LANGUAGE_FIXERS[language] ?? FIXERS)];
31
+ return [...BASE_FIXERS, ...(LANGUAGE_FIXERS[language] ?? [])];
31
32
  }
32
33
  /** Every fixer across every language — for `--list` and the unknown-target hint. */
33
34
  const ALL_FIXERS = [
@@ -133,8 +134,16 @@ async function previewFixer(fixer, result, targetDir, pkg, lock) {
133
134
  },
134
135
  });
135
136
  // assumeYes: a preview must never prompt.
137
+ let written;
136
138
  try {
137
- await fixer.run({ targetDir: tmpDir, pkg, result, lock, assumeYes: true });
139
+ ;
140
+ ({ filesWritten: written } = await fixer.run({
141
+ targetDir: tmpDir,
142
+ pkg,
143
+ result,
144
+ lock,
145
+ assumeYes: true,
146
+ }));
138
147
  }
139
148
  catch (err) {
140
149
  // A fixer that refuses has nothing to preview. Swallow it here rather than
@@ -146,14 +155,21 @@ async function previewFixer(fixer, result, targetDir, pkg, lock) {
146
155
  }
147
156
  const previews = [];
148
157
  const seen = new Set();
149
- for (const output of fixer.outputs) {
158
+ // Declared outputs miss globbed writes (apps/docs/**), so also take what the
159
+ // shadow run reported writing.
160
+ const candidates = [
161
+ ...fixer.outputs,
162
+ ...written.map((f) => (path.isAbsolute(f) ? path.relative(tmpDir, f) : f)),
163
+ ];
164
+ for (const output of candidates) {
150
165
  const rel = outputToRelativePath(output);
151
- if (seen.has(rel))
166
+ if (seen.has(rel) || rel.startsWith('..'))
152
167
  continue;
153
168
  seen.add(rel);
154
169
  const tmpPath = path.join(tmpDir, rel);
155
170
  const realPath = path.join(targetDir, rel);
156
- if (!(await fs.pathExists(tmpPath)))
171
+ const stat = await fs.stat(tmpPath).catch(() => null);
172
+ if (!stat?.isFile())
157
173
  continue;
158
174
  const newContent = await fs.readFile(tmpPath, 'utf-8');
159
175
  const existed = await fs.pathExists(realPath);
@@ -197,12 +213,34 @@ function printPreviews(previews) {
197
213
  }
198
214
  }
199
215
  }
216
+ /**
217
+ * What a fixer would actually change. Shadow-runs it like `--diff` does, except
218
+ * for safe-add fixers: some of those write remote state or the home dir, so they
219
+ * are never executed — and since they only create missing files, the declared
220
+ * outputs that already exist are dropped instead.
221
+ */
222
+ async function dryRunFiles(fixer, result, targetDir, pkg, lock) {
223
+ if ((fixer.riskLevel ?? 'destructive') === 'safe-add') {
224
+ const missing = [];
225
+ for (const out of fixer.outputs) {
226
+ if (!(await fs.pathExists(path.join(targetDir, outputToRelativePath(out))))) {
227
+ missing.push(out);
228
+ }
229
+ }
230
+ return missing;
231
+ }
232
+ const previews = await previewFixer(fixer, result, targetDir, pkg, lock);
233
+ return previews.filter((p) => p.kind !== 'unchanged').map((p) => p.path);
234
+ }
200
235
  async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, opts) {
201
236
  if (dryRun) {
237
+ const files = await dryRunFiles(fixer, result, targetDir, pkg, lock);
202
238
  if (!silent) {
203
- console.log(chalk.cyan(` [dry-run] would write: ${fixer.outputs.join(', ')}`));
239
+ console.log(chalk.cyan(files.length > 0
240
+ ? ` [dry-run] would write: ${files.join(', ')}`
241
+ : ' [dry-run] nothing to write'));
204
242
  }
205
- return { filesWritten: [], dryRun: true };
243
+ return { filesWritten: files, dryRun: true };
206
244
  }
207
245
  const { filesWritten } = await fixer.run({ targetDir, pkg, result, lock, ...opts });
208
246
  if (!silent && filesWritten.length > 0) {
@@ -332,7 +370,7 @@ export async function fixCommand(target, options = {}) {
332
370
  }
333
371
  const pkg = await readPackageJson(targetDir);
334
372
  const lock = await readLockfile(targetDir);
335
- const fixers = fixersForLanguage(await detectLanguage(targetDir));
373
+ const fixers = fixersForLanguage(await detectAuditLanguage(targetDir));
336
374
  // Same --skills-dir the claude-skills fixer writes to, so the diagnosis fix
337
375
  // acts on and the install it performs agree on one directory (#485).
338
376
  const results = await runDoctor(targetDir, options.skillsDir);
@@ -464,8 +502,10 @@ export async function fixCommand(target, options = {}) {
464
502
  if (!silent) {
465
503
  console.log(` ${chalk.bold(result.check)} (${result.status}) → ${fixer.target}`);
466
504
  }
467
- // Opt-in only — `--yes` must not sweep in a fixer that writes outside the repo.
468
- if (fixer.explicitOnly) {
505
+ // Opt-in only — `--yes` must not sweep in a fixer that writes outside the repo,
506
+ // nor an optional tool: optional alternatives (Biome/ESLint/Prettier/Oxlint,
507
+ // semantic-release/Changesets/Release Please) are mutually exclusive (#630).
508
+ if (fixer.explicitOnly || result.status === 'optional-missing') {
469
509
  actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
470
510
  if (!silent) {
471
511
  console.log(chalk.gray(` skipped — run \`fix ${fixer.target}\` explicitly`));
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'node:path';
3
+ import { hookHasUncommented } from '../../base/checks.js';
3
4
  export const PRE_PUSH_HOOK_CONTENT = `echo "🔍 Running pre-push verify..."
4
5
  pnpm verify
5
6
  STATUS=$?
@@ -25,10 +26,7 @@ export async function generateHuskyConfig(config, targetDir) {
25
26
  // Pre-commit hook. husky v10 format: just the command — the v9 shebang +
26
27
  // `. "$(dirname ...)/_/husky.sh"` bootstrap is deprecated (warns on every
27
28
  // hook run in v9, fails outright in v10).
28
- const preCommitPath = path.join(huskyDir, 'pre-commit');
29
- const preCommitContent = 'npx lint-staged\n';
30
- await fs.writeFile(preCommitPath, preCommitContent);
31
- await fs.chmod(preCommitPath, 0o755);
29
+ await ensureHookRuns(path.join(huskyDir, 'pre-commit'), /\blint-staged\b/, 'npx lint-staged\n');
32
30
  // Pre-push hook — only when the package.json already has a `verify` script.
33
31
  // In the setup flow, generatePackageJson runs before this and writes verify
34
32
  // when 2+ tools are enabled. In the `fix husky` path, a pre-existing verify
@@ -63,9 +61,24 @@ export async function generateHuskyConfig(config, targetDir) {
63
61
  export async function generatePrePushHook(targetDir) {
64
62
  const huskyDir = path.join(targetDir, '.husky');
65
63
  await fs.ensureDir(huskyDir);
66
- const prePushPath = path.join(huskyDir, 'pre-push');
67
- await fs.writeFile(prePushPath, PRE_PUSH_HOOK_CONTENT);
68
- await fs.chmod(prePushPath, 0o755);
64
+ await ensureHookRuns(path.join(huskyDir, 'pre-push'), /\bpnpm\s+verify\b/, PRE_PUSH_HOOK_CONTENT, 'pnpm verify\n');
65
+ }
66
+ /**
67
+ * Write a missing hook; append the command to an existing hook that lacks it.
68
+ * Never replaces a hand-written hook (#629). Husky runs hooks with `sh -e`, so
69
+ * an appended command still fails the hook when it fails.
70
+ */
71
+ async function ensureHookRuns(hookPath, runs, fresh, line = fresh) {
72
+ if (!(await fs.pathExists(hookPath))) {
73
+ await fs.writeFile(hookPath, fresh);
74
+ }
75
+ else {
76
+ const existing = await fs.readFile(hookPath, 'utf-8');
77
+ if (hookHasUncommented(existing, runs))
78
+ return;
79
+ await fs.writeFile(hookPath, `${existing.replace(/\n*$/, '\n')}${line}`);
80
+ }
81
+ await fs.chmod(hookPath, 0o755);
69
82
  }
70
83
  /** Package versions the commit-msg hook needs on disk to run at all. */
71
84
  export const COMMITLINT_DEPS = {
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import { renderCodeQLWorkflow } from '../../base/ci.js';
4
4
  import { LANGUAGES } from '../../languages/registry.js';
5
+ import { detectLanguage } from '../utils/detect-language.js';
5
6
  /**
6
7
  * The canonical dependency-update standard shared by every @rtorcato repo.
7
8
  *
@@ -73,6 +74,37 @@ export const DEPENDABOT_CONFIG_PATHS = [
73
74
  '.github/dependabot.yml',
74
75
  '.github/dependabot.yaml',
75
76
  ];
77
+ /**
78
+ * The manifest ecosystem a repo's dependabot.yml should cover, or null when it
79
+ * has none — Perl, or a repo with no recognised manifest at all (#631). Unlike
80
+ * `resolveLanguageModule`, `unknown` does not fall back to npm here: a C++ repo
81
+ * with no package.json has nothing for an npm block to update.
82
+ */
83
+ export async function dependabotEcosystemFor(dir) {
84
+ const language = await detectLanguage(dir);
85
+ return language === 'unknown' ? null : LANGUAGES[language].dependabotEcosystem;
86
+ }
87
+ /**
88
+ * How an existing dependabot.yml falls short of `dependabotConfig(ecosystem)`.
89
+ * The canonical groups live on the manifest block, so a repo with no manifest
90
+ * ecosystem is not expected to carry them (#631).
91
+ */
92
+ export function dependabotConfigDeltas(content, ecosystem) {
93
+ if (ecosystem === null)
94
+ return [];
95
+ return ['production-minor', 'dev-minor', 'major-updates']
96
+ .filter((group) => !new RegExp(`^\\s*${group}:`, 'm').test(content))
97
+ .map((group) => `missing \`${group}\` group`);
98
+ }
99
+ /** The existing dependabot config, in Dependabot's resolution order, or null. */
100
+ export async function readDependabotConfig(dir) {
101
+ for (const file of DEPENDABOT_CONFIG_PATHS) {
102
+ const candidate = path.join(dir, file);
103
+ if (await fs.pathExists(candidate))
104
+ return { file, content: await fs.readFile(candidate, 'utf8') };
105
+ }
106
+ return null;
107
+ }
76
108
  /**
77
109
  * The `ignore:` rules an existing dependabot.yml carries, named by
78
110
  * `dependency-name` (#422).
@@ -236,14 +268,11 @@ export const DEPENDABOT_FILES = [
236
268
  * they live in — or null when there is nothing to lose (#422).
237
269
  */
238
270
  export async function findDependabotIgnoreRules(targetDir) {
239
- for (const file of DEPENDABOT_CONFIG_PATHS) {
240
- const candidate = path.join(targetDir, file);
241
- if (!(await fs.pathExists(candidate)))
242
- continue;
243
- const rules = dependabotIgnoreRules(await fs.readFile(candidate, 'utf8'));
244
- return rules.length > 0 ? { file, rules } : null;
245
- }
246
- return null;
271
+ const existing = await readDependabotConfig(targetDir);
272
+ if (!existing)
273
+ return null;
274
+ const rules = dependabotIgnoreRules(existing.content);
275
+ return rules.length > 0 ? { file: existing.file, rules } : null;
247
276
  }
248
277
  /**
249
278
  * Scaffold the canonical Dependabot setup: the grouped \`dependabot.yml\` **and**
@@ -32,9 +32,12 @@ export async function generateTypedocConfig(pkg, targetDir) {
32
32
  };
33
33
  await fs.writeJson(path.join(targetDir, 'typedoc.json'), config, { spaces: 2 });
34
34
  }
35
+ /** The path written, or null when a docs.yml already exists — never replaced (#629). */
35
36
  export async function generateTypedocWorkflow(targetDir) {
36
- const workflowsDir = path.join(targetDir, '.github', 'workflows');
37
- await fs.ensureDir(workflowsDir);
38
- await fs.writeFile(path.join(workflowsDir, 'docs.yml'), DOCS_WORKFLOW);
39
- return '.github/workflows/docs.yml';
37
+ const rel = '.github/workflows/docs.yml';
38
+ const workflowPath = path.join(targetDir, rel);
39
+ if (await fs.pathExists(workflowPath))
40
+ return null;
41
+ await fs.outputFile(workflowPath, DOCS_WORKFLOW);
42
+ return rel;
40
43
  }
@@ -32,6 +32,20 @@ export async function detectLanguage(dir) {
32
32
  }
33
33
  return 'unknown';
34
34
  }
35
+ /**
36
+ * The language doctor/fix audit a repo as. Like `detectLanguage`, except a fresh
37
+ * dir — nothing but dotfiles (`.git`, the lockfile) — is audited as JS, the
38
+ * default `setup` scaffolds, so its full suite shows what is still to come. A
39
+ * dir with real content and no marker (C++, Go, a README) stays 'unknown' and
40
+ * gets the base checks only (#632).
41
+ */
42
+ export async function detectAuditLanguage(dir) {
43
+ const language = await detectLanguage(dir);
44
+ if (language !== 'unknown')
45
+ return language;
46
+ const entries = await fs.readdir(dir).catch(() => []);
47
+ return entries.every((entry) => entry.startsWith('.')) ? 'js' : 'unknown';
48
+ }
35
49
  /** Never worth descending into, and expensive when we do. */
36
50
  const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'target', 'vendor', '.build']);
37
51
  /**
@@ -695,7 +695,7 @@ export const FIXERS = [
695
695
  await generateTypedocConfig(pkg, targetDir);
696
696
  const workflow = await generateTypedocWorkflow(targetDir);
697
697
  const pkgPath = path.join(targetDir, 'package.json');
698
- const filesWritten = ['typedoc.json', workflow];
698
+ const filesWritten = workflow ? ['typedoc.json', workflow] : ['typedoc.json'];
699
699
  if (await fs.pathExists(pkgPath)) {
700
700
  const pkgData = (await fs.readJson(pkgPath));
701
701
  const scripts = pkgData.scripts ?? {};
@@ -29,9 +29,11 @@ export const LANGUAGES = {
29
29
  },
30
30
  };
31
31
  /**
32
- * Resolve the module for a detected language. `unknown` (a bare dir mid-setup)
33
- * maps to JS — the historical default so a fresh repo still gets the full suite.
32
+ * Resolve the module for a detected language, or null for `unknown` — a repo
33
+ * with no marker (C++, Go, a README) gets the base suite only, never JS (#632).
34
+ * A fresh, empty dir never arrives as `unknown`: `detectAuditLanguage` audits
35
+ * it as JS, and setup's language picker defaults it to JS itself.
34
36
  */
35
37
  export function resolveLanguageModule(language) {
36
- return language === 'unknown' ? LANGUAGES.js : LANGUAGES[language];
38
+ return language === 'unknown' ? null : LANGUAGES[language];
37
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.38.1",
3
+ "version": "3.38.3",
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": [