@rtorcato/repo-tooling 3.29.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 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`. |
@@ -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';
@@ -465,11 +466,43 @@ export function summarize(results) {
465
466
  declared: results.filter((r) => r.status === 'declared').length,
466
467
  };
467
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
+ }
468
494
  export async function doctorCommand(options = {}) {
469
495
  const dir = options.directory ?? process.cwd();
470
496
  const results = await runDoctor(dir, options.skillsDir);
497
+ const comparison = options.rulesFrom
498
+ ? await compareRulesWithReference(dir, options.rulesFrom)
499
+ : null;
471
500
  if (options.json) {
472
- console.log(JSON.stringify({ directory: path.resolve(dir), results }, null, 2));
501
+ console.log(JSON.stringify({
502
+ directory: path.resolve(dir),
503
+ results,
504
+ ...(comparison ? { rulesReference: comparison } : {}),
505
+ }, null, 2));
473
506
  }
474
507
  else {
475
508
  console.log(chalk.cyan(`\n🩺 Diagnosing ${path.resolve(dir)} against ${PACKAGE} presets...\n`));
@@ -491,6 +524,8 @@ export async function doctorCommand(options = {}) {
491
524
  }
492
525
  console.log();
493
526
  }
527
+ if (comparison)
528
+ printRulesComparison(comparison);
494
529
  }
495
530
  const summary = summarize(results);
496
531
  const exitCode = summary.drift > 0 || summary.missing > 0 ? 1 : 0;
@@ -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))) {
@@ -99,7 +125,14 @@ async function resolveConfig(options) {
99
125
  return config;
100
126
  return reviewPresetConfig(config);
101
127
  }
102
- return promptForConfig(path.resolve(options.directory));
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);
103
136
  }
104
137
  export async function setupProject(options) {
105
138
  if (options.configSchema) {
@@ -165,7 +198,7 @@ const SCAFFOLDABLE = ['js', 'swift'];
165
198
  * like. Languages setup can't scaffold yet are still offered — hiding them reads
166
199
  * as "repo-tooling is JS-only" when the honest answer is "not yet" (#139).
167
200
  */
168
- async function promptForLanguage(targetDir) {
201
+ async function promptForLanguage(targetDir, seed) {
169
202
  const detected = await detectLanguage(targetDir);
170
203
  const { language } = await inquirer.prompt([
171
204
  {
@@ -178,8 +211,10 @@ async function promptForLanguage(targetDir) {
178
211
  : `${module.label} (${module.supported ? 'doctor/fix only — no setup preset yet' : 'setup lands with its module'})`,
179
212
  value: module.id,
180
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.
181
216
  // 'unknown' is a bare dir mid-setup — JS is the historical default.
182
- default: detected === 'unknown' ? 'js' : detected,
217
+ default: detected !== 'unknown' ? detected : (seed?.language ?? 'js'),
183
218
  },
184
219
  ]);
185
220
  return LANGUAGES[language];
@@ -196,9 +231,16 @@ function explainNotScaffoldable(module) {
196
231
  console.log(chalk.gray(` ${module.label} setup presets are tracked at\n` +
197
232
  ' https://github.com/rtorcato/repo-tooling/issues/139\n'));
198
233
  }
199
- /** `null` when the chosen language has no scaffolding yet; nothing is written. */
200
- async function promptForConfig(targetDir) {
201
- const language = await promptForLanguage(targetDir);
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);
202
244
  // Gate on SCAFFOLDABLE, not `module.supported` — Swift is supported (#286)
203
245
  // but has no preset yet (#288), and falling through here would write a
204
246
  // package.json into a Swift repo.
@@ -228,6 +270,18 @@ async function promptForConfig(targetDir) {
228
270
  // Turborepo only makes sense in a pnpm-workspace monorepo, so the prompt is
229
271
  // only offered when one is already present in the target dir.
230
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';
231
285
  const answers = await inquirer.prompt([
232
286
  {
233
287
  type: 'input',
@@ -240,6 +294,7 @@ async function promptForConfig(targetDir) {
240
294
  type: 'select',
241
295
  name: 'projectType',
242
296
  message: '🏗️ What type of project are you building?',
297
+ default: seed?.projectType,
243
298
  choices: [
244
299
  { name: '📚 Library/Package', value: 'library' },
245
300
  { name: '🌐 Web Application', value: 'web-app' },
@@ -252,7 +307,7 @@ async function promptForConfig(targetDir) {
252
307
  type: 'confirm',
253
308
  name: 'useTypeScript',
254
309
  message: '📘 Do you want to use TypeScript?',
255
- default: true,
310
+ default: seed?.typescript.enabled ?? true,
256
311
  },
257
312
  {
258
313
  type: 'select',
@@ -277,6 +332,7 @@ async function promptForConfig(targetDir) {
277
332
  }
278
333
  return baseChoices;
279
334
  },
335
+ default: seed?.typescript.config,
280
336
  when: (answers) => answers.useTypeScript,
281
337
  },
282
338
  {
@@ -289,7 +345,7 @@ async function promptForConfig(targetDir) {
289
345
  { name: '🔥 Both Biome + ESLint', value: 'both' },
290
346
  { name: '❌ None', value: 'none' },
291
347
  ],
292
- default: 'biome',
348
+ default: seed?.linting.tool ?? 'biome',
293
349
  },
294
350
  {
295
351
  type: 'select',
@@ -302,13 +358,14 @@ async function promptForConfig(targetDir) {
302
358
  }
303
359
  return choices;
304
360
  },
361
+ default: seed?.linting.eslintConfig,
305
362
  when: (answers) => answers.lintingTool === 'eslint' || answers.lintingTool === 'both',
306
363
  },
307
364
  {
308
365
  type: 'confirm',
309
366
  name: 'oxlint',
310
367
  message: '🦀 Also run Oxlint alongside (50–100× faster than ESLint)?',
311
- default: false,
368
+ default: seed?.oxlint ?? false,
312
369
  when: (answers) => answers.lintingTool !== 'none',
313
370
  },
314
371
  {
@@ -322,7 +379,7 @@ async function promptForConfig(targetDir) {
322
379
  { name: '🌲 Cypress (E2E)', value: 'cypress' },
323
380
  { name: '❌ None', value: 'none' },
324
381
  ],
325
- default: 'vitest',
382
+ default: seed?.testing.framework ?? 'vitest',
326
383
  },
327
384
  {
328
385
  type: 'select',
@@ -334,19 +391,19 @@ async function promptForConfig(targetDir) {
334
391
  { name: '🔄 Both', value: 'both' },
335
392
  ],
336
393
  when: (answers) => answers.testingFramework === 'jest',
337
- default: 'node',
394
+ default: seed?.testing.environment ?? 'node',
338
395
  },
339
396
  {
340
397
  type: 'confirm',
341
398
  name: 'gitHooks',
342
399
  message: '🪝 Set up Git hooks (Husky + lint-staged)?',
343
- default: true,
400
+ default: seed?.gitHooks ?? true,
344
401
  },
345
402
  {
346
403
  type: 'confirm',
347
404
  name: 'commitLint',
348
405
  message: '📝 Set up conventional commit linting?',
349
- default: true,
406
+ default: seed?.commitLint ?? true,
350
407
  when: (answers) => answers.gitHooks,
351
408
  },
352
409
  {
@@ -359,40 +416,40 @@ async function promptForConfig(targetDir) {
359
416
  { name: '🙏 Release Please (Google, release-PR-driven)', value: 'release-please' },
360
417
  { name: '❌ None', value: 'none' },
361
418
  ],
362
- default: 'semantic-release',
419
+ default: seededReleaseTool,
363
420
  when: (answers) => answers.projectType === 'library',
364
421
  },
365
422
  {
366
423
  type: 'confirm',
367
424
  name: 'treeshakeCheck',
368
425
  message: '🌳 Add a tree-shake verification check (apps/treeshake-check)?',
369
- default: false,
426
+ default: seed?.treeshakeCheck ?? false,
370
427
  when: (answers) => answers.projectType === 'library',
371
428
  },
372
429
  {
373
430
  type: 'confirm',
374
431
  name: 'publint',
375
432
  message: '📦 Add publint to lint your package before publishing?',
376
- default: true,
433
+ default: seed?.publint ?? true,
377
434
  when: (answers) => answers.projectType === 'library',
378
435
  },
379
436
  {
380
437
  type: 'confirm',
381
438
  name: 'badges',
382
439
  message: '🔖 Add status badges (CI, npm, coverage, license) to the README?',
383
- default: true,
440
+ default: seed?.badges ?? true,
384
441
  },
385
442
  {
386
443
  type: 'confirm',
387
444
  name: 'securityAutomation',
388
445
  message: '🛡️ Include security automation (Dependabot + CodeQL)?',
389
- default: true,
446
+ default: seed?.securityAutomation ?? true,
390
447
  },
391
448
  {
392
449
  type: 'confirm',
393
450
  name: 'aiSetup',
394
451
  message: '🤖 Add AI agent rules (AGENTS.md, CLAUDE.md, Cursor, Copilot, Claude skill)?',
395
- default: true,
452
+ default: seed?.aiSetup ?? true,
396
453
  },
397
454
  {
398
455
  type: 'select',
@@ -403,14 +460,14 @@ async function promptForConfig(targetDir) {
403
460
  { name: '🔷 Nx (nx.json)', value: 'nx' },
404
461
  { name: '❌ None', value: 'none' },
405
462
  ],
406
- default: 'turbo',
463
+ default: seededOrchestrator,
407
464
  when: () => hasWorkspace,
408
465
  },
409
466
  {
410
467
  type: 'confirm',
411
468
  name: 'tailwind',
412
469
  message: '🎨 Add Tailwind CSS v4 (PostCSS plugin + globals.css)?',
413
- default: true,
470
+ default: seed?.tailwind ?? true,
414
471
  // Only meaningful for frontend project types.
415
472
  when: (answers) => answers.projectType === 'web-app' ||
416
473
  answers.projectType === 'react-app' ||
@@ -433,13 +490,14 @@ async function promptForConfig(targetDir) {
433
490
  }
434
491
  return choices;
435
492
  },
493
+ default: seed?.bundler,
436
494
  when: (answers) => answers.projectType !== 'nextjs-app', // Next.js has its own bundler
437
495
  },
438
496
  {
439
497
  type: 'confirm',
440
498
  name: 'bun',
441
499
  message: '🥟 Target the Bun runtime (bunfig.toml + Bun-typed tsconfig)?',
442
- default: false,
500
+ default: seed?.bun ?? false,
443
501
  // A runtime flag, not a project type — offer it for Node-compatible
444
502
  // library/API targets, not the browser-framework types.
445
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]')
@@ -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
+ }
@@ -184,6 +184,26 @@ function migrate(raw) {
184
184
  ...(Object.keys(rules).length > 0 ? { rules } : {}),
185
185
  };
186
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
+ }
187
207
  export async function readLockfile(dir) {
188
208
  let filepath = path.join(dir, LOCKFILE_NAME);
189
209
  if (!(await fs.pathExists(filepath))) {
@@ -194,19 +214,7 @@ export async function readLockfile(dir) {
194
214
  filepath = legacy;
195
215
  }
196
216
  try {
197
- const raw = (await fs.readJson(filepath));
198
- if (typeof raw !== 'object' || raw === null)
199
- return null;
200
- const obj = raw;
201
- if (typeof obj.version !== 'number')
202
- return null;
203
- // v4+ keeps config under `record`; v1–v3 keep it at the top level (#559).
204
- const config = obj.version >= LOCKFILE_VERSION
205
- ? obj.record?.config
206
- : obj.config;
207
- if (typeof config !== 'object' || config === null)
208
- return null;
209
- return migrate(obj);
217
+ return parseLockfile((await fs.readJson(filepath)));
210
218
  }
211
219
  catch {
212
220
  return null;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.29.0",
3
+ "version": "3.30.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": [