mandrel 2.35.0 → 2.37.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.
Files changed (108) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +27 -0
  3. package/.agents/rules/testing-standards.md +56 -0
  4. package/.agents/schemas/agentrc.schema.json +11 -0
  5. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  6. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  7. package/.agents/scripts/boot-sweep.js +7 -1
  8. package/.agents/scripts/check-context-budget.js +195 -103
  9. package/.agents/scripts/check-cyclomatic.js +112 -42
  10. package/.agents/scripts/check-doc-links.js +23 -2
  11. package/.agents/scripts/check-generated-validator.js +202 -0
  12. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  13. package/.agents/scripts/check-schema-references.js +2 -28
  14. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  15. package/.agents/scripts/git-cleanup.js +2 -0
  16. package/.agents/scripts/lib/baselines/components.js +32 -2
  17. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  18. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  19. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  20. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  22. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  23. package/.agents/scripts/lib/cli-args.js +0 -2
  24. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  25. package/.agents/scripts/lib/close-validation/process.js +1 -1
  26. package/.agents/scripts/lib/config/ci.js +18 -0
  27. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  28. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  29. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  30. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  31. package/.agents/scripts/lib/cpu-pool.js +90 -10
  32. package/.agents/scripts/lib/crap-utils.js +6 -2
  33. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  34. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  35. package/.agents/scripts/lib/dependency-version.js +86 -0
  36. package/.agents/scripts/lib/duplicate-search.js +1 -1
  37. package/.agents/scripts/lib/errors/index.js +1 -1
  38. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  39. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  40. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  42. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  43. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  44. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  45. package/.agents/scripts/lib/observability/source-classifier.js +1 -1
  46. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  47. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  48. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  49. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  50. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  51. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  52. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  53. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  54. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  55. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  56. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  57. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  58. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  59. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  63. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  64. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  65. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  66. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  67. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  68. package/.agents/scripts/lib/preflight-runner.js +3 -3
  69. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  70. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  71. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  72. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  73. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  74. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  75. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  76. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  77. package/.agents/scripts/lib/test-tiers.js +135 -18
  78. package/.agents/scripts/lib/transpile.js +16 -3
  79. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  80. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  81. package/.agents/scripts/notify.js +4 -10
  82. package/.agents/scripts/pr-watch-with-update.js +305 -137
  83. package/.agents/scripts/providers/github/tickets.js +109 -16
  84. package/.agents/scripts/quality-preview.js +162 -70
  85. package/.agents/scripts/run-test-profile.js +8 -5
  86. package/.agents/scripts/run-tests.js +79 -14
  87. package/.agents/scripts/single-story-close.js +0 -2
  88. package/.agents/scripts/single-story-init.js +5 -1
  89. package/.agents/workflows/audit-documentation.md +5 -6
  90. package/docs/CHANGELOG.md +50 -0
  91. package/lib/cli/migrate.js +100 -94
  92. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  93. package/lib/migrations/index.js +32 -33
  94. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  95. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  96. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  97. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  98. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  99. package/package.json +9 -5
  100. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
  101. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  102. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  103. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  104. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  105. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  106. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  107. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  108. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -17,7 +17,7 @@ import { spawnSync as defaultSpawnSync } from 'node:child_process';
17
17
  * @param {string} installCmd
18
18
  * @returns {{ bin: string, args: string[], shell: boolean }}
19
19
  */
20
- export function parseInstallCmd(installCmd) {
20
+ function parseInstallCmd(installCmd) {
21
21
  const tokens = String(installCmd ?? '')
22
22
  .trim()
23
23
  .split(/\s+/)
@@ -59,6 +59,7 @@ import {
59
59
  resolveKnipEntryPatterns,
60
60
  __testing as resolverTesting,
61
61
  } from './knip-config-resolver.js';
62
+ import { stripJsComments } from './source-text/strip-js-comments.js';
62
63
 
63
64
  // Only `resolveEntrySync` and `renderEntrySyncReport` are public — they are
64
65
  // what `check-knip-entries.js` calls. Everything else is module-private and
@@ -89,63 +90,6 @@ const INVOCATION_SURFACES = Object.freeze([
89
90
  { kind: 'dir', at: SCRIPTS_DIR, test: /\.js$/ },
90
91
  ]);
91
92
 
92
- /**
93
- * Strip line and block comments from JavaScript source, preserving string and
94
- * template literals so a `'https://…'` or a `` `${x}//y` `` is not mangled.
95
- *
96
- * Comments are stripped before scanning `.js` surfaces so a JSDoc paragraph
97
- * that merely *names* a CLI ("superseded by `node .agents/scripts/foo.js`")
98
- * cannot confer liveness on it. Several such mentions exist today; every one
99
- * of them is prose about a script's internals, not a call.
100
- *
101
- * Replaces comment bodies with equivalent whitespace rather than deleting
102
- * them, so byte offsets and line numbers survive for any future caller that
103
- * wants to report a position.
104
- *
105
- * @param {string} source
106
- * @returns {string}
107
- */
108
- function stripJsComments(source) {
109
- const text = String(source ?? '');
110
- let out = '';
111
- let i = 0;
112
- const blank = (s) => s.replace(/[^\n]/g, ' ');
113
- while (i < text.length) {
114
- const two = text.slice(i, i + 2);
115
- if (two === '//') {
116
- const end = text.indexOf('\n', i);
117
- const stop = end === -1 ? text.length : end;
118
- out += blank(text.slice(i, stop));
119
- i = stop;
120
- } else if (two === '/*') {
121
- const end = text.indexOf('*/', i + 2);
122
- const stop = end === -1 ? text.length : end + 2;
123
- out += blank(text.slice(i, stop));
124
- i = stop;
125
- } else {
126
- const ch = text[i];
127
- if (ch === "'" || ch === '"' || ch === '`') {
128
- let j = i + 1;
129
- while (j < text.length) {
130
- if (text[j] === '\\') {
131
- j += 2;
132
- continue;
133
- }
134
- if (text[j] === ch) break;
135
- j += 1;
136
- }
137
- const stop = Math.min(j + 1, text.length);
138
- out += text.slice(i, stop);
139
- i = stop;
140
- } else {
141
- out += ch;
142
- i += 1;
143
- }
144
- }
145
- }
146
- return out;
147
- }
148
-
149
93
  /**
150
94
  * Recursively list files under `dir`, skipping `node_modules` and `.git`.
151
95
  *
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { minimatch } from 'minimatch';
3
+ import { Minimatch } from 'minimatch';
4
4
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
5
5
  import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
6
6
  import { Logger } from './Logger.js';
@@ -27,6 +27,57 @@ const IGNORED_DIRS = new Set([
27
27
  '.next',
28
28
  ]);
29
29
 
30
+ /**
31
+ * Compiled-matcher cache for `isIgnoredByGlobs`, keyed on the pattern list.
32
+ *
33
+ * `minimatch(path, glob)` re-parses `glob` into an AST on every call, so the
34
+ * scan was paying O(files × globs) glob *compilations* to answer O(files ×
35
+ * globs) glob *matches* — 12–17 ms over the 619-file tree against 0.5 ms
36
+ * once the patterns are compiled (Story #5109). The number of distinct
37
+ * pattern lists in a process is tiny (one per gate), and each list is
38
+ * config-derived and immutable, so an unbounded `Map` keyed on the joined
39
+ * patterns is bounded in practice.
40
+ *
41
+ * Each entry also memoises the per-path verdict. The baseline gates ask the
42
+ * same question about the same path many times over — `crap.json` carries
43
+ * thousands of method rows spread over a few hundred files, and the evaluate
44
+ * phase filters every row — so the second and later asks about a path cost a
45
+ * `Map` lookup instead of a globstar walk. Both caches are keyed on
46
+ * config-derived, immutable inputs, and both are bounded by the tree the
47
+ * process is scanning.
48
+ *
49
+ * `\u0000` is the join separator because it cannot occur in a glob, so two
50
+ * different lists cannot collide onto one key.
51
+ *
52
+ * @type {Map<string, {matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}>}
53
+ */
54
+ const IGNORE_MATCHER_CACHE = new Map();
55
+
56
+ /**
57
+ * Compile (once) the `Minimatch` instances for a pattern list, alongside the
58
+ * per-path verdict memo that shares their lifetime.
59
+ *
60
+ * Non-string patterns are dropped rather than compiled — `minimatch()` would
61
+ * have thrown on them, and the functional call site never fed it any, so
62
+ * dropping preserves the observable result set.
63
+ *
64
+ * @param {string[]} ignoreGlobs
65
+ * @returns {{matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}}
66
+ */
67
+ function ignoreMatcherEntry(ignoreGlobs) {
68
+ const key = ignoreGlobs.join('\u0000');
69
+ let entry = IGNORE_MATCHER_CACHE.get(key);
70
+ if (entry) return entry;
71
+ entry = {
72
+ matchers: ignoreGlobs
73
+ .filter((g) => typeof g === 'string')
74
+ .map((g) => new Minimatch(g, { dot: true })),
75
+ verdicts: new Map(),
76
+ };
77
+ IGNORE_MATCHER_CACHE.set(key, entry);
78
+ return entry;
79
+ }
80
+
30
81
  /**
31
82
  * Test whether an absolute (or repo-relative) file path matches any of the
32
83
  * configured `ignoreGlobs`. This is the single source of truth for how the
@@ -39,22 +90,62 @@ const IGNORED_DIRS = new Set([
39
90
  *
40
91
  * Matching mirrors `scanDirectory`: the path is reduced to a canonicalised,
41
92
  * POSIX, repo-relative form and tested against each glob with minimatch's
42
- * `{ dot: true }` so dot-prefixed roots like `.agents/` match.
93
+ * `{ dot: true }` so dot-prefixed roots like `.agents/` match. The patterns
94
+ * are compiled at most once per distinct list (see `IGNORE_MATCHER_CACHE`);
95
+ * the matched set is identical to the functional `minimatch()` call this
96
+ * replaced, which is what the `gate-scan-fast-path` test pins over the real
97
+ * configured `ignoreGlobs`.
43
98
  *
44
99
  * @param {string} filePath absolute or relative path to the source file
45
100
  * @param {string[]} ignoreGlobs minimatch patterns; empty/absent is a no-op
46
101
  * @param {string} [cwd] root for repo-relative resolution; defaults to cwd
47
102
  * @returns {boolean} true when the file matches at least one ignore glob
48
103
  */
49
- export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
50
- if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return false;
51
- const matchCwd = cwd ?? process.cwd();
104
+ /**
105
+ * Reduce an absolute-or-relative path to the canonicalised, POSIX,
106
+ * repo-relative form the ignore patterns are written against.
107
+ *
108
+ * @param {string} filePath
109
+ * @param {string} matchCwd
110
+ * @returns {string}
111
+ */
112
+ function canonicalRelPath(filePath, matchCwd) {
52
113
  const absFilePath = path.isAbsolute(filePath)
53
114
  ? filePath
54
115
  : path.resolve(matchCwd, filePath);
55
116
  const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
56
- const relPath = canonicalisePath(rawRel);
57
- return ignoreGlobs.some((g) => minimatch(relPath, g, { dot: true }));
117
+ return canonicalisePath(rawRel);
118
+ }
119
+
120
+ /**
121
+ * Answer "does this path match any of the entry's patterns", consulting and
122
+ * populating the entry's verdict memo.
123
+ *
124
+ * Split out of `isIgnoredByGlobs` rather than inlined: the memo's
125
+ * hit/miss test is a branch, and folding it into the caller pushed that
126
+ * function from cyclomatic 4 to 5 — over the per-method CRAP contract the
127
+ * pre-push preview enforces. The lookup belongs beside the cache it reads
128
+ * anyway, and the hot function keeps a flat shape.
129
+ *
130
+ * @param {{matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}} entry
131
+ * @param {string} relPath Canonicalised, POSIX, repo-relative path.
132
+ * @returns {boolean}
133
+ */
134
+ function memoisedIgnoreVerdict(entry, relPath) {
135
+ const memoised = entry.verdicts.get(relPath);
136
+ if (memoised !== undefined) return memoised;
137
+ const verdict = entry.matchers.some((m) => m.match(relPath));
138
+ entry.verdicts.set(relPath, verdict);
139
+ return verdict;
140
+ }
141
+
142
+ export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
143
+ if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return false;
144
+ const matchCwd = cwd ?? process.cwd();
145
+ return memoisedIgnoreVerdict(
146
+ ignoreMatcherEntry(ignoreGlobs),
147
+ canonicalRelPath(filePath, matchCwd),
148
+ );
58
149
  }
59
150
 
60
151
  /**
@@ -107,7 +198,7 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
107
198
  * Calculates maintainability scores for a list of file paths.
108
199
  *
109
200
  * Each file's transpile-then-analyze unit is dispatched to a
110
- * worker_threads pool sized to `os.availableParallelism()`. Workers
201
+ * worker_threads pool whose width `runOnPool` resolves. Workers
111
202
  * are recycled across files so TypeScript loads at most once per
112
203
  * worker. The pool is bypassed for batches of fewer than
113
204
  * `SERIAL_THRESHOLD` files because spawn overhead dominates at small
@@ -129,9 +220,16 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
129
220
  * and nothing said so.
130
221
  *
131
222
  * @param {string[]} paths
223
+ * @param {{serialThreshold?: number}} [opts] `serialThreshold` overrides the
224
+ * pool-vs-serial cutover for this call only. Production callers omit it;
225
+ * the parity tests use it to drive the pooled path on a small fixture set
226
+ * rather than materialising 256 files to clear the cutover.
132
227
  * @returns {Promise<Record<string, number>>}
133
228
  */
134
- export async function calculateAll(paths) {
229
+ export async function calculateAll(paths, opts = {}) {
230
+ const serialThreshold = Number.isFinite(opts?.serialThreshold)
231
+ ? opts.serialThreshold
232
+ : SERIAL_THRESHOLD;
135
233
  const cwd = process.cwd();
136
234
  const indexed = paths.map((p) => ({
137
235
  abs: p,
@@ -139,7 +237,7 @@ export async function calculateAll(paths) {
139
237
  }));
140
238
 
141
239
  let perFile;
142
- if (indexed.length < SERIAL_THRESHOLD) {
240
+ if (indexed.length < serialThreshold) {
143
241
  perFile = indexed.map(({ abs, relPath }) => {
144
242
  try {
145
243
  return { relPath, ...scoreFile(abs) };
@@ -94,6 +94,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
94
94
  'check-cyclomatic.js',
95
95
  'check-dead-exports.js',
96
96
  'check-doc-links.js',
97
+ 'check-generated-validator.js',
97
98
  'check-gherkin-corpus.js',
98
99
  'check-knip-entries.js',
99
100
  'check-lifecycle-lint.js',
@@ -113,7 +114,6 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
113
114
  'evidence-gate.js',
114
115
  'generate-config-docs.js',
115
116
  'generate-lens-checklists.js',
116
- 'generate-lifecycle-docs.js',
117
117
  'generate-skills-index.js',
118
118
  'generate-workflows-doc.js',
119
119
  'git-cleanup.js',
@@ -37,15 +37,19 @@ import {
37
37
  removeWorktree,
38
38
  worktreesByBranch,
39
39
  } from './git-probes.js';
40
+ import { probeAncestry } from './merged-tip.js';
40
41
  import { parsePrunedRefs } from './prune.js';
41
42
 
42
43
  const TAG = '[git-cleanup]';
43
44
 
45
+ /** Fields a planner verdict forwards onto its `skipped[]` entry. */
46
+ const SKIP_DETAIL_FIELDS = ['prNumber', 'tipSha', 'mergedSha', 'detail'];
47
+
44
48
  function skipEntryFromVerdict(branch, verdict) {
45
49
  const entry = { branch, reason: verdict.reason };
46
- if (verdict.prNumber != null) entry.prNumber = verdict.prNumber;
47
- if (verdict.tipSha) entry.tipSha = verdict.tipSha;
48
- if (verdict.mergedSha) entry.mergedSha = verdict.mergedSha;
50
+ for (const field of SKIP_DETAIL_FIELDS) {
51
+ if (verdict[field] != null) entry[field] = verdict[field];
52
+ }
49
53
  return entry;
50
54
  }
51
55
 
@@ -88,6 +92,7 @@ function evaluateLocalBranch({
88
92
  wtMap,
89
93
  remoteName,
90
94
  branchTipShaFn,
95
+ ancestryFn,
91
96
  contentEquivalentFn,
92
97
  branchLastCommitFn,
93
98
  }) {
@@ -102,6 +107,7 @@ function evaluateLocalBranch({
102
107
  remoteName,
103
108
  localExists: true,
104
109
  branchTipShaFn,
110
+ ancestryFn,
105
111
  });
106
112
  if (verdict.kind === 'skip') {
107
113
  return { skip: skipEntryFromVerdict(branch, verdict) };
@@ -134,6 +140,7 @@ function evaluateLocalBranch({
134
140
  worktreePath: wt?.path ?? null,
135
141
  detectedBy,
136
142
  localExists: true,
143
+ behindMerge: verdict.reason === 'tip-behind-merge',
137
144
  },
138
145
  };
139
146
  }
@@ -147,6 +154,7 @@ function collectRemoteOnlyCandidates({
147
154
  filter,
148
155
  prProbe,
149
156
  branchTipShaFn,
157
+ ancestryFn,
150
158
  skipped,
151
159
  }) {
152
160
  const out = [];
@@ -162,6 +170,7 @@ function collectRemoteOnlyCandidates({
162
170
  remoteName,
163
171
  localExists: false,
164
172
  branchTipShaFn,
173
+ ancestryFn,
165
174
  });
166
175
  if (verdict.kind === 'no-pr') continue;
167
176
  if (verdict.kind === 'skip') {
@@ -176,6 +185,7 @@ function collectRemoteOnlyCandidates({
176
185
  worktreePath: null,
177
186
  detectedBy: 'remote-only',
178
187
  localExists: false,
188
+ behindMerge: verdict.reason === 'tip-behind-merge',
179
189
  });
180
190
  }
181
191
  return out;
@@ -187,10 +197,12 @@ function collectRemoteOnlyCandidates({
187
197
  * The PR probe classifies each candidate by the **latest** PR on the head
188
198
  * ref rather than any historical merge. Branches whose latest PR is OPEN
189
199
  * or CLOSED-not-merged are skipped with `reason: 'latest-pr-open'` /
190
- * `reason: 'latest-pr-closed-not-merged'`. When the latest PR is MERGED
191
- * but the branch tip has diverged from the PR's `headRefOid` (post-merge
192
- * force-push), the branch is skipped with
193
- * `reason: 'tip-diverged-from-merge'`.
200
+ * `reason: 'latest-pr-closed-not-merged'`. A MERGED PR whose `headRefOid`
201
+ * differs from the branch tip is resolved by ancestry in
202
+ * `merged-tip.js` — a tip *behind* the merged head becomes a candidate
203
+ * carrying `behindMerge: true`, a tip *ahead* of it keeps the
204
+ * `tip-diverged-from-merge` force-push skip, and an unresolvable rev
205
+ * skips as `unverifiable`.
194
206
  *
195
207
  * Performance (Story #3333): when the caller does not inject its own
196
208
  * `prProbe`, the planner fires **one** bulk `gh pr list --state all`
@@ -250,6 +262,7 @@ export function planCleanup(ctx) {
250
262
  prIndexFn = probeAllPrs,
251
263
  prFallback = probeLatestPr,
252
264
  branchTipShaFn = branchTipSha,
265
+ ancestryFn = probeAncestry,
253
266
  contentEquivalentFn = probeContentEquivalent,
254
267
  branchLastCommitFn = branchLastCommitAt,
255
268
  refExistsFn = refExists,
@@ -301,6 +314,7 @@ export function planCleanup(ctx) {
301
314
  wtMap,
302
315
  remoteName,
303
316
  branchTipShaFn,
317
+ ancestryFn,
304
318
  contentEquivalentFn,
305
319
  branchLastCommitFn,
306
320
  });
@@ -318,6 +332,7 @@ export function planCleanup(ctx) {
318
332
  filter,
319
333
  prProbe,
320
334
  branchTipShaFn,
335
+ ancestryFn,
321
336
  skipped,
322
337
  }),
323
338
  );
@@ -17,6 +17,7 @@ import { execFileSync } from 'node:child_process';
17
17
 
18
18
  import { gitSpawn } from '../../../git-utils.js';
19
19
  import { parseWorktreePorcelain } from '../../../worktree-manager.js';
20
+ import { resolveMergedTip } from './merged-tip.js';
20
21
 
21
22
  export {
22
23
  canFastForward,
@@ -452,6 +453,12 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
452
453
  * into `skipped[]` and continues.
453
454
  * - `{ kind: 'no-pr' }` — caller continues without skipping.
454
455
  *
456
+ * A MERGED PR whose `headRefOid` differs from the branch tip is handed
457
+ * to {@link resolveMergedTip}, which resolves it by ancestry — see that
458
+ * module for the `tip-behind-merge` / `tip-diverged-from-merge` /
459
+ * `unverifiable` taxonomy and why a bare SHA inequality could not
460
+ * express it.
461
+ *
455
462
  * @param {{
456
463
  * prInfo: { number?: number, state?: string, mergedAt?: string|null, headRefOid?: string|null } | null,
457
464
  * branch: string,
@@ -459,8 +466,10 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
459
466
  * remoteName: string,
460
467
  * localExists: boolean,
461
468
  * branchTipShaFn: (args: { cwd: string, branch: string, remoteName: string, localExists: boolean }) => string | null,
469
+ * ancestryFn?: Function,
470
+ * mergedTipFn?: typeof resolveMergedTip,
462
471
  * }} args
463
- * @returns {{ kind: 'candidate', prInfo: object } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null } | { kind: 'no-pr' }}
472
+ * @returns {{ kind: 'candidate', prInfo: object, reason?: string, tipSha?: string, mergedSha?: string } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null, detail?: string } | { kind: 'no-pr' }}
464
473
  */
465
474
  export function classifyLatestPr({
466
475
  prInfo,
@@ -469,6 +478,8 @@ export function classifyLatestPr({
469
478
  remoteName,
470
479
  localExists,
471
480
  branchTipShaFn,
481
+ ancestryFn,
482
+ mergedTipFn = resolveMergedTip,
472
483
  }) {
473
484
  if (!prInfo) return { kind: 'no-pr' };
474
485
  const state =
@@ -494,17 +505,14 @@ export function classifyLatestPr({
494
505
  prNumber: prInfo.number ?? null,
495
506
  };
496
507
  }
497
- if (prInfo.headRefOid) {
498
- const tipSha = branchTipShaFn({ cwd, branch, remoteName, localExists });
499
- if (tipSha && tipSha !== prInfo.headRefOid) {
500
- return {
501
- kind: 'skip',
502
- reason: 'tip-diverged-from-merge',
503
- prNumber: prInfo.number ?? null,
504
- tipSha,
505
- mergedSha: prInfo.headRefOid,
506
- };
507
- }
508
- }
509
- return { kind: 'candidate', prInfo };
508
+ const tipVerdict = mergedTipFn({
509
+ prInfo,
510
+ branch,
511
+ cwd,
512
+ remoteName,
513
+ localExists,
514
+ branchTipShaFn,
515
+ ancestryFn,
516
+ });
517
+ return tipVerdict ?? { kind: 'candidate', prInfo };
510
518
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * merged-tip.js — resolve a MERGED PR's head against the branch tip
3
+ * (Story #5086).
4
+ *
5
+ * Owns the ancestry probe and the taxonomy the branches-phase classifier
6
+ * applies when a merged PR's `headRefOid` and the branch tip disagree.
7
+ * Split out of `git-probes.js` so the classifier reads as one call and
8
+ * the taxonomy's own documentation sits next to the code it governs.
9
+ *
10
+ * @module lib/orchestration/git-cleanup/phases/merged-tip
11
+ */
12
+
13
+ import { gitSpawn } from '../../../git-utils.js';
14
+
15
+ /**
16
+ * Tri-state ancestry probe: is `ancestorSha` reachable from
17
+ * `descendantSha`?
18
+ *
19
+ * Mirrors the contract `checkHeadAncestor` in
20
+ * `lib/worktree/lifecycle/merge-reachability.js` proved out for the
21
+ * worktree-reap gate — the two cannot share an implementation because
22
+ * that one takes a `ctx.git.gitSpawn` / `ctx.repoRoot` bag while
23
+ * git-cleanup's probes take a bare `cwd`.
24
+ *
25
+ * `git merge-base --is-ancestor` exits **0** (ancestor), **1** (not an
26
+ * ancestor) or **128** (a rev it cannot resolve). Folding 128 into
27
+ * "not an ancestor" is the bug this probe exists to prevent: a merged
28
+ * head absent from the local object DB would silently read as a
29
+ * divergence and re-emit the wrong post-merge-force-push diagnosis. Both
30
+ * revs are therefore resolved with `git rev-parse -q --verify` first, and
31
+ * any failure fails closed to the `error` arm — so `merge-base` never
32
+ * runs against a rev git cannot resolve.
33
+ *
34
+ * @param {{ cwd: string, ancestorSha: string, descendantSha: string, spawn?: typeof gitSpawn }} args
35
+ * @returns {{ outcome: 'ancestor' } | { outcome: 'not-ancestor' } | { outcome: 'error', reason: string }}
36
+ */
37
+ export function probeAncestry({
38
+ cwd,
39
+ ancestorSha,
40
+ descendantSha,
41
+ spawn = gitSpawn,
42
+ }) {
43
+ for (const rev of [ancestorSha, descendantSha]) {
44
+ const res = spawn(
45
+ cwd,
46
+ 'rev-parse',
47
+ '--quiet',
48
+ '--verify',
49
+ `${rev}^{commit}`,
50
+ );
51
+ if (res.status !== 0) {
52
+ return { outcome: 'error', reason: `unresolvable rev ${rev}` };
53
+ }
54
+ }
55
+ const res = spawn(
56
+ cwd,
57
+ 'merge-base',
58
+ '--is-ancestor',
59
+ ancestorSha,
60
+ descendantSha,
61
+ );
62
+ if (res.status === 0) return { outcome: 'ancestor' };
63
+ if (res.status === 1) return { outcome: 'not-ancestor' };
64
+ return {
65
+ outcome: 'error',
66
+ reason: (res.stderr || res.stdout || 'unknown').trim(),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Resolve a MERGED PR's `headRefOid` against the branch's current tip.
72
+ *
73
+ * Returns `null` when there is nothing to resolve — the PR row carries no
74
+ * `headRefOid`, the tip cannot be read, or the tip already matches the
75
+ * merged head — leaving the caller's plain-candidate path untouched.
76
+ *
77
+ * Otherwise the tip is classified by **ancestry**, never by the bare SHA
78
+ * inequality this replaced. That inequality could not tell a branch that
79
+ * is *behind* the merged head from one force-pushed *past* it, and
80
+ * reported both as the latter — advising the operator to push a follow-up
81
+ * commit that, for a stale pre-merge snapshot, does not exist. The three
82
+ * arms:
83
+ *
84
+ * - **ancestor** — 0 commits ahead, every commit landed with the PR:
85
+ * a reap candidate tagged `reason: 'tip-behind-merge'`.
86
+ * - **not-ancestor** — equivalently "≥1 commit ahead", which is why one
87
+ * probe settles the whole taxonomy and no `rev-list` count is needed:
88
+ * the unchanged `tip-diverged-from-merge` force-push skip.
89
+ * - **error** — a rev the local object DB cannot resolve:
90
+ * `reason: 'unverifiable'` carrying the probe's `detail`. Never a
91
+ * silent pass, and never a force-push label.
92
+ *
93
+ * @param {object} args
94
+ * @returns {{ kind: 'candidate', prInfo: object, reason: string, tipSha: string, mergedSha: string } | { kind: 'skip', reason: string, prNumber: number|null, tipSha: string, mergedSha: string, detail?: string } | null}
95
+ */
96
+ export function resolveMergedTip({
97
+ prInfo,
98
+ branch,
99
+ cwd,
100
+ remoteName,
101
+ localExists,
102
+ branchTipShaFn,
103
+ ancestryFn = probeAncestry,
104
+ }) {
105
+ const mergedSha = prInfo.headRefOid;
106
+ if (!mergedSha) return null;
107
+ const tipSha = branchTipShaFn({ cwd, branch, remoteName, localExists });
108
+ if (!tipSha || tipSha === mergedSha) return null;
109
+ const ancestry = ancestryFn({
110
+ cwd,
111
+ ancestorSha: tipSha,
112
+ descendantSha: mergedSha,
113
+ });
114
+ if (ancestry.outcome === 'ancestor') {
115
+ return {
116
+ kind: 'candidate',
117
+ prInfo,
118
+ reason: 'tip-behind-merge',
119
+ tipSha,
120
+ mergedSha,
121
+ };
122
+ }
123
+ const errored = ancestry.outcome === 'error';
124
+ return {
125
+ kind: 'skip',
126
+ reason: errored ? 'unverifiable' : 'tip-diverged-from-merge',
127
+ prNumber: prInfo.number ?? null,
128
+ tipSha,
129
+ mergedSha,
130
+ ...(errored ? { detail: ancestry.reason } : {}),
131
+ };
132
+ }
@@ -61,6 +61,41 @@ function contentMergedNote(candidate) {
61
61
  : '';
62
62
  }
63
63
 
64
+ /**
65
+ * Pure: render a single behind-the-merged-head candidate annotation.
66
+ *
67
+ * A branch whose tip is a strict ancestor of its merged PR head is a
68
+ * stale pre-merge snapshot — reapable, because every commit on it landed
69
+ * with the PR, but reapable for a different reason than a branch whose
70
+ * tip *matches* the merged head. It used to be skipped outright as a
71
+ * post-merge force-push; the note keeps the two visibly distinct in the
72
+ * dry-run list and the confirmation prompt so the operator can see why a
73
+ * branch that is not at the merged head is nonetheless offered.
74
+ */
75
+ function behindMergeNote(candidate) {
76
+ return candidate.behindMerge
77
+ ? ' (tip behind the merged head — content already landed)'
78
+ : '';
79
+ }
80
+
81
+ /** Pure: every provenance annotation a candidate row carries, in order. */
82
+ function candidateNotes(candidate) {
83
+ return `${contentMergedNote(candidate)}${behindMergeNote(candidate)}`;
84
+ }
85
+
86
+ /**
87
+ * Pure: one candidate row — its detection provenance, worktree, locality
88
+ * and annotations. Split out of {@link renderDryRun} so that renderer
89
+ * stays a loop over rows rather than growing a fourth inline ternary
90
+ * every time a candidate gains a new dimension.
91
+ */
92
+ function renderCandidateRow(c) {
93
+ const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
94
+ const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
95
+ const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
96
+ return ` • ${c.branch} — ${pr}${wt}${remoteOnly}${candidateNotes(c)}`;
97
+ }
98
+
64
99
  /**
65
100
  * Pure: render the branch-phase candidate list as the operator-facing text
66
101
  * block.
@@ -87,14 +122,7 @@ export function renderDryRun(plan, opts = {}) {
87
122
  if (plan.candidates.length === 0) {
88
123
  lines.push(' (no merged branches to clean up)');
89
124
  } else {
90
- for (const c of plan.candidates) {
91
- const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
92
- const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
93
- const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
94
- lines.push(
95
- ` • ${c.branch} — ${pr}${wt}${remoteOnly}${contentMergedNote(c)}`,
96
- );
97
- }
125
+ for (const c of plan.candidates) lines.push(renderCandidateRow(c));
98
126
  }
99
127
  const skipped = plan.skipped ?? [];
100
128
  const currentHeadSkip = skipped.find((s) => s.reason === 'current-head');
@@ -141,6 +169,17 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
141
169
  return renderDryRun(plan, { baseBranch, execute: !opts.dryRun });
142
170
  }
143
171
 
172
+ /**
173
+ * Pure: the tip / merged short-SHA pair both merged-tip skip lines quote,
174
+ * with a placeholder for either side the planner could not resolve.
175
+ */
176
+ function shortShaPair(skip) {
177
+ return {
178
+ tip: skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>',
179
+ merged: skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>',
180
+ };
181
+ }
182
+
144
183
  /**
145
184
  * Pure: render a single latest-PR-state skip line. Returns null when the
146
185
  * skip reason is not one of the latest-PR family — `renderDryRun` filters
@@ -148,7 +187,7 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
148
187
  * `current-head`, `filtered`) stay quiet here. `not-merged` gets its own
149
188
  * renderer ({@link renderNotMergedSkipLine}).
150
189
  *
151
- * @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string }} skip
190
+ * @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string, detail?: string }} skip
152
191
  * @returns {string | null}
153
192
  */
154
193
  export function renderLatestPrSkipLine(skip) {
@@ -161,13 +200,19 @@ export function renderLatestPrSkipLine(skip) {
161
200
  return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} is still open`;
162
201
  }
163
202
  if (skip.reason === 'tip-diverged-from-merge') {
164
- const tip = skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>';
165
- const merged = skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>';
203
+ const { tip, merged } = shortShaPair(skip);
166
204
  return (
167
205
  `${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push); ` +
168
206
  `resolve by deleting manually (\`git branch -D ${skip.branch}\`) or pushing the follow-up commit`
169
207
  );
170
208
  }
209
+ if (skip.reason === 'unverifiable') {
210
+ const { tip, merged } = shortShaPair(skip);
211
+ return (
212
+ `${TAG} ⏭️ ${skip.branch} skipped — cannot verify tip ${tip} against ${prRef}'s merged ${merged}${skip.detail ? `: ${skip.detail}` : ''}; ` +
213
+ `fetch the missing commit or inspect the branch by hand before deleting it`
214
+ );
215
+ }
171
216
  if (skip.reason === 'latest-pr-unknown-state') {
172
217
  return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} has an unrecognized state`;
173
218
  }