@rtorcato/repo-tooling 3.13.1 → 3.14.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
@@ -95,6 +95,8 @@ npx @rtorcato/repo-tooling fix claude-skills --yes --json
95
95
 
96
96
  Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `claude-skills`, the one fixer whose blast radius is outside the repo.
97
97
 
98
+ A fixer may also **refuse** — the target file holds something the generator cannot reproduce, so overwriting would destroy it. Today that is `dependabot` against a config with repo-local `ignore:` rules (#422). A targeted `fix dependabot` then exits 1 with `error: dependabot-ignore-rules`; a bulk `fix --yes` records it `skipped` and carries on with the rest. Neither `--yes` nor `--json` overrides it — resolve the named rules by hand and re-run.
99
+
98
100
  ## Source-of-truth files in the repo
99
101
 
100
102
  - `src/cli/commands/setup.ts` — `ProjectConfig` interface and the setup orchestrator
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
3
3
  import { BLOCK_START, hasStaleAgentBlock } from '../cli/generators/agent-rules.js';
4
4
  import { BADGE_START, hasPublicOnlyBadges } from '../cli/generators/badges.js';
5
5
  import { claudeSkillStatus, SHIPPED_SKILL } from '../cli/generators/claude-skills.js';
6
+ import { DEPENDABOT_CONFIG_PATHS, dependabotIgnoreRules } from '../cli/generators/security.js';
6
7
  import { detectNestedLanguages } from '../cli/utils/detect-language.js';
7
8
  /**
8
9
  * Root-only detection is the decision (#317), not an oversight — but it used to
@@ -235,7 +236,7 @@ export async function checkGitHubActions(dir, preset = null) {
235
236
  }
236
237
  }
237
238
  export async function checkDependabot(dir) {
238
- for (const candidate of ['.github/dependabot.yml', '.github/dependabot.yaml']) {
239
+ for (const candidate of DEPENDABOT_CONFIG_PATHS) {
239
240
  const candidatePath = path.join(dir, candidate);
240
241
  if (await fs.pathExists(candidatePath)) {
241
242
  // The canonical standard (apps/docs/docs/guides/dependabot-strategy.md) is
@@ -250,21 +251,39 @@ export async function checkDependabot(dir) {
250
251
  }
251
252
  }
252
253
  const automergePath = path.join(dir, '.github', 'workflows', 'dependabot-automerge.yml');
253
- if (!(await fs.pathExists(automergePath))) {
254
+ if (await fs.pathExists(automergePath)) {
255
+ // Pre-#423 workflows gated on the semver type alone, so production
256
+ // bumps of a published package merged with nobody in the loop. The
257
+ // canonical gate also requires the dev-minor group.
258
+ const automerge = await fs.readFile(automergePath, 'utf8');
259
+ if (!automerge.includes("dependency-group == 'dev-minor'")) {
260
+ deltas.push('auto-merge workflow merges production bumps (missing dev-minor group gate)');
261
+ }
262
+ }
263
+ else {
254
264
  deltas.push('missing dependabot-automerge workflow');
255
265
  }
266
+ // Repo-local `ignore:` rules aren't drift — they're deliberate, and the
267
+ // canonical config has no way to express them. Report them anyway so the
268
+ // reason `fix dependabot` refuses is visible before anyone runs it (#422).
269
+ const ignored = dependabotIgnoreRules(content);
270
+ const ignoreNote = ignored.length === 0
271
+ ? ''
272
+ : ` — ${ignored.length} local \`ignore:\` rule(s) the canonical config cannot express (${ignored.join(', ')}); \`fix dependabot\` refuses rather than drop them`;
256
273
  if (deltas.length > 0) {
257
274
  return {
258
275
  check: 'Dependabot',
259
276
  status: 'drift',
260
- detail: `${candidate} drifts from canonical (${deltas.join('; ')})`,
261
- hint: 'Run `npx @rtorcato/repo-tooling fix dependabot` to apply the canonical grouping + auto-merge workflow',
277
+ detail: `${candidate} drifts from canonical (${deltas.join('; ')})${ignoreNote}`,
278
+ hint: ignored.length > 0
279
+ ? 'Run `npx @rtorcato/repo-tooling fix dependabot --diff` — it will refuse until the `ignore:` block is dealt with by hand'
280
+ : 'Run `npx @rtorcato/repo-tooling fix dependabot` to apply the canonical grouping + auto-merge workflow',
262
281
  };
263
282
  }
264
283
  return {
265
284
  check: 'Dependabot',
266
285
  status: 'ok',
267
- detail: `${candidate} + auto-merge workflow`,
286
+ detail: `${candidate} + auto-merge workflow${ignoreNote}`,
268
287
  };
269
288
  }
270
289
  }
@@ -21,7 +21,8 @@ import { generateBrand } from '../cli/generators/brand.js';
21
21
  import { generateCommunityHealth } from '../cli/generators/community-health.js';
22
22
  import { generateCommitlintConfig } from '../cli/generators/git.js';
23
23
  import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
24
- import { generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, } from '../cli/generators/security.js';
24
+ import { findDependabotIgnoreRules, generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, } from '../cli/generators/security.js';
25
+ import { classifyCopiedAssets } from '../cli/utils/copied-assets.js';
25
26
  import { copyPreset } from '../cli/utils/copy-preset.js';
26
27
  import { detectLanguage } from '../cli/utils/detect-language.js';
27
28
  import { resolveLanguageModule } from '../languages/registry.js';
@@ -75,6 +76,24 @@ async function resolveInstallDir(explicit, assumeYes) {
75
76
  return trimmed ? path.resolve(trimmed) : null;
76
77
  }
77
78
  export const BASE_FIXERS = [
79
+ {
80
+ target: 'copied-assets',
81
+ description: 'Re-copy presets that are unchanged since they were copied but older than the version this package ships',
82
+ appliesTo: ['Copied assets'],
83
+ outputs: ['(the stale copied presets, re-copied in place)'],
84
+ canFixDrift: true,
85
+ async run({ targetDir }) {
86
+ // Only the `stale` ones: their content provably still matches what was
87
+ // copied, so overwriting loses nothing. `modified` assets are somebody's
88
+ // deliberate fork and stay a human decision (#428).
89
+ const stale = (await classifyCopiedAssets(targetDir)).filter((a) => a.state === 'stale');
90
+ const filesWritten = [];
91
+ for (const asset of stale) {
92
+ filesWritten.push((await copyPreset(asset.preset, targetDir)).target);
93
+ }
94
+ return { filesWritten };
95
+ },
96
+ },
78
97
  {
79
98
  target: 'editorconfig',
80
99
  description: 'Scaffold .editorconfig (UTF-8, LF, tab indent)',
@@ -112,6 +131,16 @@ export const BASE_FIXERS = [
112
131
  outputs: ['.github/dependabot.yml', '.github/workflows/dependabot-automerge.yml'],
113
132
  canFixDrift: true,
114
133
  async run({ targetDir }) {
134
+ // The template owns the whole file but emits no `ignore:` block, so
135
+ // regenerating deletes any repo-local ignore rule — silently, and with
136
+ // nothing in `doctor` to report the loss afterwards, because from the
137
+ // fixer's point of view the file then matches the standard exactly (#422).
138
+ // Refuse rather than warn: an unattended `fix --yes` would walk past a
139
+ // warning, and the rules are unrecoverable once written over.
140
+ const ignored = await findDependabotIgnoreRules(targetDir);
141
+ if (ignored) {
142
+ throw new FixerAbort('dependabot-ignore-rules', `refusing to overwrite ${ignored.file} — it has ${ignored.rules.length} \`ignore:\` rule(s) the canonical config does not reproduce: ${ignored.rules.join(', ')}`, `re-add the \`ignore:\` block after regenerating, or delete it from ${ignored.file} to accept the loss — then re-run \`fix dependabot\``);
143
+ }
115
144
  const { dependabotEcosystem } = await moduleFor(targetDir);
116
145
  return { filesWritten: await generateDependabotConfig(targetDir, dependabotEcosystem) };
117
146
  },
@@ -51,11 +51,15 @@ export const GITHUB_STANDARD = {
51
51
  requiredContexts: ['lint', 'typecheck', 'build', 'test'],
52
52
  };
53
53
  const CODE_SCANNING_CHECK = 'Code-scanning gate';
54
+ const RELEASE_GATE_CHECK = 'Release gate';
55
+ const RELEASE_ENV_CHECK = 'Release environment';
54
56
  const CHECK_NAMES = [
55
57
  'Branch protection',
56
58
  'Merge settings',
57
59
  'Workflow permissions',
58
60
  CODE_SCANNING_CHECK,
61
+ RELEASE_GATE_CHECK,
62
+ RELEASE_ENV_CHECK,
59
63
  ];
60
64
  // Recommended CodeQL alert thresholds — GitHub's UI defaults. This is the
61
65
  // override surface: bump them here for a stricter/looser fleet-wide baseline.
@@ -143,6 +147,7 @@ export async function checkGitHubSettings(dir, exec) {
143
147
  checkMergeSettings(info),
144
148
  await checkWorkflowPermissions(gh, info.nwo),
145
149
  await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir),
150
+ ...(await checkReleaseGate(gh, info.nwo, dir)),
146
151
  ];
147
152
  }
148
153
  function probeFailureReason(probe) {
@@ -425,6 +430,270 @@ async function checkCodeScanningRuleset(gh, nwo, branch, dir) {
425
430
  : 'Run `npx @rtorcato/repo-tooling fix github-settings` to add a code_scanning branch ruleset that blocks merge on High+ CodeQL alerts',
426
431
  };
427
432
  }
433
+ // --- Release environment gate (#429) --------------------------------------
434
+ /** The environment name the standard reserves for the publish gate. */
435
+ const RELEASE_ENVIRONMENT = 'release';
436
+ /**
437
+ * What a job has to run for a merge to the default branch to reach a registry.
438
+ * `semantic-release` counts on its own — the shipped preset publishes with it.
439
+ */
440
+ const PUBLISH_COMMAND = /semantic-release|changesets\/action|(?:npm|pnpm|yarn)\s+publish/;
441
+ const GATE_HINT = 'Create a `release` environment with required reviewers (Settings → Environments) and add `environment: release` to the publishing job — a merge to the default branch then leaves the run `waiting` instead of publishing';
442
+ const ENV_HINT = 'Add `environment: release` to the publishing job, or delete the environment — whichever was meant. An environment nothing references still lists under Settings → Environments as though it gates something';
443
+ const unquote = (s) => s.replace(/^['"]|['"]$/g, '');
444
+ /**
445
+ * A workflow's `jobs:` blocks, keyed by job id.
446
+ *
447
+ * Hand-split rather than parsed: this package ships no YAML dependency, and the
448
+ * only question asked of the result is whether *one particular job* carries an
449
+ * `environment:` key. A whole-file grep would answer that wrong on any repo with
450
+ * a `github-pages` deploy job — which is the exact false negative this check
451
+ * exists to avoid. Jobs sit one indent level under `jobs:` and their keys one
452
+ * level below that, which holds for every workflow Actions accepts.
453
+ */
454
+ export function workflowJobs(yaml) {
455
+ const jobs = new Map();
456
+ const lines = yaml.split('\n');
457
+ const start = lines.findIndex((l) => /^jobs:\s*$/.test(l));
458
+ if (start === -1)
459
+ return jobs;
460
+ const indentOf = (l) => l.length - l.trimStart().length;
461
+ // The block runs until the next top-level key. A column-0 comment is not one —
462
+ // it ends nothing, so skipping it keeps a stray comment between `jobs:` and its
463
+ // first job from truncating the block and hiding every job below it.
464
+ let end = lines.length;
465
+ for (let i = start + 1; i < lines.length; i++) {
466
+ const l = lines[i] ?? '';
467
+ if (l.trim() !== '' && !l.trimStart().startsWith('#') && indentOf(l) === 0) {
468
+ end = i;
469
+ break;
470
+ }
471
+ }
472
+ const body = lines.slice(start + 1, end);
473
+ const first = body.find((l) => l.trim() !== '' && !l.trimStart().startsWith('#'));
474
+ if (first === undefined)
475
+ return jobs;
476
+ const jobIndent = indentOf(first);
477
+ let id = null;
478
+ let buf = [];
479
+ for (const line of body) {
480
+ const header = line.trim() !== '' && indentOf(line) === jobIndent
481
+ ? /^([\w.-]+):/.exec(line.trim())?.[1]
482
+ : undefined;
483
+ if (header) {
484
+ if (id)
485
+ jobs.set(id, buf.join('\n'));
486
+ id = header;
487
+ buf = [];
488
+ }
489
+ else if (id) {
490
+ buf.push(line);
491
+ }
492
+ }
493
+ if (id)
494
+ jobs.set(id, buf.join('\n'));
495
+ return jobs;
496
+ }
497
+ /**
498
+ * The environment a job runs in, in either form Actions accepts: the scalar
499
+ * `environment: release`, or a block whose `name:` names it. Null when the job
500
+ * declares none.
501
+ */
502
+ export function jobEnvironment(body) {
503
+ const inline = /^[ \t]*environment:[ \t]*(\S+)[ \t]*$/m.exec(body);
504
+ if (inline?.[1])
505
+ return unquote(inline[1]);
506
+ const at = body.search(/^[ \t]*environment:[ \t]*$/m);
507
+ if (at === -1)
508
+ return null;
509
+ // Step names are list items (`- name:`), so the first bare `name:` after the
510
+ // block opener is the environment's.
511
+ const name = /^[ \t]*name:[ \t]*(\S+)/m.exec(body.slice(at))?.[1];
512
+ return name ? unquote(name) : null;
513
+ }
514
+ /**
515
+ * A job body with whole-line comments dropped. Same reasoning as
516
+ * `hookHasUncommented` in base/checks.ts: a `#` line runs nothing, so matching
517
+ * it is a false positive. Observed on this repo's own ci.yml, where a `varcheck`
518
+ * job carrying `# npm publish uses OIDC trusted publishing` was reported as the
519
+ * publishing job. Comments are stripped rather than pattern-tested because
520
+ * `jobEnvironment` needs the same treatment — a commented-out `environment:`
521
+ * would otherwise read as a live gate, the exact inversion of this check.
522
+ */
523
+ function withoutComments(body) {
524
+ return body
525
+ .split('\n')
526
+ .filter((line) => !line.trimStart().startsWith('#'))
527
+ .join('\n');
528
+ }
529
+ /** `private: true` — nothing reaches a registry, so no gate is owed. */
530
+ async function isPrivatePackage(dir) {
531
+ try {
532
+ const pkg = await fs.readJson(path.join(dir, 'package.json'));
533
+ return pkg?.private === true;
534
+ }
535
+ catch {
536
+ return false;
537
+ }
538
+ }
539
+ /**
540
+ * The first workflow job that runs a publish command, or null if none does.
541
+ *
542
+ * `'skip'` when the directory exists but can't be read — a permission error or a
543
+ * broken symlink must not read as "nothing publishes", which would report the
544
+ * gate as `ok` on a repo whose workflows were never inspected. Same shape as
545
+ * `readEnvironments`: absent is an answer, unreadable is not.
546
+ */
547
+ async function findPublishJob(dir) {
548
+ const workflowsDir = path.join(dir, '.github', 'workflows');
549
+ if (!(await fs.pathExists(workflowsDir)))
550
+ return null;
551
+ try {
552
+ for (const f of (await fs.readdir(workflowsDir)).sort()) {
553
+ if (!/\.ya?ml$/.test(f))
554
+ continue;
555
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
556
+ if (!PUBLISH_COMMAND.test(content))
557
+ continue;
558
+ for (const [job, raw] of workflowJobs(content)) {
559
+ const body = withoutComments(raw);
560
+ if (PUBLISH_COMMAND.test(body)) {
561
+ return { file: f, job, environment: jobEnvironment(body) };
562
+ }
563
+ }
564
+ }
565
+ }
566
+ catch {
567
+ return 'skip';
568
+ }
569
+ return null;
570
+ }
571
+ async function readEnvironments(gh, nwo) {
572
+ const r = await gh(['api', `repos/${nwo}/environments`]);
573
+ // A repo with no environments can answer 404 — that's "none", not unreadable.
574
+ if (!r.ok)
575
+ return /404|not found/i.test(r.stderr) ? new Map() : 'skip';
576
+ try {
577
+ const parsed = JSON.parse(r.stdout);
578
+ const envs = new Map();
579
+ for (const e of parsed.environments ?? []) {
580
+ if (typeof e.name !== 'string')
581
+ continue;
582
+ envs.set(e.name, (e.protection_rules ?? []).some((p) => p.type === 'required_reviewers'));
583
+ }
584
+ return envs;
585
+ }
586
+ catch {
587
+ return 'skip';
588
+ }
589
+ }
590
+ /**
591
+ * The gap between merging the default branch and publishing to npm (#429). On
592
+ * the shipped semantic-release preset those are one event: nothing stands
593
+ * between a squash-merge and a new version on the registry.
594
+ *
595
+ * Two checks, because they fail differently and want different answers:
596
+ *
597
+ * - **Release gate** — the publishing job runs behind no environment at all, or
598
+ * behind one that isn't really a gate (absent, or with no required reviewers).
599
+ * - **Release environment** — the repo *has* a `release` environment and no job
600
+ * references it. That's the failure mode worth catching: an unreferenced
601
+ * environment gates nothing while reading as a gate in the GitHub UI.
602
+ *
603
+ * The two never both fire on one repo. "No `environment:` and no `release`
604
+ * environment" is the gate check's; "no `environment:` but a `release`
605
+ * environment exists" is the environment check's, and the gate check defers.
606
+ *
607
+ * A repo that publishes nothing is `ok` — not applicable, not drift. There is
608
+ * no fixer: creating the environment needs a `required_reviewers` list only a
609
+ * human can supply, so both hints describe the manual step.
610
+ */
611
+ async function checkReleaseGate(gh, nwo, dir) {
612
+ const publish = (await isPrivatePackage(dir)) ? null : await findPublishJob(dir);
613
+ if (publish === 'skip') {
614
+ const reason = 'could not read .github/workflows';
615
+ return [skip(RELEASE_GATE_CHECK, reason), skip(RELEASE_ENV_CHECK, reason)];
616
+ }
617
+ if (!publish) {
618
+ const detail = 'not applicable — no workflow job publishes to a registry';
619
+ return [
620
+ { check: RELEASE_GATE_CHECK, status: 'ok', detail },
621
+ { check: RELEASE_ENV_CHECK, status: 'ok', detail },
622
+ ];
623
+ }
624
+ const envs = await readEnvironments(gh, nwo);
625
+ if (envs === 'skip') {
626
+ const reason = 'could not read environments';
627
+ return [skip(RELEASE_GATE_CHECK, reason), skip(RELEASE_ENV_CHECK, reason)];
628
+ }
629
+ const named = publish.environment;
630
+ const where = `${publish.file} \`${publish.job}\``;
631
+ const hasRelease = envs.has(RELEASE_ENVIRONMENT);
632
+ let gate;
633
+ if (named === null) {
634
+ gate = hasRelease
635
+ ? {
636
+ check: RELEASE_GATE_CHECK,
637
+ status: 'ok',
638
+ detail: `${where} declares no environment — reported by the ${RELEASE_ENV_CHECK} check`,
639
+ }
640
+ : {
641
+ check: RELEASE_GATE_CHECK,
642
+ status: 'drift',
643
+ detail: `${where} publishes with no \`environment:\` and the repo has no \`${RELEASE_ENVIRONMENT}\` environment — anything that lands on the default branch publishes`,
644
+ hint: GATE_HINT,
645
+ };
646
+ }
647
+ else if (!envs.has(named)) {
648
+ gate = {
649
+ check: RELEASE_GATE_CHECK,
650
+ status: 'drift',
651
+ detail: `${where} names the \`${named}\` environment, which the repo does not have — Actions creates it unprotected on first run, so the publish is never held`,
652
+ hint: GATE_HINT,
653
+ };
654
+ }
655
+ else if (!envs.get(named)) {
656
+ gate = {
657
+ check: RELEASE_GATE_CHECK,
658
+ status: 'drift',
659
+ detail: `the \`${named}\` environment has no required_reviewers — ${where} passes straight through it`,
660
+ hint: GATE_HINT,
661
+ };
662
+ }
663
+ else {
664
+ gate = {
665
+ check: RELEASE_GATE_CHECK,
666
+ status: 'ok',
667
+ detail: `${where} runs behind the \`${named}\` environment (required reviewers)`,
668
+ };
669
+ }
670
+ let environment;
671
+ if (!hasRelease) {
672
+ environment = {
673
+ check: RELEASE_ENV_CHECK,
674
+ status: 'ok',
675
+ detail: `no \`${RELEASE_ENVIRONMENT}\` environment — nothing to reference`,
676
+ };
677
+ }
678
+ else if (named === RELEASE_ENVIRONMENT) {
679
+ environment = {
680
+ check: RELEASE_ENV_CHECK,
681
+ status: 'ok',
682
+ detail: `${where} references the \`${RELEASE_ENVIRONMENT}\` environment`,
683
+ };
684
+ }
685
+ else {
686
+ environment = {
687
+ check: RELEASE_ENV_CHECK,
688
+ status: 'drift',
689
+ detail: named
690
+ ? `the repo has a \`${RELEASE_ENVIRONMENT}\` environment but ${where} runs in \`${named}\` — \`${RELEASE_ENVIRONMENT}\` gates nothing while still reading as a gate in the GitHub UI`
691
+ : `the repo has a \`${RELEASE_ENVIRONMENT}\` environment but ${where} references no environment — it gates nothing while still reading as a gate in the GitHub UI`,
692
+ hint: ENV_HINT,
693
+ };
694
+ }
695
+ return [gate, environment];
696
+ }
428
697
  /** The branch-protection body PUT to the API — mirrors the doctor standard. */
429
698
  const PROTECTION_BODY = JSON.stringify({
430
699
  required_status_checks: { strict: false, contexts: GITHUB_STANDARD.requiredContexts },
@@ -15,6 +15,7 @@ import { detectLanguage } from '../utils/detect-language.js';
15
15
  import { checkGitHubSettings } from '../../base/github-settings.js';
16
16
  import { checkMilestones } from '../../base/milestones.js';
17
17
  import { checkGitIdentity } from '../../base/git-identity.js';
18
+ import { checkCopiedAssets } from '../utils/copied-assets.js';
18
19
  import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
19
20
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
20
21
  import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
@@ -150,6 +151,9 @@ function demoteDeclined(results, lock) {
150
151
  async function runBaseChecks(dir, lock, opts) {
151
152
  const results = [];
152
153
  results.push(checkLockfile(lock));
154
+ // Any language can have copied presets (swiftlint, ruff, perlcriticrc), so
155
+ // this rides with the base suite rather than a module's (#428).
156
+ results.push(await checkCopiedAssets(dir));
153
157
  results.push(await checkNestedLanguages(dir, opts.language));
154
158
  results.push(await checkGitIdentity(dir));
155
159
  results.push(await checkEditorConfig(dir));
@@ -47,6 +47,7 @@ export const FIX_TARGETS = {
47
47
  'AI setup': 'ai',
48
48
  'Claude worktree settings': 'ai',
49
49
  'Claude skills': 'claude-skills',
50
+ 'Copied assets': 'copied-assets',
50
51
  };
51
52
  /**
52
53
  * Where the Swift module's fixers shadow (or extend) the JS-named defaults
@@ -133,7 +133,17 @@ async function previewFixer(fixer, result, targetDir, pkg, lock) {
133
133
  },
134
134
  });
135
135
  // assumeYes: a preview must never prompt.
136
- await fixer.run({ targetDir: tmpDir, pkg, result, lock, assumeYes: true });
136
+ try {
137
+ await fixer.run({ targetDir: tmpDir, pkg, result, lock, assumeYes: true });
138
+ }
139
+ catch (err) {
140
+ // A fixer that refuses has nothing to preview. Swallow it here rather than
141
+ // reporting it twice — the real run happens moments later and its abort is
142
+ // what the caller prints.
143
+ if (!(err instanceof FixerAbort))
144
+ throw err;
145
+ return [];
146
+ }
137
147
  const previews = [];
138
148
  const seen = new Set();
139
149
  for (const output of fixer.outputs) {
@@ -398,10 +408,9 @@ export async function fixCommand(target, options = {}) {
398
408
  console.log(chalk.gray(' skipped\n'));
399
409
  return;
400
410
  }
401
- // ponytail: only the targeted path is guarded, because the only fixer that
402
- // aborts is `claude-skills` and it is explicitOnly — the bulk loop below
403
- // cannot reach it. A non-explicitOnly fixer that throws FixerAbort would
404
- // surface as an unhandled rejection there; guard the loop when one exists.
411
+ // A targeted abort is fatal: the user named this fixer, so failing to run it
412
+ // is the whole outcome of the command. The bulk loop below takes the other
413
+ // branch and records a skip, since one refusal must not abandon the rest.
405
414
  const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent, {
406
415
  skillsDir: options.skillsDir,
407
416
  assumeYes,
@@ -471,10 +480,27 @@ export async function fixCommand(target, options = {}) {
471
480
  skippedCount++;
472
481
  continue;
473
482
  }
474
- const outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, {
475
- skillsDir: options.skillsDir,
476
- assumeYes,
477
- });
483
+ // A fixer that refuses (e.g. dependabot, when the existing config carries
484
+ // repo-local `ignore:` rules the template can't reproduce) is a skip, not a
485
+ // crash — the remaining findings still deserve their fixers. The reason goes
486
+ // to stderr so it survives `--json`.
487
+ let outcome;
488
+ try {
489
+ outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, {
490
+ skillsDir: options.skillsDir,
491
+ assumeYes,
492
+ });
493
+ }
494
+ catch (err) {
495
+ if (!(err instanceof FixerAbort))
496
+ throw err;
497
+ actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
498
+ console.error(chalk.red(` refused — ${err.message}`));
499
+ if (err.hint)
500
+ console.error(chalk.gray(` ${err.hint}`));
501
+ skippedCount++;
502
+ continue;
503
+ }
478
504
  actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
479
505
  appliedCount++;
480
506
  }
@@ -29,13 +29,16 @@ export function dependabotConfig(ecosystem) {
29
29
  prefix: chore
30
30
  include: scope
31
31
  groups:
32
- # Safe tier: runtime + dev minor/patch auto-merge on green (see the
33
- # dependabot-automerge workflow). Grouped so react/react-dom move together.
32
+ # Runtime minor/patch. Grouped so react/react-dom move together, but NOT
33
+ # auto-merged — these ship to consumers of a published package, so they
34
+ # get a human (see the dependabot-automerge workflow).
34
35
  production-minor:
35
36
  dependency-type: production
36
37
  update-types:
37
38
  - minor
38
39
  - patch
40
+ # The only tier that auto-merges on green — dev tooling never reaches a
41
+ # consumer of the published package.
39
42
  dev-minor:
40
43
  dependency-type: development
41
44
  update-types:
@@ -62,11 +65,71 @@ ${manifest} - package-ecosystem: github-actions
62
65
  /** The JS flavour — the historical default, kept for the generators that don't
63
66
  * resolve a language module. */
64
67
  export const DEPENDABOT_CONFIG = dependabotConfig('npm');
68
+ /** Where `.github/dependabot.yml` can live, in the order Dependabot resolves it. */
69
+ export const DEPENDABOT_CONFIG_PATHS = [
70
+ '.github/dependabot.yml',
71
+ '.github/dependabot.yaml',
72
+ ];
73
+ /**
74
+ * The `ignore:` rules an existing dependabot.yml carries, named by
75
+ * `dependency-name` (#422).
76
+ *
77
+ * `dependabotConfig()` owns the whole file but emits no `ignore:` block, and
78
+ * `ignore` is precisely the key that is inherently repo-local — a pin held back
79
+ * by hand, with the reason usually in a comment above it. So every rule this
80
+ * finds is one a regeneration would delete silently.
81
+ *
82
+ * ponytail: a line scanner, not a YAML parse — the repo ships no YAML parser and
83
+ * this only needs to answer "is there something here to lose, and what is it
84
+ * called". A rule split across lines (`-` alone, `dependency-name` beneath)
85
+ * reports as `<unnamed rule>`, which still stops the overwrite. Reach for a
86
+ * parser if the message ever has to reproduce the rules rather than name them.
87
+ */
88
+ export function dependabotIgnoreRules(content) {
89
+ const lines = content.split('\n');
90
+ const rules = [];
91
+ for (const [index, line] of lines.entries()) {
92
+ const header = /^(\s*)ignore:\s*(#.*)?$/.exec(line);
93
+ if (!header)
94
+ continue;
95
+ const blockIndent = (header[1] ?? '').length;
96
+ // Rules sit at the first list indent under `ignore:`; deeper `- ` lines are
97
+ // an entry's own values (`update-types:`) and must not count as rules.
98
+ let itemIndent = null;
99
+ const names = [];
100
+ let items = 0;
101
+ for (const next of lines.slice(index + 1)) {
102
+ if (next.trim() === '')
103
+ continue;
104
+ const indent = next.search(/\S/);
105
+ if (indent <= blockIndent)
106
+ break;
107
+ if (/^\s*-\s/.test(next)) {
108
+ itemIndent ??= indent;
109
+ if (indent === itemIndent)
110
+ items++;
111
+ }
112
+ const named = /(?:^|\s)dependency-name:\s*["']?([^"'#\s]+)/.exec(next);
113
+ if (named?.[1] && (itemIndent === null || indent >= itemIndent))
114
+ names.push(named[1]);
115
+ }
116
+ while (names.length < items)
117
+ names.push('<unnamed rule>');
118
+ rules.push(...names);
119
+ }
120
+ return rules;
121
+ }
65
122
  /**
66
- * Auto-merges patch + minor Dependabot PRs once CI is green. Requires branch
67
- * protection with required status checks on the target branch — without it,
68
- * \`gh pr merge --auto\` never fires. Majors are excluded (they land in the
69
- * major-updates group for manual triage).
123
+ * Auto-merges patch + minor Dependabot PRs **from the `dev-minor` group only**
124
+ * once CI is green. Requires branch protection with required status checks on
125
+ * the target branch — without it, \`gh pr merge --auto\` never fires.
126
+ *
127
+ * Everything else falls through to a human: production bumps ship to consumers
128
+ * of a published package (#423), majors are breaking by definition, and an
129
+ * ungrouped PR reports an empty \`dependency-group\`, so the gate fails closed.
130
+ *
131
+ * The group name is the one \`dependabotConfig()\` writes — the two files are a
132
+ * paired unit and have to move together.
70
133
  */
71
134
  export const DEPENDABOT_AUTOMERGE_WORKFLOW = `name: Dependabot auto-merge
72
135
 
@@ -87,10 +150,14 @@ jobs:
87
150
  with:
88
151
  github-token: \${{ secrets.GITHUB_TOKEN }}
89
152
 
90
- - name: Auto-merge patch and minor updates
153
+ # Belt and braces: the dev-minor group is already declared minor+patch in
154
+ # dependabot.yml, but this workflow is the security gate and shouldn't
155
+ # trust a config file a consumer repo can edit independently.
156
+ - name: Auto-merge dev-dependency patch and minor updates
91
157
  if: |
92
- steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
93
- steps.metadata.outputs.update-type == 'version-update:semver-minor'
158
+ steps.metadata.outputs.dependency-group == 'dev-minor' &&
159
+ (steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
160
+ steps.metadata.outputs.update-type == 'version-update:semver-minor')
94
161
  run: gh pr merge --auto --squash "$PR_URL"
95
162
  env:
96
163
  PR_URL: \${{ github.event.pull_request.html_url }}
@@ -101,6 +168,20 @@ export const DEPENDABOT_FILES = [
101
168
  '.github/dependabot.yml',
102
169
  '.github/workflows/dependabot-automerge.yml',
103
170
  ];
171
+ /**
172
+ * The repo-local `ignore:` rules a regeneration would delete, with the file
173
+ * they live in — or null when there is nothing to lose (#422).
174
+ */
175
+ export async function findDependabotIgnoreRules(targetDir) {
176
+ for (const file of DEPENDABOT_CONFIG_PATHS) {
177
+ const candidate = path.join(targetDir, file);
178
+ if (!(await fs.pathExists(candidate)))
179
+ continue;
180
+ const rules = dependabotIgnoreRules(await fs.readFile(candidate, 'utf8'));
181
+ return rules.length > 0 ? { file, rules } : null;
182
+ }
183
+ return null;
184
+ }
104
185
  /**
105
186
  * Scaffold the canonical Dependabot setup: the grouped \`dependabot.yml\` **and**
106
187
  * the auto-merge workflow. They're a paired unit — the config batches updates
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Drift detection for copied presets (#428).
3
+ *
4
+ * `copy <preset>` used to be a one-shot: it wrote the file and nothing ever
5
+ * looked at it again, so eight copies of `sync-changelog.mjs` drifted into six
6
+ * versions with no signal. The fix is one recorded fact — the asset's pristine
7
+ * hash at copy time, in `.repo-tooling.json` — which is enough to separate the
8
+ * two cases that matter:
9
+ *
10
+ * - file ≠ recorded hash → `modified`. Someone edited it on purpose (js-common
11
+ * forked this very script and documented why in its header). Reported, never
12
+ * overwritten, never counted as drift.
13
+ * - file = recorded hash, but the shipped asset has moved on → `stale`. Nothing
14
+ * local is in it, so re-copying is lossless.
15
+ * - no recorded hash → `unknown`. Copies predating this, and repos with no
16
+ * lockfile at all. Absence of a record is not evidence of drift, so this
17
+ * never fails a build.
18
+ */
19
+ import path from 'node:path';
20
+ import { getPackageRoot, hashFile, PRESETS } from './copy-preset.js';
21
+ import { readLockfile } from './lockfile.js';
22
+ /**
23
+ * Classify every preset whose target file exists in `dir`. Presets that were
24
+ * never copied here are absent from the result rather than reported missing —
25
+ * `copy` is opt-in, and doctor already has checks for the files that aren't.
26
+ */
27
+ export async function classifyCopiedAssets(dir) {
28
+ const lock = await readLockfile(dir);
29
+ const packageRoot = getPackageRoot();
30
+ const statuses = [];
31
+ for (const name of Object.keys(PRESETS)) {
32
+ const preset = PRESETS[name];
33
+ const current = await hashFile(path.join(dir, preset.target));
34
+ if (current === null)
35
+ continue;
36
+ const recorded = lock?.assets?.[name];
37
+ // Unmodified since the copy, so whether it's stale is purely a question of
38
+ // what this package ships now. A source we can't read (shouldn't happen)
39
+ // falls back to the recorded hash — "no news", not drift.
40
+ const shipped = recorded
41
+ ? ((await hashFile(path.join(packageRoot, preset.source))) ?? recorded)
42
+ : null;
43
+ let state = 'ok';
44
+ if (!recorded)
45
+ state = 'unknown';
46
+ else if (recorded !== current)
47
+ state = 'modified';
48
+ else if (shipped !== recorded)
49
+ state = 'stale';
50
+ statuses.push({ preset: name, target: preset.target, state });
51
+ }
52
+ return statuses;
53
+ }
54
+ const listOf = (s) => s.map((a) => a.preset).join(', ');
55
+ export async function checkCopiedAssets(dir) {
56
+ const check = 'Copied assets';
57
+ const all = await classifyCopiedAssets(dir);
58
+ if (all.length === 0) {
59
+ return { check, status: 'optional-missing', detail: 'no copied presets found' };
60
+ }
61
+ const by = (state) => all.filter((a) => a.state === state);
62
+ const stale = by('stale');
63
+ const modified = by('modified');
64
+ const unknown = by('unknown');
65
+ const parts = [`${by('ok').length} in sync`];
66
+ if (modified.length > 0)
67
+ parts.push(`${modified.length} locally modified: ${listOf(modified)}`);
68
+ if (unknown.length > 0)
69
+ parts.push(`${unknown.length} untracked: ${listOf(unknown)}`);
70
+ if (stale.length === 0) {
71
+ return { check, status: 'ok', detail: parts.join('; ') };
72
+ }
73
+ return {
74
+ check,
75
+ status: 'drift',
76
+ detail: `${stale.length} stale: ${listOf(stale)}; ${parts.join('; ')}`,
77
+ hint: 'Run `npx @rtorcato/repo-tooling fix copied-assets` to re-copy them. They still match what was copied, so nothing local is lost — locally modified assets are left alone.',
78
+ };
79
+ }
@@ -1,5 +1,7 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import path from 'node:path';
2
3
  import fs from 'fs-extra';
4
+ import { recordAssetHash } from './lockfile.js';
3
5
  export const PRESETS = {
4
6
  biome: {
5
7
  source: 'tooling/biome/biome.json',
@@ -97,6 +99,11 @@ export const PRESETS = {
97
99
  target: 'scripts/sync-changelog.mjs',
98
100
  desc: 'Canonical CHANGELOG → docs sync script for Docusaurus sites',
99
101
  },
102
+ 'docusaurus-docs-helpers': {
103
+ source: 'tooling/docusaurus/docs-helpers.mjs',
104
+ target: 'scripts/docs-helpers.mjs',
105
+ desc: 'Docs-generator helpers (markdown-table escaping, export parser, generated-block splice)',
106
+ },
100
107
  'docusaurus-theme-tokens': {
101
108
  source: 'tooling/docusaurus/theme-tokens.css',
102
109
  target: 'apps/docs/src/css/_jt-tokens.css',
@@ -112,6 +119,17 @@ export function getPackageRoot() {
112
119
  const cliFile = new URL(import.meta.url).pathname;
113
120
  return path.dirname(path.dirname(path.dirname(path.dirname(cliFile))));
114
121
  }
122
+ /** sha256 of a file's bytes, or null when it doesn't exist / can't be read. */
123
+ export async function hashFile(filepath) {
124
+ try {
125
+ return createHash('sha256')
126
+ .update(await fs.readFile(filepath))
127
+ .digest('hex');
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
115
133
  export async function copyPreset(name, targetDir = process.cwd()) {
116
134
  const preset = PRESETS[name];
117
135
  const packageRoot = getPackageRoot();
@@ -124,6 +142,11 @@ export async function copyPreset(name, targetDir = process.cwd()) {
124
142
  if (legacyPath !== targetPath)
125
143
  await fs.remove(legacyPath);
126
144
  }
145
+ // Stamp what was copied, so doctor can later tell a local edit from a copy
146
+ // the package has moved past (#428). No-ops when the repo has no lockfile.
147
+ const hash = await hashFile(sourcePath);
148
+ if (hash)
149
+ await recordAssetHash(targetDir, name, hash);
127
150
  return {
128
151
  source: preset.source,
129
152
  target: preset.target,
@@ -13,12 +13,14 @@ export const LEGACY_TOOL_NAME = 'js-tooling';
13
13
  export const LEGACY_LOCKFILE_NAME = `.${LEGACY_TOOL_NAME}.json`;
14
14
  // v2 added ProjectConfig.language (multi-language seam, #140). v1 files are
15
15
  // migrated to v2 on read, defaulting language to 'js'.
16
- export const LOCKFILE_VERSION = 2;
16
+ // v3 added `assets` — the pristine hash of each copied preset (#428). Older
17
+ // files carry no hashes, which reads as "not tracked", never as drift.
18
+ export const LOCKFILE_VERSION = 3;
17
19
  const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/lockfile.json';
18
20
  /**
19
21
  * Upgrade an older lockfile in-memory. Only touches files older than the
20
22
  * current version, so a newer-than-supported file is left as-is for
21
- * checkLockfile to flag. The file is rewritten to v2 next time it's saved.
23
+ * checkLockfile to flag. The file is rewritten to v3 next time it's saved.
22
24
  */
23
25
  function migrate(lock) {
24
26
  if (lock.version >= LOCKFILE_VERSION)
@@ -27,6 +29,7 @@ function migrate(lock) {
27
29
  ...lock,
28
30
  version: LOCKFILE_VERSION,
29
31
  config: { language: 'js', ...lock.config },
32
+ assets: lock.assets ?? {},
30
33
  };
31
34
  }
32
35
  export async function readLockfile(dir) {
@@ -53,16 +56,23 @@ export async function readLockfile(dir) {
53
56
  return null;
54
57
  }
55
58
  }
56
- export async function writeLockfile(dir, config) {
59
+ /**
60
+ * @param assets Recorded asset hashes to write. Omit to carry the existing
61
+ * file's hashes forward — every caller that only means to update `config`
62
+ * would otherwise silently drop them.
63
+ */
64
+ export async function writeLockfile(dir, config, assets) {
57
65
  const { valid, errors } = validateProjectConfig(config);
58
66
  if (!valid) {
59
67
  throw new Error(`Refusing to write invalid lockfile:\n - ${errors.join('\n - ')}`);
60
68
  }
69
+ const carried = assets ?? (await readLockfile(dir))?.assets;
61
70
  const filepath = path.join(dir, LOCKFILE_NAME);
62
71
  const lockfile = {
63
72
  $schema: LOCKFILE_SCHEMA_URL,
64
73
  version: LOCKFILE_VERSION,
65
74
  config,
75
+ ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
66
76
  writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
67
77
  writtenAt: new Date().toISOString(),
68
78
  };
@@ -86,3 +96,16 @@ export async function updateLockfileConfig(dir, patch) {
86
96
  await writeLockfile(dir, merged);
87
97
  return true;
88
98
  }
99
+ /**
100
+ * Record the pristine hash of a just-copied preset (#428). Returns false when
101
+ * the repo has no lockfile — four family repos don't, and creating one as a
102
+ * side effect of `copy` would be a surprise. Those repos keep reporting the
103
+ * asset as untracked, which is the honest answer.
104
+ */
105
+ export async function recordAssetHash(dir, preset, hash) {
106
+ const existing = await readLockfile(dir);
107
+ if (!existing)
108
+ return false;
109
+ await writeLockfile(dir, existing.config, { ...existing.assets, [preset]: hash });
110
+ return true;
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.13.1",
3
+ "version": "3.14.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": [
@@ -87,6 +87,7 @@
87
87
  "tooling/docusaurus/index.mjs",
88
88
  "tooling/docusaurus/index.d.mts",
89
89
  "tooling/docusaurus/sync-changelog.mjs",
90
+ "tooling/docusaurus/docs-helpers.mjs",
90
91
  "tooling/docusaurus/theme-tokens.css",
91
92
  "tooling/docusaurus/theme.css",
92
93
  "tooling/biome/biome.json",
@@ -60,6 +60,8 @@ header names the agent *and* says why it is wearing a human's face:
60
60
  | `ai-wip` | issue | Claimed; a worktree exists. |
61
61
  | `ai-blocked` | issue | Agent gave up; needs a human. |
62
62
  | `ai-review` | PR | Awaiting agent review. |
63
+ | `ai-reviewing-code` | PR | `code-reviewer` claimed and running. Cleared with its verdict. |
64
+ | `ai-reviewing-sec` | PR | `security-expert` claimed and running. Cleared with its verdict. |
63
65
  | `ai-ok-code` | PR | `code-reviewer` passed. |
64
66
  | `ai-ok-sec` | PR | `security-expert` passed. |
65
67
  | `ai-changes` | PR | A reviewer requested changes. |
@@ -86,6 +88,8 @@ gh label create ai-ready -c '#0e8a16' -d 'Eligible for an AI agent to impleme
86
88
  gh label create ai-wip -c '#fbca04' -d 'Claimed by an agent; worktree exists'
87
89
  gh label create ai-blocked -c '#b60205' -d 'Agent gave up; needs a human'
88
90
  gh label create ai-review -c '#1d76db' -d 'PR awaiting agent review'
91
+ gh label create ai-reviewing-code -c '#c5def5' -d 'code-reviewer claimed and running'
92
+ gh label create ai-reviewing-sec -c '#c5def5' -d 'security-expert claimed and running'
89
93
  gh label create ai-ok-code -c '#0e8a16' -d 'code-reviewer passed'
90
94
  gh label create ai-ok-sec -c '#0e8a16' -d 'security-expert passed'
91
95
  gh label create ai-changes -c '#d93f0b' -d 'Reviewer requested changes'
@@ -100,14 +104,19 @@ grep -qxF '.claude/ai-loop-status' .gitignore || echo '.claude/ai-loop-status' >
100
104
 
101
105
  ```
102
106
  issue: ai-ready ─pickup─> ai-wip ─> PR opened, labelled ai-review
103
- PR: ai-review ─> reviewers ─┬─> ai-ok-code + ai-ok-sec ─┬─ issue PR ─> assigned to you, ai-review dropped
104
- │ (± ai-notes) │ ─> YOU merge ─> worktree removed
105
- │ └─ dependabot ─┬─ no ai-notes ─> auto-merge ─> worktree removed
106
- │ └─ ai-notes ───> assigned to you
107
- └─> ai-changes ─> fix round (max 2) ─> ai-review
108
- └─ round 3 ─> ai-blocked
107
+ PR: ai-review ─> ai-reviewing-* ─┬─> ai-ok-code + ai-ok-sec ─┬─ issue PR ─> assigned to you, ai-review dropped
108
+ │ (± ai-notes) │ ─> YOU merge ─> worktree removed
109
+ │ └─ dependabot ─┬─ no ai-notes ─> auto-merge ─> worktree removed
110
+ │ └─ ai-notes ───> assigned to you
111
+ └─> ai-changes ─> fix round (max 2) ─> ai-review
112
+ └─ round 3 ─> ai-blocked
109
113
  ```
110
114
 
115
+ `ai-reviewing-code` / `ai-reviewing-sec` are the *claim* step: Pass 3 applies one
116
+ immediately before spawning that reviewer, and the reviewer clears its own
117
+ alongside its verdict label. They are transient — a claim outliving its reviewer
118
+ means the agent died, which is Pass 2's stall reaping, not a state of the PR.
119
+
111
120
  Only the Dependabot arm merges itself, and only when no reviewer left `ai-notes`.
112
121
  An issue PR ends at *assigned to you* and waits there — `ai-ok-code, ai-ok-sec`
113
122
  with no `ai-review` is the loop's way of saying done. Add `ai-notes` and it means
@@ -374,7 +383,7 @@ work must never be reaped out from under itself.
374
383
  | Stalled | Condition | Do |
375
384
  |---|---|---|
376
385
  | Implementer died | issue `ai-wip` ≥45min, **and no PR exists** for `ai-<N>-<slug>` | `gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me`, comment, remove the worktree |
377
- | Reviewer died | PR `ai-review` ≥45min with no `ai-ok-*` and no `ai-changes` | re-spawn the missing reviewer — they're cheap and diff-scoped. If `ai-review` has been applied ≥3 times, `ai-blocked` instead |
386
+ | Reviewer died | PR `ai-reviewing-code` (or `ai-reviewing-sec`) ≥45min with no matching `ai-ok-*` and no `ai-changes` | `gh pr edit <N> --remove-label <the claim that stalled>` — drop **that** label, not a fixed one; a stalled `ai-reviewing-sec` cleared as `ai-reviewing-code` leaves the dead claim in place and the reviewer never re-spawns. Dropping the claim is what lets Pass 3 re-spawn it, and they're cheap and diff-scoped. If that claim has been applied ≥3 times, `ai-blocked` instead |
378
387
  | Orphan worktree | `"$WT_ROOT"/ai-<N>-*` whose issue is not `ai-wip` and has no open PR | remove the worktree and branch |
379
388
 
380
389
  The **no PR exists** condition on the first row is what makes reaping safe. An
@@ -404,8 +413,34 @@ means *a human must look*; do not spend it on a claim you already understand.
404
413
 
405
414
  **PRs labelled `ai-review`.** For each, spawn *in background* only the reviewers
406
415
  whose pass-label is missing — `code-reviewer` if no `ai-ok-code`,
407
- `security-expert` if no `ai-ok-sec`. Both can run concurrently; launch them in a
408
- single message.
416
+ `security-expert` if no `ai-ok-sec` — and **only those not already claimed**: skip
417
+ `code-reviewer` if the PR carries `ai-reviewing-code`, `security-expert` if it
418
+ carries `ai-reviewing-sec`. Both can run concurrently; launch them in a single
419
+ message.
420
+
421
+ **Claim first, then spawn** — the same shape Pass 4 uses before picking up an
422
+ issue. Apply the label immediately before the spawn, not after:
423
+
424
+ ```bash
425
+ gh pr edit <N> --add-label ai-reviewing-code # then spawn code-reviewer
426
+ gh pr edit <N> --add-label ai-reviewing-sec # then spawn security-expert
427
+ ```
428
+
429
+ Without the claim there is no window in which "a reviewer is running" is visible.
430
+ A reviewer applies its verdict label only at the *end*, after reading the diff and
431
+ posting its comment, so from spawn until then the labels are indistinguishable
432
+ from "nobody has started" — and a 15-minute tick is comfortably shorter than a
433
+ review. A tick landing in that gap spawns a duplicate of every reviewer in flight:
434
+ two agents read the same diff and post two review comments under the owner's
435
+ avatar, and the verdicts race, one applying `ai-ok-code` while the other applies
436
+ `ai-changes` and leaves the PR contradictory for Pass 1 to interpret. On a 4-PR
437
+ queue that is 8 duplicated reviewers against the monthly cap the limits section
438
+ exists to protect.
439
+
440
+ Two labels rather than one, because the reviewers are spawned independently and a
441
+ single flag could not say *which* was already running. The reviewer clears its own
442
+ claim alongside its verdict, so a claim never outlives its run; if one does, the
443
+ agent died and Pass 2's stall reaping drops it.
409
444
 
410
445
  Reviewer prompt template:
411
446
 
@@ -452,9 +487,14 @@ Reviewer prompt template:
452
487
  > restating the diff. Writing `Nothing.` is a real verdict and the common one —
453
488
  > say it plainly rather than padding the section to look thorough.
454
489
  >
455
- > Then apply exactly one verdict label:
456
- > - Clean, or only nit-level suggestions → `gh pr edit <N> --add-label <ai-ok-code|ai-ok-sec>`
457
- > - A real defect a maintainer would block on → `gh pr edit <N> --add-label ai-changes --remove-label ai-review`
490
+ > Then apply exactly one verdict label, **clearing your claim label in the same
491
+ > command**:
492
+ > - Clean, or only nit-level suggestions → `gh pr edit <N> --add-label <ai-ok-code|ai-ok-sec> --remove-label <ai-reviewing-code|ai-reviewing-sec>`
493
+ > - A real defect a maintainer would block on → `gh pr edit <N> --add-label ai-changes --remove-label ai-review --remove-label <ai-reviewing-code|ai-reviewing-sec>`
494
+ >
495
+ > Pass 3 applied that claim label immediately before spawning you, and skips
496
+ > spawning a second of you for as long as it is set. Leaving it behind wedges your
497
+ > half of the review until Pass 2 reaps it as a dead reviewer.
458
498
  >
459
499
  > And **additionally**, if and only if your `### Before merging` section is not
460
500
  > `Nothing.`:
@@ -537,7 +577,10 @@ package/from/to table survives because it sits at the top; classify from that.
537
577
  > State in your comment which rule fired, name the packages that tripped it, and say
538
578
  > whether the body was truncated so the reader knows what you could and couldn't see.
539
579
  > Same `🤖 *Automated review — …*` header line, same closing `### Before merging`
540
- > section, and same one-verdict-label rule as above.
580
+ > section, and same one-verdict-label rule as above — **including clearing your
581
+ > `<ai-reviewing-code|ai-reviewing-sec>` claim label in the same `gh pr edit`**.
582
+ > Pass 3 claimed you with it before spawning you, and a claim left behind wedges
583
+ > your half of the review until Pass 2 reaps it.
541
584
  >
542
585
  > Be sparing with `ai-notes` here specifically: it suppresses auto-merge, so a
543
586
  > reflexive note on every dependency bump wedges the one path that runs
@@ -0,0 +1,109 @@
1
+ // Canonical docs-generator helpers for @rtorcato/* repos (shipped by
2
+ // @rtorcato/repo-tooling — copy via `repo-tooling copy docusaurus-docs-helpers`).
3
+ //
4
+ // The pure pieces every subpath-exports package's doc generator needs, so the
5
+ // generator script above them stays small and project-specific:
6
+ //
7
+ // escapeForMarkdownTable(text) — make a JSDoc summary safe for a table cell
8
+ // collectExportNames(file) — recursive `export` parser over a module graph
9
+ // spliceGeneratedBlock(existing, block) — rewrite only the fenced generated region
10
+ //
11
+ // Zero-config and side-effect free: no paths, no package names, no I/O beyond
12
+ // reading the files you hand it, so this file is copied unmodified.
13
+ //
14
+ // import {
15
+ // collectExportNames,
16
+ // escapeForMarkdownTable,
17
+ // MARKER_END,
18
+ // MARKER_START,
19
+ // spliceGeneratedBlock,
20
+ // } from './docs-helpers.mjs'
21
+
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { dirname, join, resolve } from 'node:path'
24
+
25
+ const EXPORT_NAMED =
26
+ /export\s+(?:async\s+)?(?:function|const|let|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)/g
27
+ const EXPORT_BRACE = /export\s*\{\s*([^}]+)\}/g
28
+ // Both re-export forms, any specifier — `export * from …` and
29
+ // `export { … } from …`. Non-relative specifiers are filtered below rather than
30
+ // in the pattern, so a bare-package re-export is recognised and then skipped
31
+ // instead of silently parsed as nothing.
32
+ const REEXPORT_FROM = /export\s+(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]/g
33
+
34
+ /**
35
+ * Escape `text` so it survives a markdown table cell.
36
+ *
37
+ * Neutralises raw HTML-ish tags outside code spans, which would otherwise
38
+ * confuse Docusaurus's MDX parser — but keeps them inside backticks, where
39
+ * `Success<T>` and `<br>` are the point. Then escapes pipes everywhere, since
40
+ * one anywhere in the cell breaks the row.
41
+ *
42
+ * Escaping the `<` beats stripping `/<[^>]*>/`: a one-pass tag strip can leave
43
+ * a tag behind on nested input (`<<b>>` -> `<b>`) and silently eats text like
44
+ * `Success<T>` when the JSDoc forgot the backticks.
45
+ *
46
+ * Escape the backslash in the same pass as the pipe, not after it: escaping
47
+ * only `|` turns the input `a\|b` into `a\\|b`, which markdown reads as an
48
+ * escaped backslash followed by a live pipe, and the row breaks anyway.
49
+ */
50
+ export function escapeForMarkdownTable(text) {
51
+ return text
52
+ .split(/(`[^`]*`)/)
53
+ .map((part, i) => (i % 2 === 1 ? part : part.replace(/</g, '&lt;')))
54
+ .join('')
55
+ .replace(/[\\|]/g, '\\$&')
56
+ }
57
+
58
+ /**
59
+ * Collect every name `file` exports, following relative re-exports into the
60
+ * files they name. Returns the accumulating `names` set; `seen` guards against
61
+ * an import cycle re-entering a file.
62
+ *
63
+ * For an aliased export the *alias* is the exported name — `export { foo as
64
+ * bar }` exports `bar`, which is what a consumer imports — so this takes the
65
+ * last segment, not the first.
66
+ */
67
+ export function collectExportNames(file, names = new Set(), seen = new Set()) {
68
+ if (seen.has(file) || !existsSync(file)) return names
69
+ seen.add(file)
70
+ const src = readFileSync(file, 'utf8')
71
+
72
+ for (const m of src.matchAll(EXPORT_NAMED)) names.add(m[1])
73
+ for (const m of src.matchAll(EXPORT_BRACE)) {
74
+ for (const part of m[1].split(',')) {
75
+ const name = part
76
+ .trim()
77
+ .split(/\s+as\s+/)
78
+ .pop()
79
+ ?.trim()
80
+ if (name) names.add(name)
81
+ }
82
+ }
83
+ for (const m of src.matchAll(REEXPORT_FROM)) {
84
+ if (!m[1].startsWith('.')) continue
85
+ const base = resolve(dirname(file), m[1].replace(/\.js$/, ''))
86
+ for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) {
87
+ if (existsSync(candidate)) {
88
+ collectExportNames(candidate, names, seen)
89
+ break
90
+ }
91
+ }
92
+ }
93
+ return names
94
+ }
95
+
96
+ export const MARKER_START =
97
+ '<!-- generated:exports — do not edit; `pnpm docs:generate` rewrites this block -->'
98
+ export const MARKER_END = '<!-- /generated:exports -->'
99
+
100
+ /**
101
+ * Splice `block` into `existing` between the markers. Returns null when the
102
+ * page has no markers, which means "hand-written, leave it alone".
103
+ */
104
+ export function spliceGeneratedBlock(existing, block) {
105
+ const start = existing.indexOf(MARKER_START)
106
+ const end = existing.indexOf(MARKER_END)
107
+ if (start === -1 || end === -1 || end < start) return null
108
+ return existing.slice(0, start) + block + existing.slice(end + MARKER_END.length)
109
+ }