@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:'));
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -436,7 +436,10 @@ export async function fixCommand(target, options = {}) {
|
|
|
436
436
|
console.log();
|
|
437
437
|
return;
|
|
438
438
|
}
|
|
439
|
-
|
|
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
|
@@ -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
|
|