@codyswann/lisa 4.42.2 → 4.42.4
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/all/copy-overwrite/scripts/check-conflict-markers.mjs +24 -1
- package/all/copy-overwrite/scripts/check-orphaned-branches.mjs +458 -29
- package/dist/cli/doctor-reusable-workflow-refs.d.ts +3 -3
- package/dist/cli/doctor-reusable-workflow-refs.d.ts.map +1 -1
- package/dist/cli/doctor-reusable-workflow-refs.js +46 -74
- package/dist/cli/doctor-reusable-workflow-refs.js.map +1 -1
- package/dist/cli/version.d.ts +17 -0
- package/dist/cli/version.d.ts.map +1 -1
- package/dist/cli/version.js +34 -0
- package/dist/cli/version.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +22 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa-release-pin.d.ts +63 -0
- package/dist/core/lisa-release-pin.d.ts.map +1 -0
- package/dist/core/lisa-release-pin.js +147 -0
- package/dist/core/lisa-release-pin.js.map +1 -0
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +4 -1
- package/dist/core/lisa.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/reusable-workflow-pin.d.ts +113 -0
- package/dist/core/reusable-workflow-pin.d.ts.map +1 -0
- package/dist/core/reusable-workflow-pin.js +165 -0
- package/dist/core/reusable-workflow-pin.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +19 -9
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/migrations/ensure-nightly-e2e-workflow-pins.d.ts +16 -36
- package/dist/migrations/ensure-nightly-e2e-workflow-pins.d.ts.map +1 -1
- package/dist/migrations/ensure-nightly-e2e-workflow-pins.js +33 -76
- package/dist/migrations/ensure-nightly-e2e-workflow-pins.js.map +1 -1
- package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts +143 -0
- package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts.map +1 -0
- package/dist/migrations/ensure-pinned-reusable-workflow-refs.js +293 -0
- package/dist/migrations/ensure-pinned-reusable-workflow-refs.js.map +1 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +5 -0
- package/dist/migrations/index.js.map +1 -1
- package/package.json +6 -5
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/plugins/lisa/rules/reference/falsifiable-checks.md +20 -0
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
- package/plugins/lisa/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
- package/plugins/lisa-agy/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +20 -0
- package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
- package/plugins/lisa-copilot/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +20 -0
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
- package/plugins/lisa-cursor/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/reference/falsifiable-checks.md +20 -0
- package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
- package/plugins/src/base/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/scripts/check-empty-subject-guards.mjs +366 -0
- package/scripts/lisa-update-local.sh +1 -0
- package/typescript/copy-overwrite/.github/GITHUB_ACTIONS.md +6 -1
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +370 -4
- package/typescript/create-only/.github/workflows/review-evidence.yml +20 -0
|
@@ -57,7 +57,24 @@
|
|
|
57
57
|
* 0 — no tracked file carries a conflict block.
|
|
58
58
|
* 1 — ≥1 tracked file carries a conflict block.
|
|
59
59
|
* 2 — operational/usage error: unknown flag, a flag missing its value,
|
|
60
|
-
* `--root` absent or not a git repository,
|
|
60
|
+
* `--root` absent or not a git repository, git being unavailable, or
|
|
61
|
+
* ZERO tracked files enumerated.
|
|
62
|
+
*
|
|
63
|
+
* ## Zero tracked files is exit 2, not a green tick (issue #3888)
|
|
64
|
+
*
|
|
65
|
+
* The cwd anchoring above closes the case where the enumeration is scoped to a
|
|
66
|
+
* SUBDIRECTORY. It does not close the case where the enumeration returns
|
|
67
|
+
* NOTHING, and that case reported `✓ no leftover conflict markers in 0 tracked
|
|
68
|
+
* files` and exit 0 — a required push gate printing a success line for a
|
|
69
|
+
* comparison that had no subject. The count was in hand the whole time and
|
|
70
|
+
* nothing read it.
|
|
71
|
+
*
|
|
72
|
+
* An empty enumeration is not a clean repository: it is a repository whose
|
|
73
|
+
* files this gate never saw. A push carries commits, and a commit carries a
|
|
74
|
+
* tracked file, so zero here means the scan was mis-scoped, `git` answered
|
|
75
|
+
* from somewhere else, or the repository is not the one being pushed. Each of
|
|
76
|
+
* those is an operational failure of the scan, which is exit 2 — the same
|
|
77
|
+
* stance `check-workflow-package-paths` takes with the same words.
|
|
61
78
|
*
|
|
62
79
|
* @module all/copy-overwrite/scripts/check-conflict-markers
|
|
63
80
|
*/
|
|
@@ -461,6 +478,12 @@ export function main(argv, io = {}) {
|
|
|
461
478
|
throw new UsageError(`--root is not a directory: ${opts.root}`);
|
|
462
479
|
}
|
|
463
480
|
files = listTrackedFiles(opts.root);
|
|
481
|
+
if (files.length === 0) {
|
|
482
|
+
throw new UsageError(
|
|
483
|
+
`git ls-files named no tracked file under ${opts.root}; a scan of ` +
|
|
484
|
+
`nothing is not a pass`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
464
487
|
} catch (error) {
|
|
465
488
|
err.write(`error: ${error.message}\n`);
|
|
466
489
|
return 2;
|
|
@@ -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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
165
|
-
|
|
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 =
|
|
519
|
+
const base = resolveDefaultBranch(remote);
|
|
176
520
|
if (!base) {
|
|
177
|
-
|
|
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 =
|
|
183
|
-
const submitted =
|
|
526
|
+
const ahead = collectAhead(remote, base);
|
|
527
|
+
const submitted = collectSubmitted();
|
|
184
528
|
if (ahead === undefined || submitted === undefined) {
|
|
185
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
|
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
|
|
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":"
|
|
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"}
|