@brainervirus/workit-core 8.23.0 → 8.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stack.ts CHANGED
@@ -62,10 +62,10 @@ import {
62
62
  pushRemoteName,
63
63
  remoteRefTip,
64
64
  resolveRef,
65
+ sameOwnChange,
65
66
  } from "./git/rev";
66
67
  import {
67
68
  appendObserved,
68
- checkVerdicts,
69
69
  diffHash,
70
70
  readLedger,
71
71
  storeRoot,
@@ -73,6 +73,9 @@ import {
73
73
  type ReadRow,
74
74
  type VerdictBasis,
75
75
  } from "./ledger";
76
+ import type { LaneNeed, ReviewLane } from "./core/policy/lane";
77
+ import { prLaneGate } from "./core/policy/pr-lane";
78
+ import { checkVerdictsCovered, type StackCover } from "./verdict-cover";
76
79
 
77
80
  export const STACK_VERSION = 1;
78
81
 
@@ -1016,7 +1019,22 @@ export type BranchStatus = {
1016
1019
  next: NextAction | null;
1017
1020
  checks: ChecksState | null;
1018
1021
  blockers: string[];
1019
- verdict: { accepted: boolean; basis: VerdictBasis; reasons: string[]; verdictId: string | null };
1022
+ /**
1023
+ * `accepted`: an accepted independent verdict; `satisfied`: what the land
1024
+ * gate reads, the verdict the PR's review lane asks for (an accepted
1025
+ * independent one outside `verification: proportional`).
1026
+ */
1027
+ verdict: {
1028
+ accepted: boolean;
1029
+ satisfied: boolean;
1030
+ basis: VerdictBasis;
1031
+ reasons: string[];
1032
+ verdictId: string | null;
1033
+ lane: ReviewLane | null;
1034
+ need: LaneNeed;
1035
+ /** The verdict higher in the stack that covers this branch, when it has none of its own. */
1036
+ cover: StackCover | null;
1037
+ };
1020
1038
  };
1021
1039
 
1022
1040
  export type Qualification =
@@ -1081,16 +1099,29 @@ export function repoMismatch(ctx: Pick<Ctx, "resolved">, stack: StackFile): Stac
1081
1099
  return null;
1082
1100
  }
1083
1101
 
1084
- const verdictOf = (cwd: string, branch: string, rows: readonly ReadRow[]) => {
1085
- const check = checkVerdicts(cwd, branch, rows);
1102
+ const verdictOf = (
1103
+ cwd: string,
1104
+ branch: string,
1105
+ rows: readonly ReadRow[],
1106
+ doc: PrStatusDoc | null,
1107
+ ): BranchStatus["verdict"] => {
1108
+ // A lower branch may be covered by the verdict at the top of the stack (#334).
1109
+ const check = checkVerdictsCovered(cwd, branch, rows);
1110
+ // Without a PR there is no base to diff against: the gate stays independent.
1111
+ const gate = doc ? prLaneGate(cwd, check, { branch, base: doc.base, head: doc.head.sha }) : null;
1086
1112
  return {
1087
1113
  accepted: check.accepted.accepted,
1114
+ satisfied: gate ? gate.satisfied : check.accepted.accepted,
1088
1115
  basis: check.current.basis,
1089
- reasons: check.accepted.reasons as string[],
1090
- verdictId:
1091
- typeof check.accepted.verdict?.id === "string" && check.accepted.accepted
1116
+ reasons: check.accepted.reasons,
1117
+ verdictId: gate
1118
+ ? gate.verdictId
1119
+ : typeof check.accepted.verdict?.id === "string" && check.accepted.accepted
1092
1120
  ? check.accepted.verdict.id
1093
1121
  : null,
1122
+ lane: gate?.lane.lane ?? null,
1123
+ need: gate?.need ?? "non-author",
1124
+ cover: check.cover,
1094
1125
  };
1095
1126
  };
1096
1127
 
@@ -1161,12 +1192,20 @@ export function qualify(
1161
1192
  `${label} has a current independent failed verdict for ${doc.head.sha.slice(0, 12)}; --unverified never lands over a rejection`,
1162
1193
  "a new independent verdict on the head supersedes it",
1163
1194
  );
1164
- if (!(bypass && allowUnverified) && !verdict.accepted)
1195
+ if (!(bypass && allowUnverified) && !verdict.satisfied) {
1196
+ const bypassHint = allowUnverified
1197
+ ? `; or, only if the user asks to land unverified: workit stack land --unverified --reason "<why>"`
1198
+ : "";
1165
1199
  return stop(
1166
1200
  verdict.reasons.includes("no_verdict") ? "no_verdict" : "needs_verdict",
1167
- `${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)} (${verdict.reasons.join(", ") || "none"})`,
1168
- `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}${allowUnverified ? `; or, only if the user asks to land unverified: workit stack land --unverified --reason "<why>"` : ""}`,
1201
+ verdict.need === "self"
1202
+ ? `${label} has no verdict for ${doc.head.sha.slice(0, 12)} (lane self: the author's own --self verdict is enough; ${verdict.reasons.join(", ") || "none"})`
1203
+ : `${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)}${verdict.lane && verdict.lane !== "none" ? ` (lane ${verdict.lane})` : ""} (${verdict.reasons.join(", ") || "none"})`,
1204
+ verdict.need === "self"
1205
+ ? `after an observed passing \`workit check test\`, the author runs: workit ledger verdict tests-verified --self --how "<what you ran>" --branch ${entry.branch}${bypassHint}`
1206
+ : `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}${bypassHint}`,
1169
1207
  );
1208
+ }
1170
1209
  if (!grant.allowed)
1171
1210
  return stop(
1172
1211
  "grant_required",
@@ -1215,8 +1254,17 @@ export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutco
1215
1254
  checks: doc?.checks.state ?? null,
1216
1255
  blockers: doc?.blockers ?? [],
1217
1256
  verdict: merged
1218
- ? { accepted: true, basis: "none", reasons: [], verdictId: null }
1219
- : verdictOf(ctx.cwd, entry.branch, ledger.value.rows),
1257
+ ? {
1258
+ accepted: true,
1259
+ satisfied: true,
1260
+ basis: "none",
1261
+ reasons: [],
1262
+ verdictId: null,
1263
+ lane: null,
1264
+ need: "non-author",
1265
+ cover: null,
1266
+ }
1267
+ : verdictOf(ctx.cwd, entry.branch, ledger.value.rows, doc),
1220
1268
  };
1221
1269
  branches.push(row);
1222
1270
  if (merged) {
@@ -1494,7 +1542,7 @@ export function syncStack(
1494
1542
  return stackFail(
1495
1543
  "blocked",
1496
1544
  `a rebase is in progress in ${owner}`,
1497
- `git -C ${owner} rebase --continue # or --abort; then workit --cwd ${owner} stack sync`,
1545
+ `git -C ${owner} rebase --continue # or --abort; then workit stack --cwd ${owner} sync`,
1498
1546
  { conflict: entry.branch, worktree: owner, progress: outcome },
1499
1547
  );
1500
1548
  if (owner && trackedDirt(owner))
@@ -1624,7 +1672,11 @@ export function syncStack(
1624
1672
  };
1625
1673
  const after = { p: patchId(cwd, parentTip, newTip), d: diffHash(cwd, parentTip, newTip) };
1626
1674
  const equal =
1627
- before.d !== null && before.d === after.d && before.p !== null && before.p === after.p;
1675
+ before.d !== null &&
1676
+ before.d === after.d &&
1677
+ before.p !== null &&
1678
+ before.p === after.p &&
1679
+ sameOwnChange(cwd, { from: fromHead, to: newTip, fromBase, toBase: parentTip });
1628
1680
  appendObserved(cwd, {
1629
1681
  type: "stack.restacked",
1630
1682
  actor,
@@ -1866,7 +1918,7 @@ export async function landStack(
1866
1918
  }
1867
1919
  }
1868
1920
  const grant = requireGrant(ctx.cwd, "merge");
1869
- const verdict = verdictOf(ctx.cwd, entry.branch, ledgerRows());
1921
+ const verdict = verdictOf(ctx.cwd, entry.branch, ledgerRows(), doc);
1870
1922
  // A dry run predicts the retarget the real run would do first.
1871
1923
  const effective =
1872
1924
  options.dryRun && simulated.size > 0 && doc ? { ...doc, base: stack.trunk } : doc;
@@ -1906,7 +1958,7 @@ export async function landStack(
1906
1958
  deleteBranch: false,
1907
1959
  actor: ctx.actor,
1908
1960
  expect: { base: stack.trunk, branch: entry.branch },
1909
- ...(bypass && !verdict.accepted ? { unverified: bypass } : {}),
1961
+ ...(bypass && !verdict.satisfied ? { unverified: bypass } : {}),
1910
1962
  },
1911
1963
  ctx.sleep,
1912
1964
  );
@@ -0,0 +1,151 @@
1
+ // Stack-wide verdicts (#334, decision mvk5q3io): one verifier at the top of
2
+ // a stack, after its last push, covers the lower PRs it judged. A branch with
3
+ // no accepted verdict of its own is covered by a branch above it in a cached
4
+ // stack (`<store>/stacks/*.json`, S12) when:
5
+ //
6
+ // - that branch has an accepted independent verdict now (fresh or carried);
7
+ // - the verdict's session is not an author of the lower branch either;
8
+ // - every commit of the lower branch since its fork from the trunk has a
9
+ // verbatim patch-id (whitespace counts) among the commits of the judged
10
+ // head (the verdict's own head, not the upper branch's current tip): the
11
+ // lower change was judged unchanged. No merge commits, at least one
12
+ // non-empty commit;
13
+ // - the judged head holds exactly the lower branch's resulting files (no
14
+ // `<lower>..<judged>` difference in any file the lower branch changed):
15
+ // an upper commit that reverts or rewrites a lower file means the lower
16
+ // PR's own code was never judged;
17
+ // - the lower branch's own review lane is below full (decision B3): a risk
18
+ // path or a large change needs its own verdict.
19
+ //
20
+ // A lower branch that changed after the verdict (a new commit, an amend, a
21
+ // conflict resolved differently) holds a patch-id the judged head lacks and
22
+ // is not covered. When git cannot read any of this, nothing is covered. A current independent failure or a tampered ledger on the
23
+ // lower branch is never covered over. `pr merge`, `stack land`, `stack
24
+ // status`, `pr status` and `ledger check` read this; the Stop hook does not.
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { branchLane } from "./core/policy/lane";
28
+ import { commitPatchIds, diffRaw, mergeBase } from "./git/rev";
29
+ import {
30
+ authorSessions,
31
+ authoredBy,
32
+ checkVerdicts,
33
+ defaultBase,
34
+ resolveCommit,
35
+ storeRoot,
36
+ type ReadRow,
37
+ type VerdictCheck,
38
+ } from "./ledger";
39
+
40
+ /** The verdict above `branch` in a stack that covers it. */
41
+ export type StackCover = {
42
+ stack: string;
43
+ /** The upper branch the verdict was recorded on. */
44
+ branch: string;
45
+ verdictId: string;
46
+ /** The head that verdict judged. */
47
+ head: string;
48
+ };
49
+
50
+ export type CoveredCheck = VerdictCheck & { cover: StackCover | null };
51
+
52
+ type Chain = { name: string; trunk: string; above: string[] };
53
+
54
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
55
+ typeof value === "object" && value !== null && !Array.isArray(value);
56
+
57
+ /** The open branches above `branch` in each cached stack holding it, top first. */
58
+ function chainsAbove(cwd: string, branch: string): Chain[] {
59
+ const root = storeRoot(cwd);
60
+ if (!root.ok) return [];
61
+ const dir = path.join(root.value.root, "stacks");
62
+ let names: string[] = [];
63
+ try {
64
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(".json"));
65
+ } catch {
66
+ return [];
67
+ }
68
+ const chains: Chain[] = [];
69
+ for (const name of names.toSorted()) {
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
73
+ } catch {
74
+ continue;
75
+ }
76
+ if (!isRecord(parsed) || !Array.isArray(parsed.branches) || typeof parsed.trunk !== "string")
77
+ continue;
78
+ const entries = parsed.branches.filter(isRecord);
79
+ const index = entries.findIndex((entry) => entry.branch === branch && !entry.merged);
80
+ if (index < 0) continue;
81
+ chains.push({
82
+ name: typeof parsed.name === "string" ? parsed.name : name,
83
+ trunk: parsed.trunk,
84
+ above: entries
85
+ .slice(index + 1)
86
+ .filter((entry) => typeof entry.branch === "string" && !entry.merged)
87
+ .map((entry) => entry.branch as string)
88
+ .toReversed(),
89
+ });
90
+ }
91
+ return chains;
92
+ }
93
+
94
+ /**
95
+ * Whether the verdict on `judged` covers the lower branch at `head` (see the
96
+ * header). Anything git cannot read is not covered.
97
+ */
98
+ function covers(cwd: string, trunk: string, head: string, judged: string): boolean {
99
+ const trunkSha = [`origin/${trunk}`, trunk].map((ref) => resolveCommit(cwd, ref)).find(Boolean);
100
+ const ownFork = trunkSha ? mergeBase(cwd, trunkSha, head) : null;
101
+ const judgedFork = trunkSha ? mergeBase(cwd, trunkSha, judged) : null;
102
+ if (!ownFork || !judgedFork) return false;
103
+ const own = commitPatchIds(cwd, ownFork, head);
104
+ const top = commitPatchIds(cwd, judgedFork, judged);
105
+ if (!own || !top || own.merges > 0 || own.patchIds.length === 0) return false;
106
+ const judgedIds = new Set(top.patchIds);
107
+ if (!own.patchIds.every((id) => judgedIds.has(id))) return false;
108
+ // The judged tree holds exactly the lower branch's resulting files.
109
+ const changed = diffRaw(cwd, ownFork, head);
110
+ const between = changed ? diffRaw(cwd, head, judged) : null;
111
+ if (!changed || !between) return false;
112
+ const files = new Set(changed.map((entry) => entry.path));
113
+ if (between.some((entry) => files.has(entry.path))) return false;
114
+ return branchLane(cwd, { base: trunk, head }).lane !== "full";
115
+ }
116
+
117
+ /**
118
+ * `checkVerdicts` for `branch`, and when it has no accepted verdict of its
119
+ * own, the stack verdict that covers it (see the header): then `accepted`
120
+ * names that verdict and `cover` says where it came from.
121
+ */
122
+ export function checkVerdictsCovered(
123
+ cwd: string,
124
+ branch: string,
125
+ rows: readonly ReadRow[],
126
+ ): CoveredCheck {
127
+ const check = checkVerdicts(cwd, branch, rows);
128
+ const blocked = check.accepted.reasons.some(
129
+ (reason) => reason === "failing_verdict" || reason === "ledger_tampered",
130
+ );
131
+ if (check.accepted.accepted || blocked || !check.head) return { ...check, cover: null };
132
+ let authors: Set<string> | null = null;
133
+ for (const chain of chainsAbove(cwd, branch))
134
+ for (const upper of chain.above) {
135
+ const verdict = checkVerdicts(cwd, upper, rows).accepted;
136
+ const row = verdict.accepted ? verdict.verdict : null;
137
+ const session = row?.actor.session;
138
+ if (!row?.head || !session || typeof row.id !== "string") continue;
139
+ authors ??= authorSessions(cwd, rows, branch, { base: defaultBase(cwd), head: check.head });
140
+ if (authoredBy(authors, session)) continue;
141
+ if (!covers(cwd, chain.trunk, check.head, row.head)) continue;
142
+ return {
143
+ ...check,
144
+ accepted: { accepted: true, verdict: row, reasons: [] },
145
+ review: "verified",
146
+ selfVerdict: null,
147
+ cover: { stack: chain.name, branch: upper, verdictId: row.id, head: row.head },
148
+ };
149
+ }
150
+ return { ...check, cover: null };
151
+ }