@rtorcato/repo-tooling 3.26.0 → 3.28.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
@@ -18,7 +18,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
18
18
  | `setup --config <path>` | ✅ | `--dry-run` only | Scaffold with a full `ProjectConfig` JSON file. See `setup --config-schema`. |
19
19
  | `setup --config-schema` | ✅ | ✅ (JSON Schema) | Print the JSON Schema for `ProjectConfig`. Use to validate configs before scaffolding. |
20
20
  | `setup --dry-run` | ✅ | ✅ | Print resolved config + file list without writing. Pair with `--preset` or `--config`. |
21
- | `doctor --json` | ✅ | ✅ | Audit a project. Returns `{ directory, results: [{ check, status, detail, hint? }] }`. Status: `ok` / `drift` / `missing` / `optional-missing`. |
21
+ | `doctor --json` | ✅ | ✅ | Audit a project. Returns `{ directory, results: [{ check, status, detail, hint? }] }`. Status: `ok` / `drift` / `missing` / `optional-missing` / `declared`. |
22
22
  | `fix --json --yes` | ✅ | ✅ | Walk every doctor finding, apply fixers. Returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`. |
23
23
  | `fix <target> --json --yes` | ✅ | ✅ | Apply one fixer. Targets from `list --json`. |
24
24
  | `fix --dry-run` | ✅ | ✅ | Print what each fixer would write without writing. Combine with `--json`. |
@@ -149,3 +149,72 @@ export async function checkGitIdentity(dir, exec) {
149
149
  hint: HINT,
150
150
  };
151
151
  }
152
+ const HISTORY_CHECK = 'Git author history';
153
+ /**
154
+ * Bounded so doctor stays cheap on every invocation; the output always names
155
+ * how many commits were actually examined so "0 found" is never read as "all
156
+ * history is clean".
157
+ */
158
+ export const HISTORY_SCAN_LIMIT = 200;
159
+ /** How many offending commits to name; the count carries the rest. */
160
+ const HISTORY_SAMPLE = 3;
161
+ const HISTORY_HINT = 'These commits are already made: the address can never be verified as a secondary email, so they cannot be re-linked to a forge account without a history rewrite (#327). Fix the identity going forward (see the Git identity check) and treat the history as recorded loss.';
162
+ /**
163
+ * The retrospective half of `checkGitIdentity` (#557): that check reads the
164
+ * identity the *next* commit will use, this one scans recent history for
165
+ * commits already made with a bad one. Same classifier — `classifyGitEmail` —
166
+ * so the two can never drift. Same STATUS reasoning too: history is not
167
+ * something the repo's author can fix by editing the repo, so a finding is
168
+ * `optional-missing`, never `drift`/`missing`, and the exit code is untouched.
169
+ * Detection only — there is deliberately no fixer, because the only "fix" is a
170
+ * history rewrite no tool should offer unprompted.
171
+ */
172
+ export async function checkGitIdentityHistory(dir, exec) {
173
+ if (!(await fs.pathExists(path.join(dir, '.git')))) {
174
+ return { check: HISTORY_CHECK, status: 'ok', detail: 'not a git repository' };
175
+ }
176
+ if (process.env.CI) {
177
+ return { check: HISTORY_CHECK, status: 'ok', detail: 'skipped on CI' };
178
+ }
179
+ const git = exec ?? ((args) => realGitExec(args, dir));
180
+ // A shallow clone has almost no history; scanning its stub and reporting
181
+ // "0 found" would be a false all-clear.
182
+ if ((await git(['rev-parse', '--is-shallow-repository'])) === 'true') {
183
+ return {
184
+ check: HISTORY_CHECK,
185
+ status: 'ok',
186
+ detail: 'shallow clone — commit history is not available to scan',
187
+ };
188
+ }
189
+ // %h %ae: abbreviated hash + author email, one commit per line.
190
+ const log = await git(['log', `-${HISTORY_SCAN_LIMIT}`, '--format=%h %ae']);
191
+ if (log === null || log === '') {
192
+ return { check: HISTORY_CHECK, status: 'ok', detail: 'no commits to scan' };
193
+ }
194
+ const commits = log.split('\n').map((line) => {
195
+ const sp = line.indexOf(' ');
196
+ return { hash: line.slice(0, sp), email: line.slice(sp + 1) };
197
+ });
198
+ const bad = commits.filter(({ email }) => {
199
+ const verdict = classifyGitEmail(email);
200
+ return verdict === 'placeholder' || verdict === 'generated';
201
+ });
202
+ const scanned = `the last ${commits.length} commit${commits.length === 1 ? '' : 's'}`;
203
+ if (bad.length === 0) {
204
+ return {
205
+ check: HISTORY_CHECK,
206
+ status: 'ok',
207
+ detail: `no placeholder or machine-derived author emails in ${scanned}`,
208
+ };
209
+ }
210
+ const sample = bad
211
+ .slice(0, HISTORY_SAMPLE)
212
+ .map(({ hash, email }) => `${hash} (${email})`)
213
+ .join(', ');
214
+ return {
215
+ check: HISTORY_CHECK,
216
+ status: 'optional-missing',
217
+ detail: `${bad.length} of ${scanned} carry a placeholder or machine-derived author email — e.g. ${sample}`,
218
+ hint: HISTORY_HINT,
219
+ };
220
+ }
@@ -16,7 +16,7 @@ import { checkAgentUser } from '../../base/agent-user.js';
16
16
  import { checkGitHubSettings } from '../../base/github-settings.js';
17
17
  import { checkLoopLabels } from '../../base/labels.js';
18
18
  import { checkMilestones } from '../../base/milestones.js';
19
- import { checkGitIdentity } from '../../base/git-identity.js';
19
+ import { checkGitIdentity, checkGitIdentityHistory } from '../../base/git-identity.js';
20
20
  import { checkCopiedAssets } from '../utils/copied-assets.js';
21
21
  import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
22
22
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
@@ -156,6 +156,40 @@ function demoteDeclined(results, lock) {
156
156
  };
157
157
  });
158
158
  }
159
+ // Declared exceptions (#558): a failing check the lock names is reported as
160
+ // `declared` with its reason — shown, never hidden, but no longer failing the
161
+ // run. An exception naming a check this run doesn't know is itself drift:
162
+ // otherwise a typo silently does nothing and a check rename silently
163
+ // un-suppresses a finding, and both are invisible.
164
+ function applyExceptions(results, lock) {
165
+ const exceptions = lock?.exceptions;
166
+ if (!exceptions)
167
+ return results;
168
+ const known = new Set(results.map((r) => r.check));
169
+ const overlaid = results.map((r) => {
170
+ const reason = exceptions[r.check];
171
+ if (!reason || r.status === 'ok')
172
+ return r;
173
+ // Hint deliberately dropped: the deviation is declared, so "how to fix it"
174
+ // is exactly the noise the exception exists to retire.
175
+ return {
176
+ check: r.check,
177
+ status: 'declared',
178
+ detail: `${r.detail} — declared exception: ${reason}`,
179
+ };
180
+ });
181
+ for (const name of Object.keys(exceptions)) {
182
+ if (known.has(name))
183
+ continue;
184
+ overlaid.push({
185
+ check: 'Declared exceptions',
186
+ status: 'drift',
187
+ detail: `.repo-tooling.json declares an exception for "${name}", which is not a check this run knows`,
188
+ hint: 'A typo, or a check that was renamed or removed — fix or delete the entry in `exceptions`',
189
+ });
190
+ }
191
+ return overlaid;
192
+ }
159
193
  // The language-agnostic checks (src/base): repo hygiene, git hooks, CI,
160
194
  // security, and GitHub repo-settings that apply to any repo regardless of
161
195
  // language. Declared once and run for every project — a language module layers
@@ -168,6 +202,8 @@ async function runBaseChecks(dir, lock, opts) {
168
202
  results.push(await checkCopiedAssets(dir));
169
203
  results.push(await checkNestedLanguages(dir, opts.language));
170
204
  results.push(await checkGitIdentity(dir));
205
+ // The retrospective half (#557): commits already made with a bad identity.
206
+ results.push(await checkGitIdentityHistory(dir));
171
207
  results.push(await checkEditorConfig(dir));
172
208
  results.push(await checkFile(dir, COMMITLINT_FILE_CHECK));
173
209
  if (opts.hooks) {
@@ -238,7 +274,7 @@ export async function runDoctor(dir, skillsDir) {
238
274
  skillsDir,
239
275
  })),
240
276
  ];
241
- return demoteDeclined(results, lock);
277
+ return applyExceptions(demoteDeclined(results, lock), lock);
242
278
  }
243
279
  // Swift suite (#286): base checks plus the module's own. Swift repos have no
244
280
  // package.json, so nothing JS-shaped runs.
@@ -262,7 +298,7 @@ export async function runDoctor(dir, skillsDir) {
262
298
  })),
263
299
  ...(await runSwiftChecks(targetDir)),
264
300
  ];
265
- return demoteDeclined(results, lock);
301
+ return applyExceptions(demoteDeclined(results, lock), lock);
266
302
  }
267
303
  // Python suite (#290): same shape as Swift — base checks plus the module's
268
304
  // own, and nothing JS-shaped, because a Python repo has no package.json.
@@ -286,7 +322,7 @@ export async function runDoctor(dir, skillsDir) {
286
322
  })),
287
323
  ...(await runPythonChecks(targetDir)),
288
324
  ];
289
- return demoteDeclined(results, lock);
325
+ return applyExceptions(demoteDeclined(results, lock), lock);
290
326
  }
291
327
  // Perl suite (#289): same shape as Swift and Python — base checks plus the
292
328
  // module's own, and nothing JS-shaped, because a distribution has no
@@ -312,7 +348,7 @@ export async function runDoctor(dir, skillsDir) {
312
348
  })),
313
349
  ...(await runPerlChecks(targetDir)),
314
350
  ];
315
- return demoteDeclined(results, lock);
351
+ return applyExceptions(demoteDeclined(results, lock), lock);
316
352
  }
317
353
  // JS suite: the module's own checks, then the shared base ones. Only the
318
354
  // JS-shaped checks are listed here — re-listing the base suite is what made
@@ -371,13 +407,14 @@ export async function runDoctor(dir, skillsDir) {
371
407
  codeqlLanguages: languageModule.codeqlLanguages,
372
408
  skillsDir,
373
409
  })));
374
- return demoteDeclined(results, lock);
410
+ return applyExceptions(demoteDeclined(results, lock), lock);
375
411
  }
376
412
  const STATUS_ICONS = {
377
413
  ok: chalk.green('✅'),
378
414
  drift: chalk.yellow('⚠️ '),
379
415
  missing: chalk.red('❌'),
380
416
  'optional-missing': chalk.gray('➖'),
417
+ declared: chalk.blue('📝'),
381
418
  };
382
419
  function statusLabel(status) {
383
420
  switch (status) {
@@ -389,6 +426,8 @@ function statusLabel(status) {
389
426
  return chalk.red('missing');
390
427
  case 'optional-missing':
391
428
  return chalk.gray('not configured');
429
+ case 'declared':
430
+ return chalk.blue('declared');
392
431
  }
393
432
  }
394
433
  const MAX_NEXT_STEP_SUGGESTIONS = 8;
@@ -421,6 +460,7 @@ export function summarize(results) {
421
460
  drift: results.filter((r) => r.status === 'drift').length,
422
461
  missing: results.filter((r) => r.status === 'missing').length,
423
462
  optionalMissing: results.filter((r) => r.status === 'optional-missing').length,
463
+ declared: results.filter((r) => r.status === 'declared').length,
424
464
  };
425
465
  }
426
466
  export async function doctorCommand(options = {}) {
@@ -440,7 +480,7 @@ export async function doctorCommand(options = {}) {
440
480
  }
441
481
  const summary = summarize(results);
442
482
  console.log();
443
- console.log(` Summary: ${chalk.green(`${summary.ok} ok`)}, ${chalk.yellow(`${summary.drift} drift`)}, ${chalk.red(`${summary.missing} missing`)}, ${chalk.gray(`${summary.optionalMissing} not configured`)}\n`);
483
+ 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`);
444
484
  const suggestions = nextStepSuggestions(results, await detectLanguage(dir));
445
485
  if (suggestions.length > 0) {
446
486
  console.log(chalk.bold(' Next steps:'));
@@ -436,7 +436,10 @@ export async function fixCommand(target, options = {}) {
436
436
  console.log();
437
437
  return;
438
438
  }
439
- const fixable = results.filter((r) => r.status !== 'ok');
439
+ // `declared` is a deviation the lockfile records on purpose (#558) — a bulk
440
+ // fix must not "repair" it. A targeted `fix <target>` still can: naming the
441
+ // fixer is the same explicit override the declined-in-lock path gets.
442
+ const fixable = results.filter((r) => r.status !== 'ok' && r.status !== 'declared');
440
443
  if (fixable.length === 0) {
441
444
  if (json)
442
445
  return emitJson(null);
@@ -117,6 +117,11 @@ export function lockfileSchema() {
117
117
  },
118
118
  },
119
119
  },
120
+ exceptions: {
121
+ type: 'object',
122
+ additionalProperties: { type: 'string', minLength: 1 },
123
+ description: 'Declared exceptions: doctor check name → the reason this repo deliberately deviates. The reason is mandatory and non-empty — doctor shows the check as `declared` with it (never hidden) and stops failing the run for it. An entry naming a check doctor does not run is itself reported as drift.',
124
+ },
120
125
  writtenBy: {
121
126
  type: 'string',
122
127
  description: 'Package name and version that last wrote this file.',
@@ -193,6 +198,7 @@ export async function writeLockfile(dir, config, assets) {
193
198
  ...(existing?.aiLoop ? { aiLoop: existing.aiLoop } : {}),
194
199
  ...(existing?.requiredSkills ? { requiredSkills: existing.requiredSkills } : {}),
195
200
  ...(existing?.mcp ? { mcp: existing.mcp } : {}),
201
+ ...(existing?.exceptions ? { exceptions: existing.exceptions } : {}),
196
202
  writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
197
203
  writtenAt: new Date().toISOString(),
198
204
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.26.0",
3
+ "version": "3.28.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": [
@@ -30,6 +30,8 @@ npx @rtorcato/repo-tooling doctor --json # confirm clean
30
30
  prompt to **No**; `--yes` is required to overwrite. Show `fix <target> --diff` first.
31
31
  - `missing` — required and absent → fix it.
32
32
  - `optional-missing` — opt-in tool not configured. Only fix if the user wants that tool.
33
+ - `declared` — a real deviation the repo's `.repo-tooling.json` `exceptions` records on
34
+ purpose, with its reason. Leave it alone; it doesn't fail the run.
33
35
 
34
36
  `fix` returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`.
35
37