@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/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +1 -1
- package/skills/workit-fanout/references/brief.md +4 -4
- package/skills/workit-implement/SKILL.md +5 -5
- package/skills/workit-review/SKILL.md +5 -0
- package/skills/workit-ship/SKILL.md +1 -2
- package/src/autonomy.ts +22 -11
- package/src/check-config.ts +9 -2
- package/src/core/methods.ts +5 -5
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/carry.ts +74 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +57 -9
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/workspaces.ts +1 -1
- package/src/fanout-brief.ts +8 -6
- package/src/fanout-worktree.ts +1 -1
- package/src/forge/pr-ops.ts +65 -14
- package/src/forge/report.ts +4 -2
- package/src/git/default-base.ts +49 -0
- package/src/git/rev.ts +244 -0
- package/src/hooks/cwd-normalize.ts +44 -10
- package/src/hooks/delivery-verbs.ts +129 -0
- package/src/hooks/handle.ts +21 -6
- package/src/hooks/hosts/claude-code.ts +11 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/protocol.ts +2 -0
- package/src/hooks/raw-git.ts +34 -8
- package/src/hooks/shell-words.ts +32 -1
- package/src/hooks/stop.ts +50 -33
- package/src/hooks/worktree-confine.ts +3 -3
- package/src/hooks/write-gate.ts +10 -1
- package/src/ledger.ts +155 -52
- package/src/stack.ts +68 -16
- package/src/verdict-cover.ts +151 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// Review lanes (#333, ledger decisions mvi7mj28 and mvk5q3io): how much
|
|
2
|
+
// review a change needs, computed from its diff. Under the workspace setting
|
|
3
|
+
// `verification: proportional` (the default) every verdict gate reads the
|
|
4
|
+
// lane: `pr merge`, `stack land`, the Stop hook and `policy assess`.
|
|
5
|
+
//
|
|
6
|
+
// none docs, comment or formatting-only, or generated copies: no verdict
|
|
7
|
+
// self a small, low-risk behavior change: the author's `--self` verdict
|
|
8
|
+
// focused a normal behavior change, or any lockfile or binary change: an
|
|
9
|
+
// accepted non-author verdict
|
|
10
|
+
// full a risk path, a large diff or a high-risk judgment: an accepted
|
|
11
|
+
// non-author verdict (a high-risk task also asks `--kind live`)
|
|
12
|
+
//
|
|
13
|
+
// The lane fails closed (review finding mvkcoxgb): any doubt about a file
|
|
14
|
+
// makes it a behavior change, and a file on a risk path is never below full,
|
|
15
|
+
// whatever else it is (cosmetic, generated, docs or a lockfile). Generated
|
|
16
|
+
// copies and repo risk paths are read from the base revision only, so a
|
|
17
|
+
// change can neither mark its own code generated nor drop a risk path.
|
|
18
|
+
//
|
|
19
|
+
// The diff sets the floor; a recorded high-risk judgment raises it to full.
|
|
20
|
+
// A behavior-change judgment does not move it: the diff already tells docs
|
|
21
|
+
// from behavior (Markdown that steers agents counts as behavior).
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import {
|
|
25
|
+
diffNumstat,
|
|
26
|
+
diffRaw,
|
|
27
|
+
fileAt,
|
|
28
|
+
isTracked,
|
|
29
|
+
mergeBase,
|
|
30
|
+
ownChangeDrift,
|
|
31
|
+
readBlobs,
|
|
32
|
+
resolveRef,
|
|
33
|
+
treeFilesNamed,
|
|
34
|
+
untrackedFiles,
|
|
35
|
+
worktreeTop,
|
|
36
|
+
} from "../../git/rev";
|
|
37
|
+
import type { VerificationMode } from "../../autonomy";
|
|
38
|
+
import { defaultBase } from "../../git/default-base";
|
|
39
|
+
import type { VerdictCheck } from "../../ledger";
|
|
40
|
+
import { generatedByAttributes } from "./attributes";
|
|
41
|
+
import { cosmeticChange, hasOpaqueLineBreak } from "./substance";
|
|
42
|
+
|
|
43
|
+
export const REVIEW_LANES = ["none", "self", "focused", "full"] as const;
|
|
44
|
+
export type ReviewLane = (typeof REVIEW_LANES)[number];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every lane threshold, in one place. Lines are added plus deleted lines of
|
|
48
|
+
* files that change behavior (code, tests, agent instructions); docs,
|
|
49
|
+
* cosmetic edits, generated copies and lockfiles never count.
|
|
50
|
+
*/
|
|
51
|
+
export const LANE_THRESHOLDS = {
|
|
52
|
+
/** Above this a change is not small: self moves to focused (decision mvi7mj28). */
|
|
53
|
+
selfMaxLines: 100,
|
|
54
|
+
/** More behavior files than this is not small either: self moves to focused. */
|
|
55
|
+
selfMaxFiles: 5,
|
|
56
|
+
/** At or above this a diff is large: full. */
|
|
57
|
+
fullMinLines: 500,
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Path tokens that mark a risk path: merge and release gates, hooks, auth,
|
|
62
|
+
* secrets, migrations, CI. A token matches a whole directory name or a
|
|
63
|
+
* `-`/`_`/`.`-separated part of one (`auth`, `oauth-client`, `pre_commit`).
|
|
64
|
+
* Tests and docs never match a token.
|
|
65
|
+
*/
|
|
66
|
+
export const RISK_TOKENS: ReadonlySet<string> = new Set([
|
|
67
|
+
"auth",
|
|
68
|
+
"authn",
|
|
69
|
+
"authz",
|
|
70
|
+
"oauth",
|
|
71
|
+
"security",
|
|
72
|
+
"permission",
|
|
73
|
+
"permissions",
|
|
74
|
+
"secret",
|
|
75
|
+
"secrets",
|
|
76
|
+
"crypto",
|
|
77
|
+
"gate",
|
|
78
|
+
"gates",
|
|
79
|
+
"policy",
|
|
80
|
+
"policies",
|
|
81
|
+
"hook",
|
|
82
|
+
"hooks",
|
|
83
|
+
"husky",
|
|
84
|
+
"lefthook",
|
|
85
|
+
"release",
|
|
86
|
+
"releases",
|
|
87
|
+
"migration",
|
|
88
|
+
"migrations",
|
|
89
|
+
"migrate",
|
|
90
|
+
"workflows",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/** Exact repo paths (CI definitions, the lane's own inputs) that are risk paths. */
|
|
94
|
+
const RISK_FILES = new Set([
|
|
95
|
+
".gitlab-ci.yml",
|
|
96
|
+
"Jenkinsfile",
|
|
97
|
+
"azure-pipelines.yml",
|
|
98
|
+
".pre-commit-config.yaml",
|
|
99
|
+
".workit/risk-paths",
|
|
100
|
+
]);
|
|
101
|
+
/** File names that are risk paths in any directory: they decide what is generated. */
|
|
102
|
+
const RISK_NAMES = new Set([".gitattributes"]);
|
|
103
|
+
|
|
104
|
+
/** The repo's own risk paths, one glob per line at the base revision (decision B3: they only tighten). */
|
|
105
|
+
export const RISK_PATHS_FILE = ".workit/risk-paths";
|
|
106
|
+
|
|
107
|
+
const LOCKFILES = new Set([
|
|
108
|
+
"bun.lock",
|
|
109
|
+
"bun.lockb",
|
|
110
|
+
"package-lock.json",
|
|
111
|
+
"npm-shrinkwrap.json",
|
|
112
|
+
"yarn.lock",
|
|
113
|
+
"pnpm-lock.yaml",
|
|
114
|
+
"Cargo.lock",
|
|
115
|
+
"go.sum",
|
|
116
|
+
"poetry.lock",
|
|
117
|
+
"uv.lock",
|
|
118
|
+
"Pipfile.lock",
|
|
119
|
+
"Gemfile.lock",
|
|
120
|
+
"composer.lock",
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Docs are told by prose extension only: code under `docs/` is code. `.txt`
|
|
125
|
+
* is not a doc: requirements.txt, CMakeLists.txt or robots.txt change behavior.
|
|
126
|
+
*/
|
|
127
|
+
const DOC_EXT = /\.(?:md|mdx|markdown|rst|adoc)$/iu;
|
|
128
|
+
const DOC_NAMES = /^(?:license|licence|notice|authors|changelog|changes|contributors)$/iu;
|
|
129
|
+
/** Markdown that steers agents is behavior, not docs. */
|
|
130
|
+
const INSTRUCTIONS =
|
|
131
|
+
/(?:^|\/)(?:AGENTS|CLAUDE|GEMINI|SKILL)\.md$|(?:^|\/)(?:skills|agents|commands|rules|prompts)\/[^/]+/iu;
|
|
132
|
+
/**
|
|
133
|
+
* A test file: under a `test/`, `tests/` or `__tests__/` folder, or named
|
|
134
|
+
* `*.test.*`, `*.spec.*` or `*_test.*`. A `fixtures/` or `spec/` folder can
|
|
135
|
+
* hold runtime data or code, so it is not a test path (it reads as behavior).
|
|
136
|
+
*/
|
|
137
|
+
const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__)\/|\.(?:test|spec)\.[^/]+$|_test\.[^/]+$/iu;
|
|
138
|
+
|
|
139
|
+
export type ChangedFile = {
|
|
140
|
+
path: string;
|
|
141
|
+
added: number;
|
|
142
|
+
deleted: number;
|
|
143
|
+
binary?: boolean;
|
|
144
|
+
/** Only whitespace, blank-line or comment changes. */
|
|
145
|
+
cosmetic?: boolean;
|
|
146
|
+
/** Marked `linguist-generated` by the base revision's `.gitattributes`. */
|
|
147
|
+
generated?: boolean;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export type LaneInput = {
|
|
151
|
+
files: readonly ChangedFile[];
|
|
152
|
+
/** The task judgment's riskTier, when one is recorded (high raises to full). */
|
|
153
|
+
riskTier?: "trivial" | "normal" | "high" | null;
|
|
154
|
+
/** Extra risk-path globs (`**`, `*`, `?`); they only tighten. */
|
|
155
|
+
riskPaths?: readonly string[];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export type LaneAssessment = {
|
|
159
|
+
lane: ReviewLane;
|
|
160
|
+
/** Why, most decisive first. */
|
|
161
|
+
reasons: string[];
|
|
162
|
+
/** Counted lines (see LANE_THRESHOLDS). */
|
|
163
|
+
lines: number;
|
|
164
|
+
/** Files that change behavior. */
|
|
165
|
+
files: number;
|
|
166
|
+
riskFiles: string[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export type FileKind = "doc" | "cosmetic" | "generated" | "lockfile" | "test" | "behavior";
|
|
170
|
+
|
|
171
|
+
const basename = (file: string): string => file.slice(file.lastIndexOf("/") + 1);
|
|
172
|
+
|
|
173
|
+
/** Prose for people: a doc extension (or a bare LICENSE-style name), not agent instructions. */
|
|
174
|
+
const isDoc = (file: string): boolean =>
|
|
175
|
+
!INSTRUCTIONS.test(file) && (DOC_EXT.test(file) || DOC_NAMES.test(basename(file)));
|
|
176
|
+
|
|
177
|
+
/** What a file off risk paths is; risk paths are decided before (see computeLane). */
|
|
178
|
+
export function kindOf(file: ChangedFile): FileKind {
|
|
179
|
+
if (file.binary) return "behavior";
|
|
180
|
+
if (file.generated) return "generated";
|
|
181
|
+
if (LOCKFILES.has(basename(file.path))) return "lockfile";
|
|
182
|
+
if (isDoc(file.path)) return "doc";
|
|
183
|
+
if (file.cosmetic) return "cosmetic";
|
|
184
|
+
return TEST_PATH.test(file.path) ? "test" : "behavior";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const globCache = new Map<string, RegExp>();
|
|
188
|
+
/** `**` spans directories, `*` and `?` stay within one. */
|
|
189
|
+
function globMatch(pattern: string, file: string): boolean {
|
|
190
|
+
let regex = globCache.get(pattern);
|
|
191
|
+
if (!regex) {
|
|
192
|
+
let source = "";
|
|
193
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
194
|
+
const char = pattern[index];
|
|
195
|
+
if (char === "*" && pattern[index + 1] === "*") {
|
|
196
|
+
source += pattern[index + 2] === "/" ? "(?:[^/]*/)*" : ".*";
|
|
197
|
+
index += pattern[index + 2] === "/" ? 2 : 1;
|
|
198
|
+
} else if (char === "*") source += "[^/]*";
|
|
199
|
+
else if (char === "?") source += "[^/]";
|
|
200
|
+
else source += char.replace(/[.+^${}()|[\]\\]/gu, "\\$&");
|
|
201
|
+
}
|
|
202
|
+
regex = new RegExp(`^${source}$`, "u");
|
|
203
|
+
globCache.set(pattern, regex);
|
|
204
|
+
}
|
|
205
|
+
return regex.test(file) || file.startsWith(`${pattern.replace(/\/$/u, "")}/`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A risk path: a configured glob (any file, tests and docs included), a CI
|
|
210
|
+
* definition, a `.gitattributes`, `.workit/risk-paths`, or a path with a
|
|
211
|
+
* risk token (never a test or a doc).
|
|
212
|
+
*/
|
|
213
|
+
export function isRiskPath(file: string, extra: readonly string[] = []): boolean {
|
|
214
|
+
if (extra.some((pattern) => globMatch(pattern, file))) return true;
|
|
215
|
+
if (RISK_FILES.has(file) || RISK_NAMES.has(basename(file))) return true;
|
|
216
|
+
if (TEST_PATH.test(file) || isDoc(file)) return false;
|
|
217
|
+
return file
|
|
218
|
+
.split("/")
|
|
219
|
+
.map((segment, index, all) =>
|
|
220
|
+
index === all.length - 1 ? segment.replace(/\.[^.]+$/u, "") : segment,
|
|
221
|
+
)
|
|
222
|
+
.some((segment) =>
|
|
223
|
+
segment
|
|
224
|
+
.toLowerCase()
|
|
225
|
+
.split(/[-_.]/u)
|
|
226
|
+
.some((token) => RISK_TOKENS.has(token)),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The globs of a risk-paths file: one per line, `#` comments and blank lines skipped. */
|
|
231
|
+
export const parseRiskPaths = (text: string): string[] =>
|
|
232
|
+
text
|
|
233
|
+
.split("\n")
|
|
234
|
+
.map((line) => line.trim())
|
|
235
|
+
.filter((line) => line && !line.startsWith("#"))
|
|
236
|
+
.map((line) => line.replace(/^\//u, ""));
|
|
237
|
+
|
|
238
|
+
/** The review lane for a set of changed files (pure; thresholds in LANE_THRESHOLDS). */
|
|
239
|
+
export function computeLane(input: LaneInput): LaneAssessment {
|
|
240
|
+
// A risk path is decided first and overrides every other kind.
|
|
241
|
+
const riskFiles = input.files
|
|
242
|
+
.filter((file) => isRiskPath(file.path, input.riskPaths))
|
|
243
|
+
.map((file) => file.path);
|
|
244
|
+
const risky = new Set(riskFiles);
|
|
245
|
+
const kinds = input.files.map((file) => ({
|
|
246
|
+
file,
|
|
247
|
+
kind: risky.has(file.path) ? ("behavior" as const) : kindOf(file),
|
|
248
|
+
}));
|
|
249
|
+
const behavior = kinds.filter(
|
|
250
|
+
(entry) => entry.kind === "behavior" || entry.kind === "test" || entry.kind === "lockfile",
|
|
251
|
+
);
|
|
252
|
+
const lines = behavior
|
|
253
|
+
.filter((entry) => entry.kind !== "lockfile")
|
|
254
|
+
.reduce((sum, entry) => sum + entry.file.added + entry.file.deleted, 0);
|
|
255
|
+
const opaque = behavior.filter((entry) => entry.kind === "lockfile" || entry.file.binary);
|
|
256
|
+
const result = (lane: ReviewLane, reasons: string[]): LaneAssessment => ({
|
|
257
|
+
lane,
|
|
258
|
+
reasons,
|
|
259
|
+
lines,
|
|
260
|
+
files: behavior.length,
|
|
261
|
+
riskFiles,
|
|
262
|
+
});
|
|
263
|
+
const { selfMaxLines, selfMaxFiles, fullMinLines } = LANE_THRESHOLDS;
|
|
264
|
+
if (input.riskTier === "high") return result("full", ["the task judgment records high risk"]);
|
|
265
|
+
if (riskFiles.length)
|
|
266
|
+
return result("full", [
|
|
267
|
+
`touches a risk path: ${riskFiles.slice(0, 3).join(", ")}${riskFiles.length > 3 ? ", …" : ""}`,
|
|
268
|
+
]);
|
|
269
|
+
if (!behavior.length)
|
|
270
|
+
return result("none", [
|
|
271
|
+
input.files.length
|
|
272
|
+
? "only docs, comments, formatting or generated copies changed"
|
|
273
|
+
: "no changes",
|
|
274
|
+
]);
|
|
275
|
+
if (lines >= fullMinLines)
|
|
276
|
+
return result("full", [`large diff: ${lines} lines (≥ ${fullMinLines})`]);
|
|
277
|
+
if (lines > selfMaxLines)
|
|
278
|
+
return result("focused", [`${lines} lines changed (> ${selfMaxLines})`]);
|
|
279
|
+
if (behavior.length > selfMaxFiles)
|
|
280
|
+
return result("focused", [`${behavior.length} behavior files changed (> ${selfMaxFiles})`]);
|
|
281
|
+
// A lockfile or a binary has no lines to read: the author cannot vouch for it alone.
|
|
282
|
+
if (opaque.length)
|
|
283
|
+
return result("focused", [
|
|
284
|
+
`a lockfile or binary changed: ${opaque
|
|
285
|
+
.slice(0, 3)
|
|
286
|
+
.map((entry) => entry.file.path)
|
|
287
|
+
.join(", ")}${opaque.length > 3 ? ", …" : ""}`,
|
|
288
|
+
]);
|
|
289
|
+
return result("self", [
|
|
290
|
+
`small change: ${lines} line${lines === 1 ? "" : "s"} in ${behavior.length} file${behavior.length === 1 ? "" : "s"}, no risk path`,
|
|
291
|
+
]);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Lines of an untracked file; binary (or unreadable) when it holds a NUL byte. */
|
|
295
|
+
function untrackedStat(cwd: string, file: string): ChangedFile {
|
|
296
|
+
try {
|
|
297
|
+
const bytes = readFileSync(path.join(cwd, file));
|
|
298
|
+
if (bytes.includes(0)) return { path: file, added: 0, deleted: 0, binary: true };
|
|
299
|
+
const text = bytes.toString("utf8");
|
|
300
|
+
if (hasOpaqueLineBreak(text)) return { path: file, added: 0, deleted: 0, binary: true };
|
|
301
|
+
const lines = text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
302
|
+
return { path: file, added: lines, deleted: 0 };
|
|
303
|
+
} catch {
|
|
304
|
+
return { path: file, added: 0, deleted: 0, binary: true };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const REGULAR = new Set(["100644", "100755"]);
|
|
309
|
+
const ABSENT = /^0+$/u;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The files of `fork..head` (or the working tree) whose change is cosmetic:
|
|
313
|
+
* a regular file on both sides, same mode, and equal substance (comments
|
|
314
|
+
* and insignificant whitespace aside; see substance.ts). A file git lists
|
|
315
|
+
* but this cannot read is not cosmetic.
|
|
316
|
+
*/
|
|
317
|
+
function cosmeticFiles(
|
|
318
|
+
cwd: string,
|
|
319
|
+
fork: string,
|
|
320
|
+
head: string | null,
|
|
321
|
+
): { cosmetic: Set<string>; opaque: Set<string> } | null {
|
|
322
|
+
const raw = diffRaw(cwd, fork, head);
|
|
323
|
+
if (!raw) return null;
|
|
324
|
+
const candidates = raw.filter(
|
|
325
|
+
(entry) =>
|
|
326
|
+
REGULAR.has(entry.oldMode) &&
|
|
327
|
+
entry.oldMode === entry.newMode &&
|
|
328
|
+
!ABSENT.test(entry.oldBlob) &&
|
|
329
|
+
(head === null || !ABSENT.test(entry.newBlob)),
|
|
330
|
+
);
|
|
331
|
+
const blobs = readBlobs(
|
|
332
|
+
cwd,
|
|
333
|
+
candidates.flatMap((entry) => [entry.oldBlob, entry.newBlob]),
|
|
334
|
+
);
|
|
335
|
+
if (!blobs) return null;
|
|
336
|
+
const out = new Set<string>();
|
|
337
|
+
const opaque = new Set<string>();
|
|
338
|
+
for (const entry of candidates) {
|
|
339
|
+
const before = blobs.get(entry.oldBlob);
|
|
340
|
+
let after = blobs.get(entry.newBlob);
|
|
341
|
+
if (!after && head === null && ABSENT.test(entry.newBlob))
|
|
342
|
+
try {
|
|
343
|
+
after = readFileSync(path.join(cwd, entry.path));
|
|
344
|
+
} catch {
|
|
345
|
+
after = undefined;
|
|
346
|
+
}
|
|
347
|
+
if (before && after && cosmeticChange(entry.path, before, after)) out.add(entry.path);
|
|
348
|
+
// A lone CR, U+2028 or U+2029 ends a line for some readers and not for
|
|
349
|
+
// others: such a file is read as opaque, like a binary.
|
|
350
|
+
else if ([before, after].some((bytes) => bytes && hasOpaqueLineBreak(bytes.toString("utf8"))))
|
|
351
|
+
opaque.add(entry.path);
|
|
352
|
+
}
|
|
353
|
+
return { cosmetic: out, opaque };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Of `paths`, those the `.gitattributes` files at `fork` mark generated (none when unreadable). */
|
|
357
|
+
function generatedAt(cwd: string, fork: string, paths: readonly string[]): Set<string> {
|
|
358
|
+
const files = treeFilesNamed(cwd, fork, ".gitattributes");
|
|
359
|
+
const blobs = files?.size ? readBlobs(cwd, [...files.values()]) : null;
|
|
360
|
+
if (!files || !blobs) return new Set();
|
|
361
|
+
const texts = new Map<string, string>();
|
|
362
|
+
for (const [file, id] of files) {
|
|
363
|
+
const bytes = blobs.get(id);
|
|
364
|
+
if (bytes) texts.set(file, bytes.toString("utf8"));
|
|
365
|
+
}
|
|
366
|
+
return generatedByAttributes(texts, paths);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The fork point of `head` (or HEAD for the working tree) from `base`. */
|
|
370
|
+
const forkOf = (cwd: string, base: string, head: string | null): string | null =>
|
|
371
|
+
mergeBase(cwd, base, head ?? "HEAD");
|
|
372
|
+
|
|
373
|
+
/** The files of `fork..head`; generated copies are read from `attributesAt` (the base revision). */
|
|
374
|
+
function laneFiles(
|
|
375
|
+
cwd: string,
|
|
376
|
+
fork: string,
|
|
377
|
+
head: string | null,
|
|
378
|
+
attributesAt: string = fork,
|
|
379
|
+
): ChangedFile[] | null {
|
|
380
|
+
const stats = diffNumstat(cwd, fork, head);
|
|
381
|
+
const cosmetic = stats ? cosmeticFiles(cwd, fork, head) : null;
|
|
382
|
+
if (!stats || !cosmetic) return null;
|
|
383
|
+
const files: ChangedFile[] = [
|
|
384
|
+
...stats.map((entry) => ({
|
|
385
|
+
...entry,
|
|
386
|
+
...(cosmetic.opaque.has(entry.path) ? { binary: true } : {}),
|
|
387
|
+
cosmetic: !entry.binary && cosmetic.cosmetic.has(entry.path),
|
|
388
|
+
})),
|
|
389
|
+
...(head === null ? untrackedFiles(cwd).map((file) => untrackedStat(cwd, file)) : []),
|
|
390
|
+
];
|
|
391
|
+
const generated = generatedAt(
|
|
392
|
+
cwd,
|
|
393
|
+
attributesAt,
|
|
394
|
+
files.map((entry) => entry.path),
|
|
395
|
+
);
|
|
396
|
+
return files.map((entry) => ({
|
|
397
|
+
...entry,
|
|
398
|
+
generated: generated.has(entry.path),
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The files whose own change differs between two versions of a branch: the
|
|
404
|
+
* judged `from` (forked from `fromFork`) and the current `to` (forked from
|
|
405
|
+
* `toFork`). Generated copies are read from `toFork` only.
|
|
406
|
+
*
|
|
407
|
+
* - Same fork: the plain `from..to` diff, comments and formatting read as
|
|
408
|
+
* in the lane.
|
|
409
|
+
* - Moved fork (a rebase): each file the branch touches is compared by exact
|
|
410
|
+
* blob (see ownChangeDrift): it counts when the branch's resulting file
|
|
411
|
+
* differs, or when the base changed it too between the forks. Their lines
|
|
412
|
+
* come from `from..to`, which may include base edits (over-counting is the
|
|
413
|
+
* safe direction), and none is cosmetic.
|
|
414
|
+
*
|
|
415
|
+
* Null when git cannot read either side.
|
|
416
|
+
*/
|
|
417
|
+
export function driftFiles(
|
|
418
|
+
cwd: string,
|
|
419
|
+
range: { from: string; to: string; fromFork: string; toFork: string },
|
|
420
|
+
): ChangedFile[] | null {
|
|
421
|
+
if (range.fromFork === range.toFork) return laneFiles(cwd, range.from, range.to, range.toFork);
|
|
422
|
+
const drifted = ownChangeDrift(cwd, range);
|
|
423
|
+
const stats = drifted ? diffNumstat(cwd, range.from, range.to) : null;
|
|
424
|
+
if (!drifted || !stats) return null;
|
|
425
|
+
const byPath = new Map(stats.map((entry) => [entry.path, entry]));
|
|
426
|
+
const generated = generatedAt(cwd, range.toFork, drifted);
|
|
427
|
+
return drifted.map((file) => ({
|
|
428
|
+
path: file,
|
|
429
|
+
added: byPath.get(file)?.added ?? 0,
|
|
430
|
+
deleted: byPath.get(file)?.deleted ?? 0,
|
|
431
|
+
...(byPath.get(file)?.binary ? { binary: true } : {}),
|
|
432
|
+
cosmetic: false,
|
|
433
|
+
generated: generated.has(file),
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The changed files of `merge-base(base, head)..head`, or with `head` null
|
|
439
|
+
* of the working tree (tracked changes plus untracked files) since
|
|
440
|
+
* `merge-base(base, HEAD)`. Null when git cannot read them.
|
|
441
|
+
*/
|
|
442
|
+
export function changedFiles(cwd: string, base: string, head: string | null): ChangedFile[] | null {
|
|
443
|
+
const fork = forkOf(cwd, base, head);
|
|
444
|
+
return fork ? laneFiles(cwd, fork, head) : null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export type BranchLane = LaneAssessment & {
|
|
448
|
+
base: string | null;
|
|
449
|
+
head: string | null;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The lane of `head` against `base` (a branch name; `origin/<base>` is
|
|
454
|
+
* preferred, then the local branch, then the repository default). The repo's
|
|
455
|
+
* risk paths come from `.workit/risk-paths` at the fork point. When the diff
|
|
456
|
+
* cannot be read the lane is focused (an independent verdict, as before
|
|
457
|
+
* lanes), or full under a high-risk judgment.
|
|
458
|
+
*/
|
|
459
|
+
export function branchLane(
|
|
460
|
+
cwd: string,
|
|
461
|
+
options: {
|
|
462
|
+
base?: string | null;
|
|
463
|
+
/** The commit to assess; null with `workingTree` reads the working tree instead. */
|
|
464
|
+
head: string | null;
|
|
465
|
+
workingTree?: boolean;
|
|
466
|
+
judgment?: { riskTier: "trivial" | "normal" | "high" } | null;
|
|
467
|
+
riskPaths?: readonly string[];
|
|
468
|
+
},
|
|
469
|
+
): BranchLane {
|
|
470
|
+
const candidates = options.base ? [`origin/${options.base}`, options.base] : [];
|
|
471
|
+
const base = candidates.find((ref) => resolveRef(cwd, ref) !== null) ?? defaultBase(cwd) ?? null;
|
|
472
|
+
const head = options.workingTree ? null : options.head;
|
|
473
|
+
const fork = base && (options.workingTree || options.head) ? forkOf(cwd, base, head) : null;
|
|
474
|
+
const files = fork ? laneFiles(cwd, fork, head) : null;
|
|
475
|
+
const riskTier = options.judgment?.riskTier ?? null;
|
|
476
|
+
if (!fork || !files) {
|
|
477
|
+
const high = riskTier === "high";
|
|
478
|
+
return {
|
|
479
|
+
lane: high ? "full" : "focused",
|
|
480
|
+
reasons: [
|
|
481
|
+
`the diff of ${options.head?.slice(0, 12) ?? "(no head)"} against ${base ?? "(no base)"} cannot be read`,
|
|
482
|
+
...(high ? ["the task judgment records high risk"] : []),
|
|
483
|
+
],
|
|
484
|
+
lines: 0,
|
|
485
|
+
files: 0,
|
|
486
|
+
riskFiles: [],
|
|
487
|
+
base,
|
|
488
|
+
head: options.head,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const repoRisk = parseRiskPaths(fileAt(cwd, fork, RISK_PATHS_FILE) ?? "");
|
|
492
|
+
return {
|
|
493
|
+
...computeLane({
|
|
494
|
+
files,
|
|
495
|
+
riskTier,
|
|
496
|
+
riskPaths: [...repoRisk, ...(options.riskPaths ?? [])],
|
|
497
|
+
}),
|
|
498
|
+
base,
|
|
499
|
+
head: options.head,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* A warning when `.workit/risk-paths` sits in the working tree but is not
|
|
505
|
+
* tracked: the lane reads it from the base revision only, so it applies once
|
|
506
|
+
* it is committed there. Null when there is nothing to warn about.
|
|
507
|
+
*/
|
|
508
|
+
export function untrackedRiskPathsWarning(cwd: string): string | null {
|
|
509
|
+
const root = worktreeTop(cwd);
|
|
510
|
+
if (!root || !existsSync(path.join(root, RISK_PATHS_FILE))) return null;
|
|
511
|
+
return isTracked(root, RISK_PATHS_FILE)
|
|
512
|
+
? null
|
|
513
|
+
: `${RISK_PATHS_FILE} is not committed: it does not apply until it is committed on the base branch`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** What a verdict gate asks for in a lane under `verification: proportional`. */
|
|
517
|
+
export type LaneNeed = "none" | "self" | "non-author";
|
|
518
|
+
export const laneNeed = (lane: ReviewLane): LaneNeed =>
|
|
519
|
+
lane === "none" ? "none" : lane === "self" ? "self" : "non-author";
|
|
520
|
+
|
|
521
|
+
export type LaneGate = {
|
|
522
|
+
/** What the gate asks: nothing, the author's own `--self` verdict, or a non-author one. */
|
|
523
|
+
need: LaneNeed;
|
|
524
|
+
satisfied: boolean;
|
|
525
|
+
/** The verdict that satisfies it (null under lane none, or when unmet). */
|
|
526
|
+
verdictId: string | null;
|
|
527
|
+
review: "verified" | "self-reviewed" | "none";
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* One verdict gate for `pr merge`, `stack land` and the Stop hook. Under
|
|
532
|
+
* `verification: proportional` with a lane, the lane decides; under `self`
|
|
533
|
+
* or `independent` (or without a lane) an accepted non-author verdict is
|
|
534
|
+
* needed, as before lanes. A current independent failure or a tampered
|
|
535
|
+
* ledger always blocks. The caller still checks the verdict's head.
|
|
536
|
+
*/
|
|
537
|
+
export function laneGate(
|
|
538
|
+
check: Pick<VerdictCheck, "accepted" | "selfVerdict">,
|
|
539
|
+
verification: VerificationMode,
|
|
540
|
+
lane: Pick<LaneAssessment, "lane"> | null,
|
|
541
|
+
): LaneGate {
|
|
542
|
+
const need: LaneNeed =
|
|
543
|
+
verification === "proportional" && lane ? laneNeed(lane.lane) : "non-author";
|
|
544
|
+
const blocked = check.accepted.reasons.some(
|
|
545
|
+
(reason) => reason === "failing_verdict" || reason === "ledger_tampered",
|
|
546
|
+
);
|
|
547
|
+
if (check.accepted.accepted && check.accepted.verdict)
|
|
548
|
+
return {
|
|
549
|
+
need,
|
|
550
|
+
satisfied: true,
|
|
551
|
+
verdictId: String(check.accepted.verdict.id),
|
|
552
|
+
review: "verified",
|
|
553
|
+
};
|
|
554
|
+
if (need === "self" && check.selfVerdict && !blocked)
|
|
555
|
+
return {
|
|
556
|
+
need,
|
|
557
|
+
satisfied: true,
|
|
558
|
+
verdictId: String(check.selfVerdict.id),
|
|
559
|
+
review: "self-reviewed",
|
|
560
|
+
};
|
|
561
|
+
return { need, satisfied: need === "none" && !blocked, verdictId: null, review: "none" };
|
|
562
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The verdict gate of one PR head under the workspace's verification mode:
|
|
2
|
+
// what `pr merge`, `stack land` and `pr status` read (#333).
|
|
3
|
+
import { resolveAutonomy, type VerificationMode } from "../../autonomy";
|
|
4
|
+
import { currentBranch } from "../../git/rev";
|
|
5
|
+
import type { VerdictCheck } from "../../ledger";
|
|
6
|
+
import { currentJudgment } from "./current-judgment";
|
|
7
|
+
import { branchLane, laneGate, type BranchLane, type LaneGate } from "./lane";
|
|
8
|
+
|
|
9
|
+
export type PrLaneGate = LaneGate & { verification: VerificationMode; lane: BranchLane };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The review lane of `head` against the PR's `base` and what the gate asks
|
|
13
|
+
* for it. The checked-out task's judgment raises the lane only when `branch`
|
|
14
|
+
* is checked out here (it describes that work, not another branch's).
|
|
15
|
+
*/
|
|
16
|
+
export function prLaneGate(
|
|
17
|
+
cwd: string,
|
|
18
|
+
check: Pick<VerdictCheck, "accepted" | "selfVerdict">,
|
|
19
|
+
pr: { branch: string; base: string; head: string },
|
|
20
|
+
): PrLaneGate {
|
|
21
|
+
const verification = resolveAutonomy(cwd).verification;
|
|
22
|
+
const lane = branchLane(cwd, {
|
|
23
|
+
base: pr.base,
|
|
24
|
+
head: pr.head,
|
|
25
|
+
judgment: currentBranch(cwd) === pr.branch ? currentJudgment(cwd) : null,
|
|
26
|
+
});
|
|
27
|
+
return { ...laneGate(check, verification, lane), verification, lane };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One line naming the lane and why, for refusals and status. */
|
|
31
|
+
export const laneLabel = (gate: Pick<PrLaneGate, "lane">): string =>
|
|
32
|
+
`lane ${gate.lane.lane} (${gate.lane.reasons.join("; ")})`;
|