@codyswann/lisa 4.42.2 → 4.42.3

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 (106) hide show
  1. package/all/copy-overwrite/scripts/check-orphaned-branches.mjs +458 -29
  2. package/dist/cli/doctor-reusable-workflow-refs.d.ts +3 -3
  3. package/dist/cli/doctor-reusable-workflow-refs.d.ts.map +1 -1
  4. package/dist/cli/doctor-reusable-workflow-refs.js +46 -74
  5. package/dist/cli/doctor-reusable-workflow-refs.js.map +1 -1
  6. package/dist/cli/version.d.ts +17 -0
  7. package/dist/cli/version.d.ts.map +1 -1
  8. package/dist/cli/version.js +34 -0
  9. package/dist/cli/version.js.map +1 -1
  10. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  11. package/dist/core/lisa-owned-hash-ledger.js +18 -0
  12. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  13. package/dist/core/lisa-release-pin.d.ts +63 -0
  14. package/dist/core/lisa-release-pin.d.ts.map +1 -0
  15. package/dist/core/lisa-release-pin.js +147 -0
  16. package/dist/core/lisa-release-pin.js.map +1 -0
  17. package/dist/core/lisa.d.ts.map +1 -1
  18. package/dist/core/lisa.js +4 -1
  19. package/dist/core/lisa.js.map +1 -1
  20. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  21. package/dist/core/reusable-workflow-pin.d.ts +113 -0
  22. package/dist/core/reusable-workflow-pin.d.ts.map +1 -0
  23. package/dist/core/reusable-workflow-pin.js +165 -0
  24. package/dist/core/reusable-workflow-pin.js.map +1 -0
  25. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  26. package/dist/core/upstream-evidence-manifest.js +14 -7
  27. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  28. package/dist/migrations/ensure-nightly-e2e-workflow-pins.d.ts +16 -36
  29. package/dist/migrations/ensure-nightly-e2e-workflow-pins.d.ts.map +1 -1
  30. package/dist/migrations/ensure-nightly-e2e-workflow-pins.js +33 -76
  31. package/dist/migrations/ensure-nightly-e2e-workflow-pins.js.map +1 -1
  32. package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts +143 -0
  33. package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts.map +1 -0
  34. package/dist/migrations/ensure-pinned-reusable-workflow-refs.js +293 -0
  35. package/dist/migrations/ensure-pinned-reusable-workflow-refs.js.map +1 -0
  36. package/dist/migrations/index.d.ts +1 -0
  37. package/dist/migrations/index.d.ts.map +1 -1
  38. package/dist/migrations/index.js +5 -0
  39. package/dist/migrations/index.js.map +1 -1
  40. package/package.json +4 -4
  41. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  44. package/plugins/lisa/.codex-plugin/skills/lisa-git-submit-pr/SKILL.md +16 -0
  45. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  46. package/plugins/lisa/skills/lisa-git-submit-pr/SKILL.md +16 -0
  47. package/plugins/lisa-agy/plugin.json +1 -1
  48. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  49. package/plugins/lisa-agy/skills/lisa-git-submit-pr/SKILL.md +16 -0
  50. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  53. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  57. package/plugins/lisa-copilot/skills/lisa-git-submit-pr/SKILL.md +16 -0
  58. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  60. package/plugins/lisa-cursor/skills/lisa-git-submit-pr/SKILL.md +16 -0
  61. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-expo-agy/plugin.json +1 -1
  64. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  69. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  74. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  79. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  84. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  88. package/plugins/lisa-rails-agy/plugin.json +1 -1
  89. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  93. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  94. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  98. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  99. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  101. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  102. package/plugins/src/base/skills/lisa-git-submit-pr/SKILL.md +16 -0
  103. package/scripts/lisa-update-local.sh +1 -0
  104. package/typescript/copy-overwrite/.github/GITHUB_ACTIONS.md +6 -1
  105. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +370 -4
  106. package/typescript/create-only/.github/workflows/review-evidence.yml +20 -0
@@ -40,9 +40,46 @@
40
40
  * the repository. Both exclusions exist because a check that reports everything
41
41
  * is one nobody reads, which is the same outcome as no check at all.
42
42
  *
43
- * Determinism: Node built-ins only, no `Date`, no `Math.random`. Branch and
44
- * pull-request facts come from `git` and `gh`; with neither available the
45
- * script reports that it could not look rather than that it found nothing.
43
+ * ## The work item is resolved BEFORE the branch (issue #3904)
44
+ *
45
+ * Every signal above is a property of the **copy**: ahead, unpushed, untracked,
46
+ * stale. None of them says whether the **work** survived, and that is only
47
+ * visible in the tracker. Abandonment and uniqueness look identical from inside
48
+ * a checkout, so a report built on branch facts alone reads superseded work as
49
+ * unique — with maximum apparent urgency, because the more thoroughly stranded
50
+ * a branch looks, the more it demands rescue.
51
+ *
52
+ * Measured in this repository: a branch one commit ahead of the default branch,
53
+ * on the remote, with no pull request in any state — the textbook "just open
54
+ * the PR" recovery. Its work item was CLOSED; the behaviour had already shipped
55
+ * from a sibling item through a different merged pull request. Opening that PR
56
+ * would have produced a duplicate for a reviewer to work out, and it could not
57
+ * have been landed anyway: the commit gate requires a `Work-Item:` trailer
58
+ * naming a **live** item, so the only routes left are a refusal or retro-fitted
59
+ * attribution — which is falsified provenance, not a recovery.
60
+ *
61
+ * So the tracker is asked first and the verdict is the tracker's:
62
+ * `superseded` (item closed — preserve, never promote), `unsubmitted` (item
63
+ * live — this really is work awaiting a pull request), or `unresolved` (no item
64
+ * could be resolved, which is reported as its own outcome and never folded into
65
+ * either of the other two).
66
+ *
67
+ * ## Which side is ahead is measured, never inferred from the diff
68
+ *
69
+ * The sharpest case in the same sweep: a branch whose subjects were all absent
70
+ * from the default branch, reading as dozens of stranded commits, whose pull
71
+ * request had in fact merged days after the local ref last moved. It was
72
+ * stale-**behind**, not ahead, and its `git diff` was symmetric and enormous
73
+ * because it predated a large amount of the base. **A conflict resolution that
74
+ * takes the branch side there deletes everything the base gained** — the
75
+ * reading that feels like rescue is the one that destroys. Every candidate
76
+ * therefore carries a two-sided `--left-right` count, so the direction is a
77
+ * number that was read rather than an impression formed from a diff.
78
+ *
79
+ * Determinism: Node built-ins only, no `Date`, no `Math.random`. Branch,
80
+ * pull-request and work-item facts come from `git` and `gh`; with neither
81
+ * available the script reports that it could not look rather than that it found
82
+ * nothing.
46
83
  * @module check-orphaned-branches
47
84
  */
48
85
 
@@ -56,6 +93,58 @@ const IGNORED_PREFIXES = Object.freeze(["backup/", "gh-readonly-queue/"]);
56
93
  /** Exit codes. `FOUND` is advisory by default; see `--fail-on-found`. */
57
94
  const EXIT = Object.freeze({ OK: 0, FOUND: 1, UNAVAILABLE: 70 });
58
95
 
96
+ /**
97
+ * What the tracker says about the work, which is what decides the verdict.
98
+ *
99
+ * `UNRESOLVED` is a third outcome and not a shade of the other two. A branch
100
+ * whose item could not be resolved has not been cleared and has not been
101
+ * condemned — reporting it as either would be the vacuous pass this file's
102
+ * family exists to refuse.
103
+ */
104
+ const VERDICT = Object.freeze({
105
+ SUPERSEDED: "superseded",
106
+ UNRESOLVED: "unresolved",
107
+ UNSUBMITTED: "unsubmitted",
108
+ });
109
+
110
+ /**
111
+ * Where a work-item reference came from, which changes how much it is worth.
112
+ *
113
+ * A `Work-Item:` trailer is the canonical binding the commit gate itself reads.
114
+ * A number lifted from a branch name is an inference — `chore/bump-node-22`
115
+ * names no work item — so a verdict resting on one is printed with its source
116
+ * attached, and the reader is told to confirm before acting on it.
117
+ */
118
+ const REF_SOURCE = Object.freeze({ NAME: "branch name", TRAILER: "trailer" });
119
+
120
+ /**
121
+ * A work-item reference inside one `/`-separated branch-name segment.
122
+ *
123
+ * Anchored, and deliberately narrow. Measured against the real branch list, a
124
+ * loose "first run of digits anywhere" rule invented references out of
125
+ * `chore/lisa-update-fixes-2026-04-12` (the year) and
126
+ * `claude/wonderful-vaughan-61d007` (a hex suffix), then reported a confident
127
+ * verdict on each. A missed reference degrades to `unresolved`, which is loud
128
+ * and safe; an invented one produces a wrong ruling that reads exactly like a
129
+ * right one, so the trade is taken deliberately in that direction.
130
+ *
131
+ * Accepts `3604-…`, `wi3791`, `issue-1423-…`, `worktree-issues-1055`. The
132
+ * trailing `(?![0-9a-z])` is what rejects `61d007`.
133
+ */
134
+ const REF_IN_SEGMENT = /^(?:[a-z]+-)*[a-z]{0,4}(\d{2,})(?![0-9a-z])/u;
135
+
136
+ /** A work-item reference inside a `Work-Item:` trailer value. */
137
+ const REF_IN_TRAILER = /(?:^|\D)(\d{2,})/u;
138
+
139
+ /** `20260904`: a compact `yyyymmdd` date, never a work item. */
140
+ const COMPACT_DATE = /^20\d{6}$/u;
141
+
142
+ /** A bare year, which is a date only when a `-04-12` follows it. */
143
+ const YEAR = /^20\d{2}$/u;
144
+
145
+ /** The separator that turns a bare year into a date. */
146
+ const DATE_TAIL = /^-\d{2}/u;
147
+
59
148
  /**
60
149
  * Run a command and return its trimmed stdout, or undefined on any failure.
61
150
  *
@@ -155,14 +244,260 @@ export function branchesAhead(remote = "origin", base = "main") {
155
244
  }
156
245
 
157
246
  /**
158
- * Branches carrying commits that no pull request has ever covered.
247
+ * The work-item reference a branch is bound to, and how strongly.
248
+ *
249
+ * The trailer is asked first because it is the same binding the commit gate
250
+ * enforces. It is routinely absent — the branch that motivated this check
251
+ * carried no trailer at all — so the branch name is the fallback, reported as
252
+ * an inference rather than as a fact.
253
+ * @param {string} branch Short branch name.
254
+ * @param {string} base Default branch name.
255
+ * @param {string} remote Remote name.
256
+ * @param {typeof run} exec Command runner.
257
+ * @returns {{ref: string, refSource: string} | undefined} The reference.
258
+ */
259
+ export function workItemRef(branch, base, remote = "origin", exec = run) {
260
+ const trailers = exec("git", [
261
+ "log",
262
+ "--format=%(trailers:key=Work-Item,valueonly,separator=%x2C)",
263
+ `${remote}/${base}..${remote}/${branch}`,
264
+ ]);
265
+ const fromTrailer = trailers
266
+ ?.split(/[\n,]/u)
267
+ .map(value => value.trim())
268
+ .find(value => value.length > 0);
269
+ if (fromTrailer) {
270
+ const digits = REF_IN_TRAILER.exec(fromTrailer);
271
+ if (digits) return { ref: digits[1], refSource: REF_SOURCE.TRAILER };
272
+ }
273
+ const fromName = refInBranchName(branch);
274
+ return fromName ? { ref: fromName, refSource: REF_SOURCE.NAME } : undefined;
275
+ }
276
+
277
+ /**
278
+ * The work-item number a branch NAME suggests, if any.
279
+ *
280
+ * Segment-anchored, and dates are refused outright — see `REF_IN_SEGMENT`.
281
+ * @param {string} branch Short branch name.
282
+ * @returns {string | undefined} The number, as written.
283
+ */
284
+ export function refInBranchName(branch) {
285
+ for (const segment of branch.split("/")) {
286
+ const matched = REF_IN_SEGMENT.exec(segment);
287
+ if (!matched) continue;
288
+ const [whole] = matched;
289
+ const number = matched[1];
290
+ if (COMPACT_DATE.test(number)) continue;
291
+ if (YEAR.test(number) && DATE_TAIL.test(segment.slice(whole.length)))
292
+ continue;
293
+ return number;
294
+ }
295
+ return undefined;
296
+ }
297
+
298
+ /**
299
+ * The merged pull request that carried a work item, when one can be named.
300
+ *
301
+ * Two routes, and they are not equally strong. `closedByPullRequestsReferences`
302
+ * is the tracker's own answer. When it is empty — which is exactly what
303
+ * supersession by a SIBLING item looks like — a merged pull request mentioning
304
+ * the reference is a candidate the reader is asked to verify, never a
305
+ * conclusion. Naming it is the whole point: "superseded" with no route attached
306
+ * is a classification the reader has to take on trust.
307
+ * @param {string} ref Work-item reference.
308
+ * @param {typeof run} exec Command runner.
309
+ * @returns {string | undefined} The pull request, prefixed with `#`.
310
+ */
311
+ export function mergedRoute(ref, exec = run) {
312
+ const closedBy = exec("gh", [
313
+ "issue",
314
+ "view",
315
+ ref,
316
+ "--json",
317
+ "closedByPullRequestsReferences",
318
+ ]);
319
+ const direct =
320
+ parseJson(closedBy)?.closedByPullRequestsReferences?.[0]?.number;
321
+ if (direct) return `#${direct}`;
322
+ const searched = exec("gh", [
323
+ "pr",
324
+ "list",
325
+ "--state",
326
+ "merged",
327
+ "--search",
328
+ ref,
329
+ "--limit",
330
+ "1",
331
+ "--json",
332
+ "number",
333
+ ]);
334
+ const candidate = parseJson(searched)?.[0]?.number;
335
+ return candidate ? `#${candidate}` : undefined;
336
+ }
337
+
338
+ /**
339
+ * What the tracker said about one branch's work item.
340
+ * @typedef {object} WorkItem
341
+ * @property {string} ref The item, as `#1234`.
342
+ * @property {string} state The tracker's own state string.
343
+ * @property {string} [refSource] One of `REF_SOURCE`; absent when the caller does not track it.
344
+ * @property {string} [route] The merged pull request the work reached the base through.
345
+ */
346
+
347
+ /** @typedef {{baseAhead: number, branchAhead: number}} Divergence */
348
+
349
+ /**
350
+ * One reported branch, with the tracker's verdict on whether its work survived.
351
+ * @typedef {object} Candidate
352
+ * @property {number} ahead Commits ahead of the base.
353
+ * @property {boolean} [baseLeads] Whether the base has moved; undefined when unmeasured.
354
+ * @property {string} branch Short branch name.
355
+ * @property {Divergence} [divergence] The two-sided count, when it was taken.
356
+ * @property {string} [ref] The work item, as `#1234`.
357
+ * @property {string} [refSource] Where the reference came from.
358
+ * @property {string} [route] The merged pull request that carried the work.
359
+ * @property {string} [state] The tracker's state for the item.
360
+ * @property {string} verdict One of `VERDICT`.
361
+ */
362
+
363
+ /**
364
+ * Parse JSON, returning undefined rather than throwing.
365
+ * @param {string | undefined} text Candidate JSON.
366
+ * @returns {any} The parsed value, or undefined.
367
+ */
368
+ function parseJson(text) {
369
+ if (text === undefined) return undefined;
370
+ try {
371
+ return JSON.parse(text);
372
+ } catch {
373
+ return undefined;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * The tracker's state for each candidate branch, keyed by branch.
379
+ *
380
+ * A branch missing from the returned map is one whose item could not be
381
+ * resolved. That is reported as `unresolved`, not as clean.
382
+ * @param {ReadonlyArray<string>} branches Candidate branch names.
383
+ * @param {string} base Default branch name.
384
+ * @param {string} remote Remote name.
385
+ * @param {typeof run} exec Command runner.
386
+ * @returns {Map<string, WorkItem>} States, keyed by branch.
387
+ */
388
+ export function workItemStates(branches, base, remote = "origin", exec = run) {
389
+ const states = new Map();
390
+ for (const branch of branches) {
391
+ const found = workItemRef(branch, base, remote, exec);
392
+ if (!found) continue;
393
+ const view = parseJson(
394
+ exec("gh", ["issue", "view", found.ref, "--json", "number,state,url"])
395
+ );
396
+ if (typeof view?.state !== "string") continue;
397
+ // `gh issue view <n>` resolves a PULL REQUEST number just as happily as an
398
+ // issue number, and returns `MERGED` for it. Measured: a number lifted from
399
+ // a branch name landed on a pull request, which was then read as a closed
400
+ // work item and reported as proof the work had shipped. The url is the only
401
+ // field that separates the two.
402
+ if (typeof view.url === "string" && view.url.includes("/pull/")) continue;
403
+ const entry = {
404
+ ref: `#${view.number ?? found.ref}`,
405
+ refSource: found.refSource,
406
+ state: view.state,
407
+ };
408
+ if (view.state !== "OPEN") {
409
+ const route = mergedRoute(found.ref, exec);
410
+ // A work item is not its own route. The search below matches the
411
+ // reference itself, so without this the report claims a superseded item
412
+ // "reached main through" the very number under discussion.
413
+ if (route && route !== entry.ref) entry.route = route;
414
+ }
415
+ states.set(branch, entry);
416
+ }
417
+ return states;
418
+ }
419
+
420
+ /**
421
+ * How far each side has moved since the branches parted, keyed by branch.
422
+ *
423
+ * `--left-right` against the merge base, so the two counts are independent.
424
+ * `rev-list --count base..branch` alone — what the ahead collector uses to find
425
+ * candidates — cannot distinguish a branch carrying new work from one that
426
+ * merely predates a large amount of the base.
427
+ * @param {ReadonlyArray<string>} branches Candidate branch names.
428
+ * @param {string} base Default branch name.
429
+ * @param {string} remote Remote name.
430
+ * @param {typeof run} exec Command runner.
431
+ * @returns {Map<string, Divergence>} Counts, keyed by branch.
432
+ */
433
+ export function divergences(branches, base, remote = "origin", exec = run) {
434
+ const counts = new Map();
435
+ for (const branch of branches) {
436
+ const measured = exec("git", [
437
+ "rev-list",
438
+ "--left-right",
439
+ "--count",
440
+ `${remote}/${base}...${remote}/${branch}`,
441
+ ]);
442
+ const [left, right] = (measured ?? "")
443
+ .split(/\s+/u)
444
+ .map(value => Number.parseInt(value, 10));
445
+ if (Number.isFinite(left) && Number.isFinite(right))
446
+ counts.set(branch, { baseAhead: left, branchAhead: right });
447
+ }
448
+ return counts;
449
+ }
450
+
451
+ /**
452
+ * Branches carrying commits that no pull request has ever covered, each with
453
+ * the tracker's verdict on whether the WORK survived.
454
+ *
455
+ * The branch facts narrow the field; the work item decides. A superseded row is
456
+ * still returned — suppressing it would hide the very finding that stops a
457
+ * duplicate pull request — but it is returned labelled.
159
458
  * @param {object} inputs Inputs.
160
459
  * @param {ReadonlyArray<{branch: string, ahead: number}>} inputs.ahead Branches ahead of base.
161
460
  * @param {Set<string>} inputs.submitted Branch names that have a pull request.
162
- * @returns {ReadonlyArray<{branch: string, ahead: number}>} The orphans.
461
+ * @param {Map<string, WorkItem>} [inputs.items] Work-item states by branch.
462
+ * @param {Map<string, Divergence>} [inputs.divergence] Two-sided counts by branch.
463
+ * @returns {ReadonlyArray<Candidate>} The candidates, each carrying a verdict.
464
+ */
465
+ export function orphanedBranches({
466
+ ahead,
467
+ divergence = new Map(),
468
+ items = new Map(),
469
+ submitted,
470
+ }) {
471
+ return ahead
472
+ .filter(row => !submitted.has(row.branch))
473
+ .map(row => {
474
+ const item = items.get(row.branch);
475
+ const sides = divergence.get(row.branch);
476
+ return {
477
+ ahead: row.ahead,
478
+ // Undefined, deliberately, when the direction was never measured. A
479
+ // `false` here would assert that the base does not lead, which is a
480
+ // claim nothing checked.
481
+ baseLeads: sides ? sides.baseAhead > 0 : undefined,
482
+ branch: row.branch,
483
+ divergence: sides,
484
+ ref: item?.ref,
485
+ refSource: item?.refSource,
486
+ route: item?.route,
487
+ state: item?.state,
488
+ verdict: verdictFor(item),
489
+ };
490
+ });
491
+ }
492
+
493
+ /**
494
+ * The verdict a work-item state earns.
495
+ * @param {{state: string} | undefined} item The tracker's answer, if any.
496
+ * @returns {string} One of `VERDICT`.
163
497
  */
164
- export function orphanedBranches({ ahead, submitted }) {
165
- return ahead.filter(row => !submitted.has(row.branch));
498
+ function verdictFor(item) {
499
+ if (!item) return VERDICT.UNRESOLVED;
500
+ return item.state === "OPEN" ? VERDICT.UNSUBMITTED : VERDICT.SUPERSEDED;
166
501
  }
167
502
 
168
503
  /**
@@ -170,47 +505,141 @@ export function orphanedBranches({ ahead, submitted }) {
170
505
  * @param {string[]} argv Arguments.
171
506
  * @returns {number} Exit code.
172
507
  */
173
- export function main(argv = process.argv.slice(2)) {
508
+ export function main(argv = process.argv.slice(2), probe = {}) {
509
+ const {
510
+ collectAhead = branchesAhead,
511
+ collectDivergence = divergences,
512
+ collectSubmitted = branchesWithPullRequests,
513
+ collectWorkItems = workItemStates,
514
+ log = console.log,
515
+ resolveDefaultBranch = defaultBranch,
516
+ warn = console.error,
517
+ } = probe;
174
518
  const remote = "origin";
175
- const base = defaultBranch(remote);
519
+ const base = resolveDefaultBranch(remote);
176
520
  if (!base) {
177
- console.error(
521
+ warn(
178
522
  "check-orphaned-branches: could not resolve the remote default branch, so nothing was checked. This is NOT a clean result."
179
523
  );
180
524
  return EXIT.UNAVAILABLE;
181
525
  }
182
- const ahead = branchesAhead(remote, base);
183
- const submitted = branchesWithPullRequests();
526
+ const ahead = collectAhead(remote, base);
527
+ const submitted = collectSubmitted();
184
528
  if (ahead === undefined || submitted === undefined) {
185
- console.error(
529
+ warn(
186
530
  `check-orphaned-branches: could not read ${ahead === undefined ? "branches" : "pull requests"}, so nothing was checked. This is NOT a clean result.`
187
531
  );
188
532
  return EXIT.UNAVAILABLE;
189
533
  }
190
- const orphans = orphanedBranches({ ahead, submitted });
191
- if (orphans.length === 0) {
192
- console.log(
534
+ const candidates = ahead
535
+ .filter(row => !submitted.has(row.branch))
536
+ .map(row => row.branch);
537
+ if (candidates.length === 0) {
538
+ log(
193
539
  `check-orphaned-branches: every remote branch ahead of ${base} has a pull request.`
194
540
  );
195
541
  return EXIT.OK;
196
542
  }
197
- console.log(
198
- `check-orphaned-branches: ${orphans.length} branch(es) carry commits that were pushed and never submitted.\n`
199
- );
200
- for (const { branch, ahead: count } of [...orphans].sort((a, b) =>
201
- a.branch.localeCompare(b.branch)
202
- ))
203
- console.log(
204
- ` ${branch} — ${count} commit(s) ahead of ${base}, no pull request`
205
- );
206
- console.log(
207
- "\nWork on these branches is in no review, no CI run, and no report, and it drifts from " +
208
- `${base} until it is submitted or deleted. Open a pull request for each, or delete the branch ` +
209
- "if the work is abandoned — but decide, rather than leaving it invisible."
543
+ // The tracker is asked BEFORE anything is classified, because the branch
544
+ // facts above cannot tell abandonment from uniqueness and the tracker can.
545
+ const orphans = orphanedBranches({
546
+ ahead,
547
+ divergence: collectDivergence(candidates, base, remote),
548
+ items: collectWorkItems(candidates, base, remote),
549
+ submitted,
550
+ });
551
+ log(
552
+ `check-orphaned-branches: work item resolved before branch state, for ${orphans.length} branch(es) ahead of ${base} with no pull request.\n`
210
553
  );
554
+ const sorted = [...orphans].sort((a, b) => a.branch.localeCompare(b.branch));
555
+ for (const section of SECTIONS) {
556
+ const rows = sorted.filter(row => row.verdict === section.verdict);
557
+ if (rows.length === 0) continue;
558
+ log(`${section.heading}\n`);
559
+ for (const row of rows)
560
+ for (const line of section.render(row, base)) log(line);
561
+ log("");
562
+ }
211
563
  return argv.includes("--fail-on-found") ? EXIT.FOUND : EXIT.OK;
212
564
  }
213
565
 
566
+ /**
567
+ * How each verdict is written up.
568
+ *
569
+ * Advice is scoped to its own section rather than gathered into one closing
570
+ * paragraph, because the closing paragraph is what told an operator to open a
571
+ * pull request for work that had already shipped. A superseded candidate must
572
+ * be able to appear in this report without the words "open a pull request"
573
+ * appearing anywhere near it.
574
+ */
575
+ const SECTIONS = Object.freeze([
576
+ Object.freeze({
577
+ heading:
578
+ " SUPERSEDED — the work item is closed. The work reached the base by another route.\n" +
579
+ " Preserve these; do not promote them. The branch on the remote IS the durable copy:\n" +
580
+ " it is not deleted here, and it must not be committed, pushed further, or opened as a\n" +
581
+ " pull request. The commit gate would refuse it anyway — it requires a Work-Item trailer\n" +
582
+ " naming a LIVE item — and the only way around that refusal is to retro-fit attribution\n" +
583
+ " to an unrelated open item, which falsifies the commit's provenance.",
584
+ render: renderRow,
585
+ verdict: VERDICT.SUPERSEDED,
586
+ }),
587
+ Object.freeze({
588
+ heading:
589
+ " UNSUBMITTED — the work item is live. This is real work in no review, no CI run and no\n" +
590
+ " report, drifting from the base until it is submitted. Open a pull request for each.",
591
+ render: renderRow,
592
+ verdict: VERDICT.UNSUBMITTED,
593
+ }),
594
+ Object.freeze({
595
+ heading:
596
+ " UNRESOLVED — no work item could be resolved for these, so nothing is known about\n" +
597
+ " whether the work survived. This is NOT a clean result and NOT a licence to act:\n" +
598
+ " resolve the item by hand before deciding anything about the branch.",
599
+ render: renderRow,
600
+ verdict: VERDICT.UNRESOLVED,
601
+ }),
602
+ ]);
603
+
604
+ /**
605
+ * One candidate, written out.
606
+ * @param {Candidate} row A row from `orphanedBranches`.
607
+ * @param {string} base Default branch name.
608
+ * @returns {ReadonlyArray<string>} Lines to print.
609
+ */
610
+ function renderRow(row, base) {
611
+ const item = row.ref
612
+ ? `${row.ref} ${row.state}${row.refSource === REF_SOURCE.NAME ? " (reference inferred from the branch name — confirm it before acting)" : ""}`
613
+ : "no work item resolved (no Work-Item trailer, no reference in the branch name)";
614
+ const lines = [` ${row.branch} — ${item}`];
615
+ if (row.route)
616
+ lines.push(` the work reached ${base} through ${row.route}`);
617
+ lines.push(
618
+ ` ${row.ahead} commit(s) ahead of ${base}${direction(row, base)}`
619
+ );
620
+ return lines;
621
+ }
622
+
623
+ /**
624
+ * The conflict-direction clause for one candidate.
625
+ *
626
+ * Says nothing reassuring when nothing was measured. An unmeasured direction
627
+ * reported as "the base does not lead" is exactly the false all-clear that
628
+ * makes a branch-side resolution look safe.
629
+ * @param {Candidate} row A row from `orphanedBranches`.
630
+ * @param {string} base Default branch name.
631
+ * @returns {string} The clause, empty when there is nothing to say.
632
+ */
633
+ function direction(row, base) {
634
+ if (row.baseLeads === undefined)
635
+ return "; direction NOT measured — establish which side leads before resolving any conflict";
636
+ if (!row.baseLeads) return `; ${base} has not moved since they parted`;
637
+ return (
638
+ `; ${base} leads by ${row.divergence.baseAhead} commit(s) — ` +
639
+ "do NOT take the branch side in any conflict here, it would delete what the base gained"
640
+ );
641
+ }
642
+
214
643
  if (invokedAsScript(import.meta.url)) {
215
644
  // exitCode (not process.exit): when stdout is a pipe, writes are async and
216
645
  // process.exit() truncates the report mid-flush.
@@ -1,5 +1,5 @@
1
1
  import type { DoctorCheck } from "./doctor.js";
2
- /** One caller pointing at the wrong kind of ref for its role. */
2
+ /** One caller still naming something other than a full commit SHA. */
3
3
  export interface RefFinding {
4
4
  /** Workflow file inside the project, relative to the project root. */
5
5
  file: string;
@@ -11,13 +11,13 @@ export interface RefFinding {
11
11
  problem: string;
12
12
  }
13
13
  /**
14
- * Find callers whose ref does not match the role of the reusable they call.
14
+ * Find callers still naming something other than a full-length commit SHA.
15
15
  * @param targetPath - Project root
16
16
  * @returns Every finding, in file order
17
17
  */
18
18
  export declare function reusableRefFindings(targetPath: string): Promise<RefFinding[]>;
19
19
  /**
20
- * Report callers of Lisa's reusable workflows using the wrong ref for their role.
20
+ * Report callers of Lisa's reusable workflows that are still mutable.
21
21
  * @param targetPath - Project path to inspect
22
22
  * @returns Doctor check result
23
23
  */
@@ -1 +1 @@
1
- {"version":3,"file":"doctor-reusable-workflow-refs.d.ts","sourceRoot":"","sources":["../../src/cli/doctor-reusable-workflow-refs.ts"],"names":[],"mappings":"AAyCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AA4B/C,iEAAiE;AACjE,MAAM,WAAW,UAAU;IACzB,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,oDAAoD;IACpD,OAAO,EAAE,MAAM,CAAC;CACjB;AAsCD;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,UAAU,EAAE,CAAC,CAqBvB;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,WAAW,CAAC,CA6BtB"}
1
+ {"version":3,"file":"doctor-reusable-workflow-refs.d.ts","sourceRoot":"","sources":["../../src/cli/doctor-reusable-workflow-refs.ts"],"names":[],"mappings":"AA0CA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAK/C,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,oDAAoD;IACpD,OAAO,EAAE,MAAM,CAAC;CACjB;AAmCD;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,UAAU,EAAE,CAAC,CAoBvB;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,WAAW,CAAC,CA6BtB"}