@rtorcato/repo-tooling 3.28.0 → 3.30.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 +2 -0
- package/dist/base/agent-user.js +6 -6
- package/dist/cli/commands/doctor.js +45 -8
- package/dist/cli/commands/fix-targets.js +2 -2
- package/dist/cli/commands/fix.js +3 -3
- package/dist/cli/commands/setup.js +86 -26
- package/dist/cli/index.js +5 -0
- package/dist/cli/utils/copied-assets.js +1 -1
- package/dist/cli/utils/json-schema.js +64 -0
- package/dist/cli/utils/lockfile.js +131 -85
- package/dist/cli/utils/reference-rules.js +125 -0
- package/dist/languages/js/fixers.js +2 -2
- package/package.json +1 -1
- package/skills/ai-issue-loop/SKILL.md +4 -3
- package/skills/ai-workflow/SKILL.md +1 -1
- package/skills/repo-tooling/SKILL.md +1 -1
package/AGENTS.md
CHANGED
|
@@ -19,6 +19,8 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
|
|
|
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
21
|
| `doctor --json` | ✅ | ✅ | Audit a project. Returns `{ directory, results: [{ check, status, detail, hint? }] }`. Status: `ok` / `drift` / `missing` / `optional-missing` / `declared`. |
|
|
22
|
+
| `doctor --rules-from <owner/repo>` | ✅ | ✅ | Report how this repo's `config`/`rules` differ from a reference repo's `.repo-tooling.json` (read over `gh`). Informational only — adds a `rulesReference` key to the JSON, never a check result, never affects the exit code. |
|
|
23
|
+
| `setup --from <owner/repo>` | ❌ (seeds the wizard) | `--dry-run` only | Seed the wizard's defaults from a reference repo's recorded config. Seeded, not skipped — every question is still asked. |
|
|
22
24
|
| `fix --json --yes` | ✅ | ✅ | Walk every doctor finding, apply fixers. Returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`. |
|
|
23
25
|
| `fix <target> --json --yes` | ✅ | ✅ | Apply one fixer. Targets from `list --json`. |
|
|
24
26
|
| `fix --dry-run` | ✅ | ✅ | Print what each fixer would write without writing. Combine with `--json`. |
|
package/dist/base/agent-user.js
CHANGED
|
@@ -31,15 +31,15 @@ export async function checkAgentUser(dir, agentUser, exec) {
|
|
|
31
31
|
return {
|
|
32
32
|
check: CHECK,
|
|
33
33
|
status: 'ok',
|
|
34
|
-
detail: 'not applicable — no aiLoop.agentUser in .repo-tooling.json',
|
|
34
|
+
detail: 'not applicable — no rules.aiLoop.agentUser in .repo-tooling.json',
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
if (!LOGIN.test(agentUser)) {
|
|
38
38
|
return {
|
|
39
39
|
check: CHECK,
|
|
40
40
|
status: 'drift',
|
|
41
|
-
detail: `aiLoop.agentUser "${agentUser}" is not a valid GitHub login`,
|
|
42
|
-
hint: 'Fix or remove aiLoop.agentUser in .repo-tooling.json',
|
|
41
|
+
detail: `rules.aiLoop.agentUser "${agentUser}" is not a valid GitHub login`,
|
|
42
|
+
hint: 'Fix or remove rules.aiLoop.agentUser in .repo-tooling.json',
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
// Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
|
|
@@ -52,15 +52,15 @@ export async function checkAgentUser(dir, agentUser, exec) {
|
|
|
52
52
|
return {
|
|
53
53
|
check: CHECK,
|
|
54
54
|
status: 'ok',
|
|
55
|
-
detail: `aiLoop.agentUser "${agentUser}" is an assignable collaborator`,
|
|
55
|
+
detail: `rules.aiLoop.agentUser "${agentUser}" is an assignable collaborator`,
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
if (/HTTP 404/.test(r.stderr)) {
|
|
59
59
|
return {
|
|
60
60
|
check: CHECK,
|
|
61
61
|
status: 'drift',
|
|
62
|
-
detail: `aiLoop.agentUser "${agentUser}" is not an assignable collaborator — the loop skills will silently assign nothing`,
|
|
63
|
-
hint: `Add the account as a collaborator (\`gh api -X PUT repos/{owner}/{repo}/collaborators/${agentUser}\`) or remove aiLoop.agentUser from .repo-tooling.json`,
|
|
62
|
+
detail: `rules.aiLoop.agentUser "${agentUser}" is not an assignable collaborator — the loop skills will silently assign nothing`,
|
|
63
|
+
hint: `Add the account as a collaborator (\`gh api -X PUT repos/{owner}/{repo}/collaborators/${agentUser}\`) or remove rules.aiLoop.agentUser from .repo-tooling.json`,
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
// Offline, unauthenticated, or gh missing — not evidence of drift.
|
|
@@ -19,6 +19,7 @@ import { checkMilestones } from '../../base/milestones.js';
|
|
|
19
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
|
+
import { compareRulesWithReference } from '../utils/reference-rules.js';
|
|
22
23
|
import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
|
|
23
24
|
import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
24
25
|
import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
@@ -136,7 +137,9 @@ function checkLockfile(lock) {
|
|
|
136
137
|
return {
|
|
137
138
|
check: 'lockfile',
|
|
138
139
|
status: 'ok',
|
|
139
|
-
|
|
140
|
+
// The stamp only ever describes the tool-written `record` subtree — the
|
|
141
|
+
// human-written `rules` half is deliberately unstamped (#559).
|
|
142
|
+
detail: `.repo-tooling.json v${lock.version} (record written by ${lock.record.writtenBy})`,
|
|
140
143
|
};
|
|
141
144
|
}
|
|
142
145
|
// Lockfile-driven demotion: if the lock records an intentional opt-out for a
|
|
@@ -162,7 +165,7 @@ function demoteDeclined(results, lock) {
|
|
|
162
165
|
// otherwise a typo silently does nothing and a check rename silently
|
|
163
166
|
// un-suppresses a finding, and both are invisible.
|
|
164
167
|
function applyExceptions(results, lock) {
|
|
165
|
-
const exceptions = lock?.exceptions;
|
|
168
|
+
const exceptions = lock?.rules?.exceptions;
|
|
166
169
|
if (!exceptions)
|
|
167
170
|
return results;
|
|
168
171
|
const known = new Set(results.map((r) => r.check));
|
|
@@ -221,7 +224,7 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
221
224
|
// ai-issue-loop label colours/descriptions (#446) — same seam, same self-skip.
|
|
222
225
|
results.push(await checkLoopLabels(dir));
|
|
223
226
|
// aiLoop.agentUser assignability (#530) — same seam, same self-skip.
|
|
224
|
-
results.push(await checkAgentUser(dir, lock?.aiLoop?.agentUser));
|
|
227
|
+
results.push(await checkAgentUser(dir, lock?.rules?.aiLoop?.agentUser));
|
|
225
228
|
results.push(await checkGitLabCI(dir));
|
|
226
229
|
results.push(await checkCodeowners(dir));
|
|
227
230
|
results.push(await checkCommunityHealth(dir));
|
|
@@ -231,13 +234,13 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
231
234
|
results.push(await checkClaudeSkills(opts.skillsDir));
|
|
232
235
|
// #533: gated on `aiLoop`, which is already the "this repo uses the pipeline"
|
|
233
236
|
// signal, so a repo that doesn't gets no line at all rather than an empty one.
|
|
234
|
-
if (lock?.aiLoop && lock.requiredSkills?.length) {
|
|
235
|
-
results.push(await checkRequiredSkills(lock.requiredSkills, opts.skillsDir));
|
|
237
|
+
if (lock?.rules?.aiLoop && lock.rules.requiredSkills?.length) {
|
|
238
|
+
results.push(await checkRequiredSkills(lock.rules.requiredSkills, opts.skillsDir));
|
|
236
239
|
}
|
|
237
240
|
// #534: advisory. Absent `mcp.recommended` means the repo has nothing to say
|
|
238
241
|
// about MCP, which is not a finding.
|
|
239
|
-
if (lock?.mcp?.recommended?.length) {
|
|
240
|
-
results.push(await checkRecommendedMcp(dir, lock.mcp.recommended));
|
|
242
|
+
if (lock?.rules?.mcp?.recommended?.length) {
|
|
243
|
+
results.push(await checkRecommendedMcp(dir, lock.rules.mcp.recommended));
|
|
241
244
|
}
|
|
242
245
|
results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
|
|
243
246
|
results.push(await checkCoverageUpload(dir));
|
|
@@ -463,11 +466,43 @@ export function summarize(results) {
|
|
|
463
466
|
declared: results.filter((r) => r.status === 'declared').length,
|
|
464
467
|
};
|
|
465
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Print the `--rules-from` comparison (#563). Deliberately not a CheckResult:
|
|
471
|
+
* two repos legitimately differ, so a difference is never a finding about either
|
|
472
|
+
* one, and keeping it out of `results` is what makes "never affects the exit
|
|
473
|
+
* code" structural rather than a promise the next check has to remember.
|
|
474
|
+
*/
|
|
475
|
+
function printRulesComparison(comparison) {
|
|
476
|
+
console.log(chalk.cyan(`\n📐 Rules vs ${comparison.reference}`), chalk.gray('(informational — differences are not drift and do not affect the exit code)\n'));
|
|
477
|
+
if (!comparison.compared) {
|
|
478
|
+
console.log(` ${chalk.gray('➖')} ${chalk.gray(`not compared — ${comparison.reason}`)}\n`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const differences = comparison.differences ?? [];
|
|
482
|
+
if (differences.length === 0) {
|
|
483
|
+
console.log(` ${chalk.green('✅')} ${chalk.gray('identical — no differences to report')}\n`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const show = (v) => (v === undefined ? chalk.dim('(absent)') : JSON.stringify(v));
|
|
487
|
+
for (const d of differences) {
|
|
488
|
+
console.log(` ${chalk.bold(d.path)}`);
|
|
489
|
+
console.log(` ${chalk.gray('here:')} ${show(d.local)}`);
|
|
490
|
+
console.log(` ${chalk.gray(`${comparison.reference}:`)} ${show(d.reference)}`);
|
|
491
|
+
}
|
|
492
|
+
console.log(chalk.gray(`\n ${differences.length} difference(s).\n`));
|
|
493
|
+
}
|
|
466
494
|
export async function doctorCommand(options = {}) {
|
|
467
495
|
const dir = options.directory ?? process.cwd();
|
|
468
496
|
const results = await runDoctor(dir, options.skillsDir);
|
|
497
|
+
const comparison = options.rulesFrom
|
|
498
|
+
? await compareRulesWithReference(dir, options.rulesFrom)
|
|
499
|
+
: null;
|
|
469
500
|
if (options.json) {
|
|
470
|
-
console.log(JSON.stringify({
|
|
501
|
+
console.log(JSON.stringify({
|
|
502
|
+
directory: path.resolve(dir),
|
|
503
|
+
results,
|
|
504
|
+
...(comparison ? { rulesReference: comparison } : {}),
|
|
505
|
+
}, null, 2));
|
|
471
506
|
}
|
|
472
507
|
else {
|
|
473
508
|
console.log(chalk.cyan(`\n🩺 Diagnosing ${path.resolve(dir)} against ${PACKAGE} presets...\n`));
|
|
@@ -489,6 +524,8 @@ export async function doctorCommand(options = {}) {
|
|
|
489
524
|
}
|
|
490
525
|
console.log();
|
|
491
526
|
}
|
|
527
|
+
if (comparison)
|
|
528
|
+
printRulesComparison(comparison);
|
|
492
529
|
}
|
|
493
530
|
const summary = summarize(results);
|
|
494
531
|
const exitCode = summary.drift > 0 || summary.missing > 0 ? 1 : 0;
|
|
@@ -124,7 +124,7 @@ export function getFixTargetForCheck(checkName, language) {
|
|
|
124
124
|
export function declinedInLock(lock, checkName) {
|
|
125
125
|
if (!lock)
|
|
126
126
|
return false;
|
|
127
|
-
const c = lock.config;
|
|
127
|
+
const c = lock.record.config;
|
|
128
128
|
switch (checkName) {
|
|
129
129
|
case 'TypeScript':
|
|
130
130
|
return c.typescript?.enabled === false;
|
|
@@ -186,7 +186,7 @@ export function declinedInLock(lock, checkName) {
|
|
|
186
186
|
* the lockfile already reflects the change.
|
|
187
187
|
*/
|
|
188
188
|
export function lockfilePatchForTarget(target, lock) {
|
|
189
|
-
const c = lock.config;
|
|
189
|
+
const c = lock.record.config;
|
|
190
190
|
switch (target) {
|
|
191
191
|
case 'biome':
|
|
192
192
|
if (c.linting.tool === 'biome' || c.linting.tool === 'both')
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -291,7 +291,7 @@ export async function fixCommand(target, options = {}) {
|
|
|
291
291
|
}
|
|
292
292
|
process.exit(1);
|
|
293
293
|
}
|
|
294
|
-
const files = computeFileList(resyncLock.config);
|
|
294
|
+
const files = computeFileList(resyncLock.record.config);
|
|
295
295
|
if (!silent) {
|
|
296
296
|
console.log(chalk.cyan(`\n🔄 Resync from ${LOCKFILE_NAME} (${files.length} files in scope)\n`));
|
|
297
297
|
}
|
|
@@ -320,8 +320,8 @@ export async function fixCommand(target, options = {}) {
|
|
|
320
320
|
return;
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
|
-
await generateConfigs(resyncLock.config, targetDir);
|
|
324
|
-
await writeLockfile(targetDir, resyncLock.config);
|
|
323
|
+
await generateConfigs(resyncLock.record.config, targetDir);
|
|
324
|
+
await writeLockfile(targetDir, resyncLock.record.config);
|
|
325
325
|
if (json) {
|
|
326
326
|
console.log(JSON.stringify({ directory: targetDir, mode: 'resync', dryRun: false, files }, null, 2));
|
|
327
327
|
}
|
|
@@ -8,6 +8,7 @@ import { detectLanguage } from '../utils/detect-language.js';
|
|
|
8
8
|
import { formatGeneratedFiles } from '../utils/format.js';
|
|
9
9
|
import { installDependencies } from '../utils/install.js';
|
|
10
10
|
import { LOCKFILE_NAME, writeLockfile } from '../utils/lockfile.js';
|
|
11
|
+
import { fetchReferenceLockfile } from '../utils/reference-rules.js';
|
|
11
12
|
import { buildPresetConfig, computeFileList, CONFIG_SCHEMA, PRESET_NAMES, validateProjectConfig, } from './setup-presets.js';
|
|
12
13
|
/**
|
|
13
14
|
* The opinionated extras a preset turns on, in the order they're offered for
|
|
@@ -63,11 +64,36 @@ async function reviewPresetConfig(config) {
|
|
|
63
64
|
}
|
|
64
65
|
return reviewed;
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Read a reference repo's recorded config to seed the wizard (#563). Only
|
|
69
|
+
* `record.config` — the answers — crosses over: `assets` and the
|
|
70
|
+
* `writtenBy`/`writtenAt` stamps describe the reference's own history, and this
|
|
71
|
+
* repo's lockfile gets its own on the first write. `rules` is left behind too,
|
|
72
|
+
* since an `exceptions` entry excuses a deviation in the repo that wrote it.
|
|
73
|
+
*
|
|
74
|
+
* `null` means the reference could not be read — reported plainly and declined,
|
|
75
|
+
* the way `doctor --rules-from` reports "not compared". Falling through to an
|
|
76
|
+
* unseeded wizard would be worse: the user asked for that repo's answers, and
|
|
77
|
+
* generic defaults would be written as though they had confirmed them.
|
|
78
|
+
*/
|
|
79
|
+
async function seedFromReference(reference) {
|
|
80
|
+
const result = await fetchReferenceLockfile(reference);
|
|
81
|
+
if (!result.ok) {
|
|
82
|
+
console.error(chalk.red(`\n❌ Cannot seed from ${reference} — ${result.reason}.`));
|
|
83
|
+
console.error(chalk.gray(' Re-run without --from to answer the wizard from scratch.\n'));
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
console.log(chalk.cyan(`\n🌱 Seeding defaults from ${reference} — every answer is still yours to change.\n`));
|
|
87
|
+
return result.lockfile.record.config;
|
|
88
|
+
}
|
|
66
89
|
/** `null` means the run was declined, not that it failed — see promptForConfig. */
|
|
67
90
|
async function resolveConfig(options) {
|
|
68
91
|
if (options.config && options.preset) {
|
|
69
92
|
console.warn(chalk.yellow('⚠️ Both --config and --preset given; --config wins.\n'));
|
|
70
93
|
}
|
|
94
|
+
if (options.from && (options.config || options.preset)) {
|
|
95
|
+
console.warn(chalk.yellow('⚠️ --from seeds the wizard, which --config/--preset skip; ignoring --from.\n'));
|
|
96
|
+
}
|
|
71
97
|
if (options.config) {
|
|
72
98
|
const configPath = path.resolve(options.config);
|
|
73
99
|
if (!(await fs.pathExists(configPath))) {
|
|
@@ -76,10 +102,12 @@ async function resolveConfig(options) {
|
|
|
76
102
|
const raw = await fs.readJson(configPath);
|
|
77
103
|
// Accept the `.repo-tooling.json` lockfile itself as the config source, so a
|
|
78
104
|
// repo needs only one file: the lockfile already embeds the full
|
|
79
|
-
// ProjectConfig under `config
|
|
80
|
-
// separate
|
|
81
|
-
const candidate = typeof raw === 'object' && raw !== null && '
|
|
82
|
-
? raw.config
|
|
105
|
+
// ProjectConfig — under `record.config` since v4, top-level `config` before
|
|
106
|
+
// (#559). Unwrap it here rather than requiring a separate config file (#271).
|
|
107
|
+
const candidate = typeof raw === 'object' && raw !== null && 'version' in raw
|
|
108
|
+
? (raw.record?.config ??
|
|
109
|
+
raw.config ??
|
|
110
|
+
raw)
|
|
83
111
|
: raw;
|
|
84
112
|
const { valid, errors } = validateProjectConfig(candidate);
|
|
85
113
|
if (!valid) {
|
|
@@ -97,7 +125,14 @@ async function resolveConfig(options) {
|
|
|
97
125
|
return config;
|
|
98
126
|
return reviewPresetConfig(config);
|
|
99
127
|
}
|
|
100
|
-
|
|
128
|
+
let seed;
|
|
129
|
+
if (options.from) {
|
|
130
|
+
const seeded = await seedFromReference(options.from);
|
|
131
|
+
if (seeded === null)
|
|
132
|
+
return null;
|
|
133
|
+
seed = seeded;
|
|
134
|
+
}
|
|
135
|
+
return promptForConfig(path.resolve(options.directory), seed);
|
|
101
136
|
}
|
|
102
137
|
export async function setupProject(options) {
|
|
103
138
|
if (options.configSchema) {
|
|
@@ -163,7 +198,7 @@ const SCAFFOLDABLE = ['js', 'swift'];
|
|
|
163
198
|
* like. Languages setup can't scaffold yet are still offered — hiding them reads
|
|
164
199
|
* as "repo-tooling is JS-only" when the honest answer is "not yet" (#139).
|
|
165
200
|
*/
|
|
166
|
-
async function promptForLanguage(targetDir) {
|
|
201
|
+
async function promptForLanguage(targetDir, seed) {
|
|
167
202
|
const detected = await detectLanguage(targetDir);
|
|
168
203
|
const { language } = await inquirer.prompt([
|
|
169
204
|
{
|
|
@@ -176,8 +211,10 @@ async function promptForLanguage(targetDir) {
|
|
|
176
211
|
: `${module.label} (${module.supported ? 'doctor/fix only — no setup preset yet' : 'setup lands with its module'})`,
|
|
177
212
|
value: module.id,
|
|
178
213
|
})),
|
|
214
|
+
// What this dir already looks like beats the reference's language: the
|
|
215
|
+
// files on disk are evidence, the reference is only a suggestion.
|
|
179
216
|
// 'unknown' is a bare dir mid-setup — JS is the historical default.
|
|
180
|
-
default: detected
|
|
217
|
+
default: detected !== 'unknown' ? detected : (seed?.language ?? 'js'),
|
|
181
218
|
},
|
|
182
219
|
]);
|
|
183
220
|
return LANGUAGES[language];
|
|
@@ -194,9 +231,16 @@ function explainNotScaffoldable(module) {
|
|
|
194
231
|
console.log(chalk.gray(` ${module.label} setup presets are tracked at\n` +
|
|
195
232
|
' https://github.com/rtorcato/repo-tooling/issues/139\n'));
|
|
196
233
|
}
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
234
|
+
/**
|
|
235
|
+
* `null` when the chosen language has no scaffolding yet; nothing is written.
|
|
236
|
+
*
|
|
237
|
+
* @param seed A reference repo's recorded config (#563). It moves the *defaults*
|
|
238
|
+
* only — every question is still asked, so seeding can never write an answer
|
|
239
|
+
* the user did not see. `projectName` is deliberately never seeded: a new repo
|
|
240
|
+
* is not the reference repo.
|
|
241
|
+
*/
|
|
242
|
+
async function promptForConfig(targetDir, seed) {
|
|
243
|
+
const language = await promptForLanguage(targetDir, seed);
|
|
200
244
|
// Gate on SCAFFOLDABLE, not `module.supported` — Swift is supported (#286)
|
|
201
245
|
// but has no preset yet (#288), and falling through here would write a
|
|
202
246
|
// package.json into a Swift repo.
|
|
@@ -226,6 +270,18 @@ async function promptForConfig(targetDir) {
|
|
|
226
270
|
// Turborepo only makes sense in a pnpm-workspace monorepo, so the prompt is
|
|
227
271
|
// only offered when one is already present in the target dir.
|
|
228
272
|
const hasWorkspace = await fs.pathExists(path.join(targetDir, 'pnpm-workspace.yaml'));
|
|
273
|
+
// Two questions ask for something the config stores as a set of booleans, so
|
|
274
|
+
// their seeded default has to be derived rather than read.
|
|
275
|
+
const seededReleaseTool = !seed
|
|
276
|
+
? 'semantic-release'
|
|
277
|
+
: seed.semanticRelease
|
|
278
|
+
? 'semantic-release'
|
|
279
|
+
: seed.changesets
|
|
280
|
+
? 'changesets'
|
|
281
|
+
: seed.releasePlease
|
|
282
|
+
? 'release-please'
|
|
283
|
+
: 'none';
|
|
284
|
+
const seededOrchestrator = !seed ? 'turbo' : seed.turborepo ? 'turbo' : seed.nx ? 'nx' : 'none';
|
|
229
285
|
const answers = await inquirer.prompt([
|
|
230
286
|
{
|
|
231
287
|
type: 'input',
|
|
@@ -238,6 +294,7 @@ async function promptForConfig(targetDir) {
|
|
|
238
294
|
type: 'select',
|
|
239
295
|
name: 'projectType',
|
|
240
296
|
message: '🏗️ What type of project are you building?',
|
|
297
|
+
default: seed?.projectType,
|
|
241
298
|
choices: [
|
|
242
299
|
{ name: '📚 Library/Package', value: 'library' },
|
|
243
300
|
{ name: '🌐 Web Application', value: 'web-app' },
|
|
@@ -250,7 +307,7 @@ async function promptForConfig(targetDir) {
|
|
|
250
307
|
type: 'confirm',
|
|
251
308
|
name: 'useTypeScript',
|
|
252
309
|
message: '📘 Do you want to use TypeScript?',
|
|
253
|
-
default: true,
|
|
310
|
+
default: seed?.typescript.enabled ?? true,
|
|
254
311
|
},
|
|
255
312
|
{
|
|
256
313
|
type: 'select',
|
|
@@ -275,6 +332,7 @@ async function promptForConfig(targetDir) {
|
|
|
275
332
|
}
|
|
276
333
|
return baseChoices;
|
|
277
334
|
},
|
|
335
|
+
default: seed?.typescript.config,
|
|
278
336
|
when: (answers) => answers.useTypeScript,
|
|
279
337
|
},
|
|
280
338
|
{
|
|
@@ -287,7 +345,7 @@ async function promptForConfig(targetDir) {
|
|
|
287
345
|
{ name: '🔥 Both Biome + ESLint', value: 'both' },
|
|
288
346
|
{ name: '❌ None', value: 'none' },
|
|
289
347
|
],
|
|
290
|
-
default: 'biome',
|
|
348
|
+
default: seed?.linting.tool ?? 'biome',
|
|
291
349
|
},
|
|
292
350
|
{
|
|
293
351
|
type: 'select',
|
|
@@ -300,13 +358,14 @@ async function promptForConfig(targetDir) {
|
|
|
300
358
|
}
|
|
301
359
|
return choices;
|
|
302
360
|
},
|
|
361
|
+
default: seed?.linting.eslintConfig,
|
|
303
362
|
when: (answers) => answers.lintingTool === 'eslint' || answers.lintingTool === 'both',
|
|
304
363
|
},
|
|
305
364
|
{
|
|
306
365
|
type: 'confirm',
|
|
307
366
|
name: 'oxlint',
|
|
308
367
|
message: '🦀 Also run Oxlint alongside (50–100× faster than ESLint)?',
|
|
309
|
-
default: false,
|
|
368
|
+
default: seed?.oxlint ?? false,
|
|
310
369
|
when: (answers) => answers.lintingTool !== 'none',
|
|
311
370
|
},
|
|
312
371
|
{
|
|
@@ -320,7 +379,7 @@ async function promptForConfig(targetDir) {
|
|
|
320
379
|
{ name: '🌲 Cypress (E2E)', value: 'cypress' },
|
|
321
380
|
{ name: '❌ None', value: 'none' },
|
|
322
381
|
],
|
|
323
|
-
default: 'vitest',
|
|
382
|
+
default: seed?.testing.framework ?? 'vitest',
|
|
324
383
|
},
|
|
325
384
|
{
|
|
326
385
|
type: 'select',
|
|
@@ -332,19 +391,19 @@ async function promptForConfig(targetDir) {
|
|
|
332
391
|
{ name: '🔄 Both', value: 'both' },
|
|
333
392
|
],
|
|
334
393
|
when: (answers) => answers.testingFramework === 'jest',
|
|
335
|
-
default: 'node',
|
|
394
|
+
default: seed?.testing.environment ?? 'node',
|
|
336
395
|
},
|
|
337
396
|
{
|
|
338
397
|
type: 'confirm',
|
|
339
398
|
name: 'gitHooks',
|
|
340
399
|
message: '🪝 Set up Git hooks (Husky + lint-staged)?',
|
|
341
|
-
default: true,
|
|
400
|
+
default: seed?.gitHooks ?? true,
|
|
342
401
|
},
|
|
343
402
|
{
|
|
344
403
|
type: 'confirm',
|
|
345
404
|
name: 'commitLint',
|
|
346
405
|
message: '📝 Set up conventional commit linting?',
|
|
347
|
-
default: true,
|
|
406
|
+
default: seed?.commitLint ?? true,
|
|
348
407
|
when: (answers) => answers.gitHooks,
|
|
349
408
|
},
|
|
350
409
|
{
|
|
@@ -357,40 +416,40 @@ async function promptForConfig(targetDir) {
|
|
|
357
416
|
{ name: '🙏 Release Please (Google, release-PR-driven)', value: 'release-please' },
|
|
358
417
|
{ name: '❌ None', value: 'none' },
|
|
359
418
|
],
|
|
360
|
-
default:
|
|
419
|
+
default: seededReleaseTool,
|
|
361
420
|
when: (answers) => answers.projectType === 'library',
|
|
362
421
|
},
|
|
363
422
|
{
|
|
364
423
|
type: 'confirm',
|
|
365
424
|
name: 'treeshakeCheck',
|
|
366
425
|
message: '🌳 Add a tree-shake verification check (apps/treeshake-check)?',
|
|
367
|
-
default: false,
|
|
426
|
+
default: seed?.treeshakeCheck ?? false,
|
|
368
427
|
when: (answers) => answers.projectType === 'library',
|
|
369
428
|
},
|
|
370
429
|
{
|
|
371
430
|
type: 'confirm',
|
|
372
431
|
name: 'publint',
|
|
373
432
|
message: '📦 Add publint to lint your package before publishing?',
|
|
374
|
-
default: true,
|
|
433
|
+
default: seed?.publint ?? true,
|
|
375
434
|
when: (answers) => answers.projectType === 'library',
|
|
376
435
|
},
|
|
377
436
|
{
|
|
378
437
|
type: 'confirm',
|
|
379
438
|
name: 'badges',
|
|
380
439
|
message: '🔖 Add status badges (CI, npm, coverage, license) to the README?',
|
|
381
|
-
default: true,
|
|
440
|
+
default: seed?.badges ?? true,
|
|
382
441
|
},
|
|
383
442
|
{
|
|
384
443
|
type: 'confirm',
|
|
385
444
|
name: 'securityAutomation',
|
|
386
445
|
message: '🛡️ Include security automation (Dependabot + CodeQL)?',
|
|
387
|
-
default: true,
|
|
446
|
+
default: seed?.securityAutomation ?? true,
|
|
388
447
|
},
|
|
389
448
|
{
|
|
390
449
|
type: 'confirm',
|
|
391
450
|
name: 'aiSetup',
|
|
392
451
|
message: '🤖 Add AI agent rules (AGENTS.md, CLAUDE.md, Cursor, Copilot, Claude skill)?',
|
|
393
|
-
default: true,
|
|
452
|
+
default: seed?.aiSetup ?? true,
|
|
394
453
|
},
|
|
395
454
|
{
|
|
396
455
|
type: 'select',
|
|
@@ -401,14 +460,14 @@ async function promptForConfig(targetDir) {
|
|
|
401
460
|
{ name: '🔷 Nx (nx.json)', value: 'nx' },
|
|
402
461
|
{ name: '❌ None', value: 'none' },
|
|
403
462
|
],
|
|
404
|
-
default:
|
|
463
|
+
default: seededOrchestrator,
|
|
405
464
|
when: () => hasWorkspace,
|
|
406
465
|
},
|
|
407
466
|
{
|
|
408
467
|
type: 'confirm',
|
|
409
468
|
name: 'tailwind',
|
|
410
469
|
message: '🎨 Add Tailwind CSS v4 (PostCSS plugin + globals.css)?',
|
|
411
|
-
default: true,
|
|
470
|
+
default: seed?.tailwind ?? true,
|
|
412
471
|
// Only meaningful for frontend project types.
|
|
413
472
|
when: (answers) => answers.projectType === 'web-app' ||
|
|
414
473
|
answers.projectType === 'react-app' ||
|
|
@@ -431,13 +490,14 @@ async function promptForConfig(targetDir) {
|
|
|
431
490
|
}
|
|
432
491
|
return choices;
|
|
433
492
|
},
|
|
493
|
+
default: seed?.bundler,
|
|
434
494
|
when: (answers) => answers.projectType !== 'nextjs-app', // Next.js has its own bundler
|
|
435
495
|
},
|
|
436
496
|
{
|
|
437
497
|
type: 'confirm',
|
|
438
498
|
name: 'bun',
|
|
439
499
|
message: '🥟 Target the Bun runtime (bunfig.toml + Bun-typed tsconfig)?',
|
|
440
|
-
default: false,
|
|
500
|
+
default: seed?.bun ?? false,
|
|
441
501
|
// A runtime flag, not a project type — offer it for Node-compatible
|
|
442
502
|
// library/API targets, not the browser-framework types.
|
|
443
503
|
when: (answers) => answers.projectType === 'library' || answers.projectType === 'node-api',
|
package/dist/cli/index.js
CHANGED
|
@@ -34,6 +34,8 @@ program
|
|
|
34
34
|
.option('--config <path>', 'Skip prompts; read a ProjectConfig or .repo-tooling.json lockfile from <path>')
|
|
35
35
|
.option('--dry-run', 'Print the resolved config and file list, write nothing')
|
|
36
36
|
.option('--config-schema', 'Print the JSON Schema for ProjectConfig and exit')
|
|
37
|
+
// Seeds the wizard, never skips it (#563) — every answer is still yours.
|
|
38
|
+
.option('--from <owner/repo>', "Seed the wizard's defaults from that repo's .repo-tooling.json instead of the built-in ones (read via `gh`)")
|
|
37
39
|
.action(setupProject);
|
|
38
40
|
program
|
|
39
41
|
.command('copy <config>')
|
|
@@ -321,6 +323,9 @@ program
|
|
|
321
323
|
// The read side of `fix --skills-dir` (#485). No --yes/--json requirement
|
|
322
324
|
// here: doctor never writes, so an unresolved directory is just reported.
|
|
323
325
|
.option('--skills-dir <path>', 'Where `fix claude-skills` installs user-global agent skills (default: ~/.claude/skills). Pass the same path `fix` was given, or the skill reports as not installed')
|
|
326
|
+
// Rules are per-repo (#563): this compares them against another repo's, and
|
|
327
|
+
// only reports. Never drift, never a fixer, never part of the exit code.
|
|
328
|
+
.option('--rules-from <owner/repo>', "Report how this repo's config and rules differ from that repo's .repo-tooling.json (informational; read via `gh`)")
|
|
324
329
|
.action(doctorCommand);
|
|
325
330
|
program
|
|
326
331
|
.command('fix [target]')
|
|
@@ -33,7 +33,7 @@ export async function classifyCopiedAssets(dir) {
|
|
|
33
33
|
const current = await hashFile(path.join(dir, preset.target));
|
|
34
34
|
if (current === null)
|
|
35
35
|
continue;
|
|
36
|
-
const recorded = lock?.assets?.[name];
|
|
36
|
+
const recorded = lock?.record.assets?.[name];
|
|
37
37
|
// Unmodified since the copy, so whether it's stale is purely a question of
|
|
38
38
|
// what this package ships now. A source we can't read (shouldn't happen)
|
|
39
39
|
// falls back to the recorded hash — "no news", not drift.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON Schema validator covering exactly the keyword subset `lockfileSchema()`
|
|
3
|
+
* and `CONFIG_SCHEMA` use: type (object/array/string/integer/boolean), enum,
|
|
4
|
+
* minLength, required, properties, additionalProperties (`false` or a schema)
|
|
5
|
+
* and items. Returns one message per problem; an empty array means valid.
|
|
6
|
+
*
|
|
7
|
+
* Hand-rolled because the repo has no JSON Schema validator and the schemas lean
|
|
8
|
+
* on seven keywords — not enough to justify an ajv dependency. It lives in src
|
|
9
|
+
* rather than in a test because a reference repo's lockfile is untrusted input
|
|
10
|
+
* that has to be schema-checked before it is read (#563).
|
|
11
|
+
*/
|
|
12
|
+
// ponytail: `format: date-time` is not checked, and neither are composition
|
|
13
|
+
// keywords — none appear in either schema. Reach for ajv the day one does.
|
|
14
|
+
export function validateAgainstSchema(value, schema, path = '$') {
|
|
15
|
+
const errors = [];
|
|
16
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
17
|
+
errors.push(`${path}: ${JSON.stringify(value)} is not one of ${schema.enum.join(', ')}`);
|
|
18
|
+
}
|
|
19
|
+
if (schema.type === 'array') {
|
|
20
|
+
if (!Array.isArray(value))
|
|
21
|
+
return [`${path}: expected array`];
|
|
22
|
+
if (schema.items) {
|
|
23
|
+
for (const [i, item] of value.entries()) {
|
|
24
|
+
errors.push(...validateAgainstSchema(item, schema.items, `${path}[${i}]`));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return errors;
|
|
28
|
+
}
|
|
29
|
+
if (schema.type === 'object') {
|
|
30
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
31
|
+
return [`${path}: expected object`];
|
|
32
|
+
}
|
|
33
|
+
const obj = value;
|
|
34
|
+
for (const key of schema.required ?? []) {
|
|
35
|
+
if (!(key in obj))
|
|
36
|
+
errors.push(`${path}: missing required property "${key}"`);
|
|
37
|
+
}
|
|
38
|
+
for (const [key, child] of Object.entries(obj)) {
|
|
39
|
+
const property = schema.properties?.[key];
|
|
40
|
+
if (property)
|
|
41
|
+
errors.push(...validateAgainstSchema(child, property, `${path}.${key}`));
|
|
42
|
+
else if (schema.additionalProperties === false) {
|
|
43
|
+
errors.push(`${path}: unknown property "${key}"`);
|
|
44
|
+
}
|
|
45
|
+
else if (typeof schema.additionalProperties === 'object') {
|
|
46
|
+
errors.push(...validateAgainstSchema(child, schema.additionalProperties, `${path}.${key}`));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return errors;
|
|
50
|
+
}
|
|
51
|
+
if (schema.type === 'integer') {
|
|
52
|
+
if (!Number.isInteger(value))
|
|
53
|
+
errors.push(`${path}: expected integer`);
|
|
54
|
+
}
|
|
55
|
+
else if (schema.type && typeof value !== schema.type) {
|
|
56
|
+
errors.push(`${path}: expected ${schema.type}, got ${typeof value}`);
|
|
57
|
+
}
|
|
58
|
+
if (schema.minLength !== undefined &&
|
|
59
|
+
typeof value === 'string' &&
|
|
60
|
+
value.length < schema.minLength) {
|
|
61
|
+
errors.push(`${path}: shorter than minLength ${schema.minLength}`);
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
@@ -16,7 +16,10 @@ export const LEGACY_LOCKFILE_NAME = `.${LEGACY_TOOL_NAME}.json`;
|
|
|
16
16
|
// migrated to v2 on read, defaulting language to 'js'.
|
|
17
17
|
// v3 added `assets` — the pristine hash of each copied preset (#428). Older
|
|
18
18
|
// files carry no hashes, which reads as "not tracked", never as drift.
|
|
19
|
-
|
|
19
|
+
// v4 split the file into two subtrees with documented ownership (#559):
|
|
20
|
+
// `record` (tool-written, stamped) and `rules` (human-written, unstamped).
|
|
21
|
+
// Nothing was renamed or dropped — the flat v3 fields just moved into them.
|
|
22
|
+
export const LOCKFILE_VERSION = 4;
|
|
20
23
|
const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/lockfile.json';
|
|
21
24
|
/**
|
|
22
25
|
* How much of the repo's workflow assumes a recommended MCP server (#534).
|
|
@@ -48,10 +51,10 @@ export function lockfileSchema() {
|
|
|
48
51
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
49
52
|
$id: LOCKFILE_SCHEMA_URL,
|
|
50
53
|
title: 'Lockfile',
|
|
51
|
-
description: `${LOCKFILE_NAME} —
|
|
54
|
+
description: `${LOCKFILE_NAME} — two documents sharing one file: \`record\` is written by @rtorcato/repo-tooling (\`setup\` and \`fix\`) and stamped with provenance; \`rules\` is written by humans, reviewed in PRs, and never stamped. Both are read by \`doctor\`.`,
|
|
52
55
|
type: 'object',
|
|
53
56
|
additionalProperties: false,
|
|
54
|
-
required: ['version', '
|
|
57
|
+
required: ['version', 'record'],
|
|
55
58
|
properties: {
|
|
56
59
|
$schema: {
|
|
57
60
|
type: 'string',
|
|
@@ -59,78 +62,93 @@ export function lockfileSchema() {
|
|
|
59
62
|
},
|
|
60
63
|
version: {
|
|
61
64
|
type: 'integer',
|
|
62
|
-
description: `Lockfile format version (current: ${LOCKFILE_VERSION}). v2 added config.language, v3 added assets; older files are migrated on read.`,
|
|
65
|
+
description: `Lockfile format version (current: ${LOCKFILE_VERSION}). v2 added config.language, v3 added assets, v4 split the file into record/rules subtrees; older files are migrated on read.`,
|
|
63
66
|
},
|
|
64
|
-
|
|
65
|
-
...projectConfigSchema,
|
|
66
|
-
description: 'The resolved setup configuration this repo was scaffolded or audited with.',
|
|
67
|
-
},
|
|
68
|
-
assets: {
|
|
69
|
-
type: 'object',
|
|
70
|
-
additionalProperties: { type: 'string' },
|
|
71
|
-
description: "Preset name → sha256 of the asset's pristine content at copy time. Lets doctor tell a deliberate local fork (file differs from this hash) from a copy the package has since moved past (file still matches, shipped asset doesn't). A preset with no entry is untracked, never drifted.",
|
|
72
|
-
},
|
|
73
|
-
aiLoop: {
|
|
67
|
+
record: {
|
|
74
68
|
type: 'object',
|
|
75
69
|
additionalProperties: false,
|
|
76
|
-
|
|
70
|
+
required: ['config', 'writtenBy', 'writtenAt'],
|
|
71
|
+
description: 'The tool-written record of what setup/fix last did. Only the tool writes here — the writtenBy/writtenAt stamps are provenance claims about exactly this subtree.',
|
|
77
72
|
properties: {
|
|
78
|
-
|
|
73
|
+
config: {
|
|
74
|
+
...projectConfigSchema,
|
|
75
|
+
description: 'The resolved setup configuration this repo was scaffolded or audited with.',
|
|
76
|
+
},
|
|
77
|
+
assets: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
additionalProperties: { type: 'string' },
|
|
80
|
+
description: "Preset name → sha256 of the asset's pristine content at copy time. Lets doctor tell a deliberate local fork (file differs from this hash) from a copy the package has since moved past (file still matches, shipped asset doesn't). A preset with no entry is untracked, never drifted.",
|
|
81
|
+
},
|
|
82
|
+
writtenBy: {
|
|
79
83
|
type: 'string',
|
|
80
|
-
description: '
|
|
84
|
+
description: 'Package name and version that last wrote the record subtree.',
|
|
85
|
+
},
|
|
86
|
+
writtenAt: {
|
|
87
|
+
type: 'string',
|
|
88
|
+
format: 'date-time',
|
|
89
|
+
description: 'ISO 8601 timestamp of the last record write.',
|
|
81
90
|
},
|
|
82
91
|
},
|
|
83
92
|
},
|
|
84
|
-
|
|
85
|
-
type: 'array',
|
|
86
|
-
items: { type: 'string', enum: SHIPPED_SKILLS },
|
|
87
|
-
description: 'Agent skills this repo\'s workflows depend on. doctor compares each installed copy\'s stamped hash against the shipped one and reports a missing or stale skill — always as "not configured", never drift, because it probes the machine rather than the repo. It never runs the fixer for you.',
|
|
88
|
-
},
|
|
89
|
-
mcp: {
|
|
93
|
+
rules: {
|
|
90
94
|
type: 'object',
|
|
91
95
|
additionalProperties: false,
|
|
92
|
-
description: "
|
|
96
|
+
description: "The human-written ruleset: the repo's stated intent, edited by hand and reviewed in PRs. The tool carries it forward verbatim on every write and never stamps it.",
|
|
93
97
|
properties: {
|
|
94
|
-
|
|
98
|
+
aiLoop: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
description: 'Settings for the ai-issue-loop skills. Repo-scoped on purpose: committed here they travel with the repo and survive a new laptop.',
|
|
102
|
+
properties: {
|
|
103
|
+
agentUser: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
description: 'Login that in-flight work is assigned to, so `assignee` says whose turn it is. Must be an assignable collaborator; the skills verify that at runtime.',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
requiredSkills: {
|
|
95
110
|
type: 'array',
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
items: { type: 'string', enum: SHIPPED_SKILLS },
|
|
112
|
+
description: 'Agent skills this repo\'s workflows depend on. doctor compares each installed copy\'s stamped hash against the shipped one and reports a missing or stale skill — always as "not configured", never drift, because it probes the machine rather than the repo. It never runs the fixer for you.',
|
|
113
|
+
},
|
|
114
|
+
mcp: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
additionalProperties: false,
|
|
117
|
+
description: "Advisory MCP metadata: names, importance and reasons only, never an install directive. Executable server config belongs in the native .mcp.json, which carries Claude Code's own first-use consent prompt.",
|
|
118
|
+
properties: {
|
|
119
|
+
recommended: {
|
|
120
|
+
type: 'array',
|
|
121
|
+
description: "MCP servers this repo's workflow assumes. doctor reports which of them .mcp.json does not declare, informationally — it never installs or enables one.",
|
|
122
|
+
items: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
required: ['name', 'importance', 'why'],
|
|
126
|
+
properties: {
|
|
127
|
+
name: {
|
|
128
|
+
type: 'string',
|
|
129
|
+
description: 'The server name as it would appear in .mcp.json.',
|
|
130
|
+
},
|
|
131
|
+
importance: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
enum: MCP_IMPORTANCE,
|
|
134
|
+
description: "How much of the repo's workflow assumes the server.",
|
|
135
|
+
},
|
|
136
|
+
why: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
description: 'One line on what the server is for — the thing .mcp.json structurally cannot say.',
|
|
139
|
+
},
|
|
140
|
+
},
|
|
114
141
|
},
|
|
115
142
|
},
|
|
116
143
|
},
|
|
117
144
|
},
|
|
145
|
+
exceptions: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
additionalProperties: { type: 'string', minLength: 1 },
|
|
148
|
+
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.',
|
|
149
|
+
},
|
|
118
150
|
},
|
|
119
151
|
},
|
|
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
|
-
},
|
|
125
|
-
writtenBy: {
|
|
126
|
-
type: 'string',
|
|
127
|
-
description: 'Package name and version that last wrote this file.',
|
|
128
|
-
},
|
|
129
|
-
writtenAt: {
|
|
130
|
-
type: 'string',
|
|
131
|
-
format: 'date-time',
|
|
132
|
-
description: 'ISO 8601 timestamp of the last write.',
|
|
133
|
-
},
|
|
134
152
|
},
|
|
135
153
|
};
|
|
136
154
|
}
|
|
@@ -139,17 +157,53 @@ export function lockfileSchema() {
|
|
|
139
157
|
* current version, so a newer-than-supported file is left as-is for
|
|
140
158
|
* checkLockfile to flag. `version` stays at the on-disk value — bumping it here
|
|
141
159
|
* hid every older file from doctor's older-than-current check (#531); the write
|
|
142
|
-
* path stamps LOCKFILE_VERSION anyway, so the file is
|
|
160
|
+
* path stamps LOCKFILE_VERSION anyway, so the file is v4 next time it's saved.
|
|
161
|
+
*
|
|
162
|
+
* v1–v3 are flat: nest the fields into record/rules (#559), default language
|
|
163
|
+
* to 'js' (v1, #140) and assets to {} (pre-v3, #428). Nothing is renamed.
|
|
143
164
|
*/
|
|
144
|
-
function migrate(
|
|
145
|
-
if (
|
|
146
|
-
return
|
|
165
|
+
function migrate(raw) {
|
|
166
|
+
if (raw.version >= LOCKFILE_VERSION)
|
|
167
|
+
return raw;
|
|
168
|
+
const flat = raw;
|
|
169
|
+
const rules = {
|
|
170
|
+
...(flat.aiLoop ? { aiLoop: flat.aiLoop } : {}),
|
|
171
|
+
...(flat.requiredSkills ? { requiredSkills: flat.requiredSkills } : {}),
|
|
172
|
+
...(flat.mcp ? { mcp: flat.mcp } : {}),
|
|
173
|
+
...(flat.exceptions ? { exceptions: flat.exceptions } : {}),
|
|
174
|
+
};
|
|
147
175
|
return {
|
|
148
|
-
...
|
|
149
|
-
|
|
150
|
-
|
|
176
|
+
...(flat.$schema ? { $schema: flat.$schema } : {}),
|
|
177
|
+
version: flat.version,
|
|
178
|
+
record: {
|
|
179
|
+
config: { language: 'js', ...flat.config },
|
|
180
|
+
assets: flat.assets ?? {},
|
|
181
|
+
writtenBy: flat.writtenBy,
|
|
182
|
+
writtenAt: flat.writtenAt,
|
|
183
|
+
},
|
|
184
|
+
...(Object.keys(rules).length > 0 ? { rules } : {}),
|
|
151
185
|
};
|
|
152
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Normalize already-parsed JSON into a Lockfile, or null when it isn't one.
|
|
189
|
+
* Split out of readLockfile so a lockfile fetched from somewhere other than the
|
|
190
|
+
* filesystem — a reference repo, over `gh` — goes through the same migration
|
|
191
|
+
* before anything reads it (#563).
|
|
192
|
+
*/
|
|
193
|
+
export function parseLockfile(raw) {
|
|
194
|
+
if (typeof raw !== 'object' || raw === null)
|
|
195
|
+
return null;
|
|
196
|
+
const obj = raw;
|
|
197
|
+
if (typeof obj.version !== 'number')
|
|
198
|
+
return null;
|
|
199
|
+
// v4+ keeps config under `record`; v1–v3 keep it at the top level (#559).
|
|
200
|
+
const config = obj.version >= LOCKFILE_VERSION
|
|
201
|
+
? obj.record?.config
|
|
202
|
+
: obj.config;
|
|
203
|
+
if (typeof config !== 'object' || config === null)
|
|
204
|
+
return null;
|
|
205
|
+
return migrate(obj);
|
|
206
|
+
}
|
|
153
207
|
export async function readLockfile(dir) {
|
|
154
208
|
let filepath = path.join(dir, LOCKFILE_NAME);
|
|
155
209
|
if (!(await fs.pathExists(filepath))) {
|
|
@@ -160,15 +214,7 @@ export async function readLockfile(dir) {
|
|
|
160
214
|
filepath = legacy;
|
|
161
215
|
}
|
|
162
216
|
try {
|
|
163
|
-
|
|
164
|
-
if (typeof raw !== 'object' || raw === null)
|
|
165
|
-
return null;
|
|
166
|
-
const obj = raw;
|
|
167
|
-
if (typeof obj.version !== 'number')
|
|
168
|
-
return null;
|
|
169
|
-
if (typeof obj.config !== 'object' || obj.config === null)
|
|
170
|
-
return null;
|
|
171
|
-
return migrate(obj);
|
|
217
|
+
return parseLockfile((await fs.readJson(filepath)));
|
|
172
218
|
}
|
|
173
219
|
catch {
|
|
174
220
|
return null;
|
|
@@ -186,21 +232,21 @@ export async function writeLockfile(dir, config, assets) {
|
|
|
186
232
|
}
|
|
187
233
|
// One read, because everything not rebuilt from `config` has to be carried
|
|
188
234
|
// forward explicitly — this object is constructed from scratch, so any key
|
|
189
|
-
// not named here is dropped by the next `fix lockfile`.
|
|
235
|
+
// not named here is dropped by the next `fix lockfile`. `rules` rides along
|
|
236
|
+
// verbatim: it is the human's subtree, and the stamp says nothing about it.
|
|
190
237
|
const existing = await readLockfile(dir);
|
|
191
|
-
const carried = assets ?? existing?.assets;
|
|
238
|
+
const carried = assets ?? existing?.record.assets;
|
|
192
239
|
const filepath = path.join(dir, LOCKFILE_NAME);
|
|
193
240
|
const lockfile = {
|
|
194
241
|
$schema: LOCKFILE_SCHEMA_URL,
|
|
195
242
|
version: LOCKFILE_VERSION,
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
writtenAt: new Date().toISOString(),
|
|
243
|
+
record: {
|
|
244
|
+
config,
|
|
245
|
+
...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
|
|
246
|
+
writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
|
|
247
|
+
writtenAt: new Date().toISOString(),
|
|
248
|
+
},
|
|
249
|
+
...(existing?.rules ? { rules: existing.rules } : {}),
|
|
204
250
|
};
|
|
205
251
|
await fs.writeJson(filepath, lockfile, { spaces: 2 });
|
|
206
252
|
// Migrate a pre-rename repo to the new name: now that the canonical file is
|
|
@@ -218,7 +264,7 @@ export async function updateLockfileConfig(dir, patch) {
|
|
|
218
264
|
const existing = await readLockfile(dir);
|
|
219
265
|
if (!existing)
|
|
220
266
|
return false;
|
|
221
|
-
const merged = { ...existing.config, ...patch };
|
|
267
|
+
const merged = { ...existing.record.config, ...patch };
|
|
222
268
|
await writeLockfile(dir, merged);
|
|
223
269
|
return true;
|
|
224
270
|
}
|
|
@@ -232,6 +278,6 @@ export async function recordAssetHash(dir, preset, hash) {
|
|
|
232
278
|
const existing = await readLockfile(dir);
|
|
233
279
|
if (!existing)
|
|
234
280
|
return false;
|
|
235
|
-
await writeLockfile(dir, existing.config, { ...existing.assets, [preset]: hash });
|
|
281
|
+
await writeLockfile(dir, existing.record.config, { ...existing.record.assets, [preset]: hash });
|
|
236
282
|
return true;
|
|
237
283
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { realGhExec } from '../../base/github-settings.js';
|
|
2
|
+
import { validateAgainstSchema } from './json-schema.js';
|
|
3
|
+
import { LOCKFILE_NAME, lockfileSchema, parseLockfile, readLockfile, } from './lockfile.js';
|
|
4
|
+
/**
|
|
5
|
+
* Rules are per-repo (#563): there is no guideline package, so sharing a
|
|
6
|
+
* guideline means pointing at another repo. This module is the one fetcher both
|
|
7
|
+
* halves of that share — `doctor --rules-from` reads a reference repo's rules to
|
|
8
|
+
* report differences, and `setup --from` reads the same file to seed the wizard.
|
|
9
|
+
*
|
|
10
|
+
* The reference is untrusted input from end to end: the `owner/repo` string is
|
|
11
|
+
* shape-checked before it reaches an API path, the response is size-capped, and
|
|
12
|
+
* the JSON is validated against the published schema before a single field is
|
|
13
|
+
* read. Nothing here ever writes, and nothing is ever applied.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* `owner/repo`. The injection boundary — the reference is interpolated into the
|
|
17
|
+
* gh API path below, so anything with a slash, `..` or a shell metacharacter in
|
|
18
|
+
* it has to be rejected here rather than defended against later.
|
|
19
|
+
*/
|
|
20
|
+
const REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
21
|
+
/**
|
|
22
|
+
* Generous by four orders of magnitude — a real lockfile is ~1 KB. This is not a
|
|
23
|
+
* tuning knob, it is the "huge file" guard: the response is fully buffered by the
|
|
24
|
+
* time we see it, so the cap stops us parsing and diffing a repo's 40 MB joke.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_BYTES = 128 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* The GitHub arm of the fetch. Another forge is a second function of this shape
|
|
29
|
+
* dispatched from fetchReferenceLockfile — everything downstream (size cap,
|
|
30
|
+
* parse, schema validation, diff) is forge-agnostic and already shared.
|
|
31
|
+
*/
|
|
32
|
+
async function fetchFromGitHub(reference, exec) {
|
|
33
|
+
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin));
|
|
34
|
+
// `Accept: raw` returns the file itself rather than the base64-in-JSON envelope.
|
|
35
|
+
const r = await gh([
|
|
36
|
+
'api',
|
|
37
|
+
`repos/${reference}/contents/${LOCKFILE_NAME}`,
|
|
38
|
+
'-H',
|
|
39
|
+
'Accept: application/vnd.github.raw',
|
|
40
|
+
]);
|
|
41
|
+
if (r.ok)
|
|
42
|
+
return { ok: true, text: r.stdout };
|
|
43
|
+
if (/HTTP 404/.test(r.stderr)) {
|
|
44
|
+
return { ok: false, reason: `${reference} has no ${LOCKFILE_NAME} (or is not visible to you)` };
|
|
45
|
+
}
|
|
46
|
+
const detail = r.stderr.trim().split('\n').pop() ?? 'gh failed';
|
|
47
|
+
return { ok: false, reason: `could not read ${reference}: ${detail}` };
|
|
48
|
+
}
|
|
49
|
+
function parseReference(reference, text) {
|
|
50
|
+
if (Buffer.byteLength(text) > MAX_BYTES) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: `${reference}'s ${LOCKFILE_NAME} is larger than ${MAX_BYTES} bytes`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { ok: false, reason: `${reference}'s ${LOCKFILE_NAME} is not valid JSON` };
|
|
62
|
+
}
|
|
63
|
+
// Migrate first, then validate: an older reference is flat on disk and would
|
|
64
|
+
// fail the current schema for a reason that says nothing about its rules.
|
|
65
|
+
const lockfile = parseLockfile(raw);
|
|
66
|
+
if (!lockfile) {
|
|
67
|
+
return { ok: false, reason: `${reference}'s ${LOCKFILE_NAME} is not a recognisable lockfile` };
|
|
68
|
+
}
|
|
69
|
+
const errors = validateAgainstSchema(lockfile, lockfileSchema());
|
|
70
|
+
if (errors.length > 0) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: `${reference}'s ${LOCKFILE_NAME} fails the published schema: ${errors.slice(0, 3).join('; ')}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, lockfile };
|
|
77
|
+
}
|
|
78
|
+
export async function fetchReferenceLockfile(reference, exec) {
|
|
79
|
+
if (!REFERENCE.test(reference)) {
|
|
80
|
+
return { ok: false, reason: `"${reference}" is not an owner/repo reference` };
|
|
81
|
+
}
|
|
82
|
+
const fetched = await fetchFromGitHub(reference, exec);
|
|
83
|
+
if (!fetched.ok)
|
|
84
|
+
return fetched;
|
|
85
|
+
return parseReference(reference, fetched.text);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The comparable half of a lockfile: the resolved config plus the human-written
|
|
89
|
+
* rules. `assets` hashes and the `writtenBy`/`writtenAt` stamps are excluded on
|
|
90
|
+
* purpose — they differ between any two repos by construction, so diffing them
|
|
91
|
+
* would bury every difference that means something.
|
|
92
|
+
*/
|
|
93
|
+
function rulesView(lock) {
|
|
94
|
+
return { config: lock.record.config, ...(lock.rules ?? {}) };
|
|
95
|
+
}
|
|
96
|
+
function flatten(value, prefix = '', out = new Map()) {
|
|
97
|
+
// Arrays are leaves: `requiredSkills` differing by one entry is one difference
|
|
98
|
+
// about the list, not a per-index report that shifts when the order does.
|
|
99
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
100
|
+
for (const [key, child] of Object.entries(value)) {
|
|
101
|
+
flatten(child, prefix ? `${prefix}.${key}` : key, out);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
out.set(prefix, value);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
export function diffRules(local, reference) {
|
|
109
|
+
const mine = local ? flatten(rulesView(local)) : new Map();
|
|
110
|
+
const theirs = flatten(rulesView(reference));
|
|
111
|
+
return [...new Set([...mine.keys(), ...theirs.keys()])]
|
|
112
|
+
.sort()
|
|
113
|
+
.filter((p) => JSON.stringify(mine.get(p)) !== JSON.stringify(theirs.get(p)))
|
|
114
|
+
.map((p) => ({ path: p, local: mine.get(p), reference: theirs.get(p) }));
|
|
115
|
+
}
|
|
116
|
+
export async function compareRulesWithReference(dir, reference, exec) {
|
|
117
|
+
const result = await fetchReferenceLockfile(reference, exec);
|
|
118
|
+
if (!result.ok)
|
|
119
|
+
return { reference, compared: false, reason: result.reason };
|
|
120
|
+
return {
|
|
121
|
+
reference,
|
|
122
|
+
compared: true,
|
|
123
|
+
differences: diffRules(await readLockfile(dir), result.lockfile),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -864,10 +864,10 @@ export const FIXERS = [
|
|
|
864
864
|
console.error(chalk.yellow(' no package.json found — skipping'));
|
|
865
865
|
return { filesWritten: [] };
|
|
866
866
|
}
|
|
867
|
-
const config = lock ? lock.config : inferProjectConfig(pkg);
|
|
867
|
+
const config = lock ? lock.record.config : inferProjectConfig(pkg);
|
|
868
868
|
// Recorded hashes win: they capture the pristine content at copy time,
|
|
869
869
|
// which a byte-match against today's shipped asset can only approximate.
|
|
870
|
-
const assets = { ...(await identifiablePresetHashes(targetDir)), ...lock?.assets };
|
|
870
|
+
const assets = { ...(await identifiablePresetHashes(targetDir)), ...lock?.record.assets };
|
|
871
871
|
await writeLockfile(targetDir, config, assets);
|
|
872
872
|
return { filesWritten: [LOCKFILE_NAME] };
|
|
873
873
|
},
|
package/package.json
CHANGED
|
@@ -219,8 +219,9 @@ behaves exactly as it did before this existed.
|
|
|
219
219
|
|
|
220
220
|
```bash
|
|
221
221
|
# Repo config first — committed, so it travels with the repo and survives a new
|
|
222
|
-
# machine. `AI_LOOP_AGENT` overrides it for a repo with no lockfile.
|
|
223
|
-
|
|
222
|
+
# machine. `AI_LOOP_AGENT` overrides it for a repo with no lockfile. The flat
|
|
223
|
+
# `.aiLoop` fallback reads a pre-v4 lockfile that hasn't migrated yet (#559).
|
|
224
|
+
AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.rules.aiLoop.agentUser // .aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
|
|
224
225
|
# A typo would fail every `gh` edit for the whole tick, so prove it is assignable
|
|
225
226
|
# once, here. 204 = yes, 404 = no; push access is what qualifies an account.
|
|
226
227
|
[ -n "$AGENT_USER" ] && { gh api "repos/$OWNER_REPO/assignees/$AGENT_USER" --silent 2>/dev/null || {
|
|
@@ -235,7 +236,7 @@ being that assignment quietly stops. In `.repo-tooling.json` it is committed,
|
|
|
235
236
|
reviewable, and carried forward by `fix lockfile`:
|
|
236
237
|
|
|
237
238
|
```json
|
|
238
|
-
{ "aiLoop": { "agentUser": "your-bot-account" } }
|
|
239
|
+
{ "rules": { "aiLoop": { "agentUser": "your-bot-account" } } }
|
|
239
240
|
```
|
|
240
241
|
|
|
241
242
|
Every later use is `${AGENT_USER:+--add-assignee "$AGENT_USER"}`, which expands
|
|
@@ -53,7 +53,7 @@ git -C "$ROOT" fetch --prune
|
|
|
53
53
|
# Optional: the account in-flight work is assigned to, so `assignee` says whose
|
|
54
54
|
# turn it is. Unset → nothing below assigns, exactly as before. See the
|
|
55
55
|
# ai-issue-loop skill's Pass 0 for why this is repo config rather than an env var.
|
|
56
|
-
AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
|
|
56
|
+
AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.rules.aiLoop.agentUser // .aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
|
|
57
57
|
[ -n "$AGENT_USER" ] && { gh api "repos/$R/assignees/$AGENT_USER" --silent 2>/dev/null || AGENT_USER=""; }
|
|
58
58
|
```
|
|
59
59
|
|
|
@@ -30,7 +30,7 @@ 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
|
|
33
|
+
- `declared` — a real deviation the repo's `.repo-tooling.json` `rules.exceptions` records on
|
|
34
34
|
purpose, with its reason. Leave it alone; it doesn't fail the run.
|
|
35
35
|
|
|
36
36
|
`fix` returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`.
|