@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
package/src/hooks/shell-words.ts
CHANGED
|
@@ -18,6 +18,12 @@ export type Segment = {
|
|
|
18
18
|
pipe?: true;
|
|
19
19
|
/** Text it reads on standard input: heredoc bodies and here-strings. */
|
|
20
20
|
stdin?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Indexes into `words` of the words the shell expands before the command
|
|
23
|
+
* runs: an unquoted `$`, `*`, `?`, `[` or backtick, or a `$(…)`, `<(…)` or
|
|
24
|
+
* double-quoted `$`/backtick. The text of such a word is not what runs.
|
|
25
|
+
*/
|
|
26
|
+
expands?: number[];
|
|
21
27
|
/** Command substitutions in its words (`$(…)`, `` `…` ``, `<(…)`), run with it. */
|
|
22
28
|
subs?: string[];
|
|
23
29
|
/** The command could not be analyzed (too deeply nested): why. The only segment then. */
|
|
@@ -291,6 +297,20 @@ const substitutions = (body: string): string[] => {
|
|
|
291
297
|
const PLAIN_POSIX = /[^\s'"\\`$(){},<>|;&#]+/uy;
|
|
292
298
|
const PLAIN_POWERSHELL = /[^\s'"`(){}<>|;&#]+/uy;
|
|
293
299
|
|
|
300
|
+
/** Does a double-quoted body hold an expansion (`$x`, `$(…)`, a backtick)? */
|
|
301
|
+
const expandsInQuotes = (body: string, dialect: ShellDialect): boolean => {
|
|
302
|
+
const escape = dialect === "posix" ? "\\" : "`";
|
|
303
|
+
for (let index = 0; index < body.length; index++) {
|
|
304
|
+
const char = body[index];
|
|
305
|
+
if (char === escape) index++;
|
|
306
|
+
else if (char === "$" || (dialect === "posix" && char === "`")) return true;
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/** An unquoted glob or variable character in a plain run. */
|
|
312
|
+
const EXPANDING_RUN = /[*?[]/u;
|
|
313
|
+
|
|
294
314
|
/** The `)` of a `(( … ))` arithmetic command starting at `start`, or 0 for a subshell (`((a) )`). */
|
|
295
315
|
const arithmeticEnd = (command: string, start: number): number => {
|
|
296
316
|
const end = substitutionEnd(command, start);
|
|
@@ -316,6 +336,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
316
336
|
let current: Segment = { words: [], redirects: [] };
|
|
317
337
|
let word = "";
|
|
318
338
|
let inWord = false;
|
|
339
|
+
let expanding = false;
|
|
319
340
|
let pending: "redirect" | "input" | "heredoc" | "herestring" | null = null;
|
|
320
341
|
const heredocs: Array<{ delimiter: string; owner: Segment }> = [];
|
|
321
342
|
const escape = posix ? "\\" : "`";
|
|
@@ -327,11 +348,16 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
327
348
|
} else if (pending === "heredoc")
|
|
328
349
|
heredocs.push({ delimiter: text.replace(/^-/, ""), owner: current });
|
|
329
350
|
else if (pending === "herestring") (current.stdin ??= []).push(text);
|
|
330
|
-
else if (pending !== "input")
|
|
351
|
+
else if (pending !== "input") {
|
|
352
|
+
const at = current.words.length;
|
|
331
353
|
current.words.push(...(MARKERS.test(word) ? expandBraces(word) : [word]));
|
|
354
|
+
if (expanding || (!posix && word.startsWith("@")))
|
|
355
|
+
(current.expands ??= []).push(...current.words.slice(at).map((_, i) => at + i));
|
|
356
|
+
}
|
|
332
357
|
pending = null;
|
|
333
358
|
word = "";
|
|
334
359
|
inWord = false;
|
|
360
|
+
expanding = false;
|
|
335
361
|
};
|
|
336
362
|
const flushSegment = () => {
|
|
337
363
|
flushWord();
|
|
@@ -347,6 +373,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
347
373
|
/** A substitution read apart: its command runs, its text stays in the word. */
|
|
348
374
|
const substitution = (inner: string, text: string) => {
|
|
349
375
|
(current.subs ??= []).push(inner);
|
|
376
|
+
expanding = true;
|
|
350
377
|
word += text;
|
|
351
378
|
inWord = true;
|
|
352
379
|
};
|
|
@@ -358,6 +385,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
358
385
|
if (run) {
|
|
359
386
|
word += run[0];
|
|
360
387
|
inWord = true;
|
|
388
|
+
if (EXPANDING_RUN.test(run[0])) expanding = true;
|
|
361
389
|
index += run[0].length - 1;
|
|
362
390
|
continue;
|
|
363
391
|
}
|
|
@@ -383,6 +411,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
383
411
|
const body = command.slice(index + 1, end < 0 ? undefined : end);
|
|
384
412
|
if (posix && char === '"' && /\$\(|`/.test(body))
|
|
385
413
|
(current.subs ??= []).push(...substitutions(body));
|
|
414
|
+
if (char === '"' && expandsInQuotes(body, dialect)) expanding = true;
|
|
386
415
|
word += unquote(body, char, dialect);
|
|
387
416
|
inWord = true;
|
|
388
417
|
index = end < 0 ? command.length : end;
|
|
@@ -428,6 +457,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
428
457
|
if (char === "(" && inWord && word.endsWith("$")) {
|
|
429
458
|
word = word.slice(0, -1);
|
|
430
459
|
inWord = word !== "";
|
|
460
|
+
if (!inWord) expanding = false;
|
|
431
461
|
}
|
|
432
462
|
group(char === "(" ? "open" : "close");
|
|
433
463
|
} else if (!posix && (char === "{" || char === "}")) {
|
|
@@ -474,6 +504,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
474
504
|
} else if (posix && char === "," && word.includes(OPEN)) word += COMMA;
|
|
475
505
|
else if (posix && char === "}" && word.includes(OPEN)) word += CLOSE;
|
|
476
506
|
else {
|
|
507
|
+
if (char === "$") expanding = true;
|
|
477
508
|
word += char;
|
|
478
509
|
inWord = true;
|
|
479
510
|
}
|
package/src/hooks/stop.ts
CHANGED
|
@@ -11,11 +11,15 @@
|
|
|
11
11
|
// or merged;
|
|
12
12
|
// (c) verdict: the session opened the PR (or verified delivery) but the
|
|
13
13
|
// head lacks the verdict the policy asks for, while the endpoint is
|
|
14
|
-
// green or merged (a PR opens without one)
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
14
|
+
// green or merged (a PR opens without one). Under `verification:
|
|
15
|
+
// proportional` (the default) the review lane of the head decides
|
|
16
|
+
// (core/policy/lane.ts): none owes nothing, self the author's own
|
|
17
|
+
// `--self` verdict, focused and full an accepted non-author verdict.
|
|
18
|
+
// Under `self`: the author's own at normal risk; at high risk or under
|
|
19
|
+
// `independent`: a non-author one, and not raised when the task's
|
|
20
|
+
// latest judgment records no behavior change at normal risk
|
|
21
|
+
// (policy/derive.ts asks for none either). Never raised while a
|
|
22
|
+
// verifier or reviewer this lead started still runs.
|
|
19
23
|
// Each obligation blocks once per head and session (a marker keyed by kind
|
|
20
24
|
// and head), not once per turn.
|
|
21
25
|
// A branch that landed owes nothing: a recorded merge (`workit pr merge`, or
|
|
@@ -29,10 +33,9 @@
|
|
|
29
33
|
// the ledger and local git only (no forge call), and every failure allows
|
|
30
34
|
// the stop.
|
|
31
35
|
import { spawnSync } from "node:child_process";
|
|
32
|
-
import { resolveAutonomy } from "../autonomy";
|
|
33
|
-
import {
|
|
34
|
-
import
|
|
35
|
-
import { TaskStore } from "../core/task-store";
|
|
36
|
+
import { resolveAutonomy, type VerificationMode } from "../autonomy";
|
|
37
|
+
import { currentJudgment } from "../core/policy/current-judgment";
|
|
38
|
+
import { branchLane, laneNeed } from "../core/policy/lane";
|
|
36
39
|
import { currentBranch, pushRemoteName } from "../git/rev";
|
|
37
40
|
import { checkVerdicts, readLedger, type ReadRow } from "../ledger";
|
|
38
41
|
import type { HostDescriptor } from "./descriptor";
|
|
@@ -237,43 +240,57 @@ const redChecks = (
|
|
|
237
240
|
};
|
|
238
241
|
};
|
|
239
242
|
|
|
240
|
-
/**
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
243
|
+
/**
|
|
244
|
+
* The base the branch's PR targets: the newest the forge reported (a
|
|
245
|
+
* `pr.status` row, so a PR retargeted on the forge is read against its
|
|
246
|
+
* current base), else the one it was opened with (`pr.created`), else null
|
|
247
|
+
* (the default base). Read from the ledger: the hook never calls the forge.
|
|
248
|
+
*/
|
|
249
|
+
const prBase = (rows: readonly ReadRow[]): string | null => {
|
|
250
|
+
const seen = rows.findLast(
|
|
251
|
+
(row) =>
|
|
252
|
+
(row.type === "pr.status" || row.type === "pr.created") &&
|
|
253
|
+
row.observer === "workit_cli" &&
|
|
254
|
+
typeof row.base === "string",
|
|
255
|
+
);
|
|
256
|
+
return typeof seen?.base === "string" ? seen.base : null;
|
|
255
257
|
};
|
|
256
258
|
|
|
257
259
|
/**
|
|
258
260
|
* (c) Delivered by this session, yet the head lacks the verdict the policy
|
|
259
|
-
* asks for
|
|
260
|
-
*
|
|
261
|
+
* asks for. Under `verification: proportional` the review lane of the head
|
|
262
|
+
* decides (core/policy/lane.ts): none owes nothing, self the author's own,
|
|
263
|
+
* focused and full a non-author one. Under `self`, the author's own at
|
|
264
|
+
* normal risk; under `independent` or at high risk, a non-author one.
|
|
261
265
|
*/
|
|
262
266
|
const unverified = (
|
|
263
267
|
cwd: string,
|
|
264
268
|
branch: string,
|
|
265
269
|
all: readonly ReadRow[],
|
|
266
270
|
descriptor: HostDescriptor,
|
|
267
|
-
verification:
|
|
271
|
+
verification: VerificationMode,
|
|
272
|
+
head: string | null,
|
|
268
273
|
): StopObligation | null => {
|
|
269
274
|
const check = checkVerdicts(cwd, branch, all);
|
|
270
275
|
if (check.accepted.accepted || check.accepted.reasons.includes("failing_verdict")) return null;
|
|
271
|
-
const judgment =
|
|
276
|
+
const judgment = currentJudgment(cwd);
|
|
272
277
|
const high = judgment?.riskTier === "high";
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
278
|
+
let independent: boolean;
|
|
279
|
+
if (verification === "proportional") {
|
|
280
|
+
const lane = branchLane(cwd, {
|
|
281
|
+
base: prBase(all.filter((row) => row.branch === branch)),
|
|
282
|
+
head,
|
|
283
|
+
judgment,
|
|
284
|
+
});
|
|
285
|
+
const need = laneNeed(lane.lane);
|
|
286
|
+
if (need === "none") return null;
|
|
287
|
+
independent = need === "non-author";
|
|
288
|
+
} else {
|
|
289
|
+
// Like policy/derive.ts: a verdict is asked for a behavior change or high
|
|
290
|
+
// risk. A recorded judgment with neither (docs only) owes none.
|
|
291
|
+
if (judgment && !judgment.behaviorChange && !high) return null;
|
|
292
|
+
independent = verification === "independent" || high;
|
|
293
|
+
}
|
|
277
294
|
if (!independent && check.review === "self-reviewed") return null;
|
|
278
295
|
const has = check.review === "self-reviewed" ? "only a self verdict" : "no current verdict";
|
|
279
296
|
const verifier = `${descriptor.subagents.agentPrefix}verifier`;
|
|
@@ -332,7 +349,7 @@ export function stopObligation(
|
|
|
332
349
|
}
|
|
333
350
|
// A PR opens without a verdict: only green and merged need one.
|
|
334
351
|
if (!delivered || (endpoint !== "green" && endpoint !== "merged")) return null;
|
|
335
|
-
const left = unverified(input.cwd, branch, all, descriptor, autonomy.verification);
|
|
352
|
+
const left = unverified(input.cwd, branch, all, descriptor, autonomy.verification, head);
|
|
336
353
|
return left ? { ...left, head } : null;
|
|
337
354
|
} catch {
|
|
338
355
|
return null;
|
|
@@ -182,7 +182,7 @@ const shown = (target: string): string =>
|
|
|
182
182
|
|
|
183
183
|
const unboundDeny = (input: HookInput, target: string): HookDecision => {
|
|
184
184
|
const unblock =
|
|
185
|
-
"Work only in the worktree your brief names, which the lead made with `workit fanout worktree create <slice>`: edit files under it and run one verb per call against it (`workit --cwd <worktree>
|
|
185
|
+
"Work only in the worktree your brief names, which the lead made with `workit fanout worktree create <slice>`: edit files under it and run one verb per call against it (`workit git --cwd <worktree> commit -m … -- <paths>`, `workit check --cwd <worktree> <name>`). If your brief names no worktree, stop and report that to the lead.";
|
|
186
186
|
return {
|
|
187
187
|
kind: "deny",
|
|
188
188
|
reason: `workit worktree confinement: implementer ${input.session.agentId} is not working in a Workit worktree yet, and ${shown(target)} is outside every worktree \`workit fanout worktree create\` made, so it would change the lead's checkout. ${unblock}`,
|
|
@@ -191,7 +191,7 @@ const unboundDeny = (input: HookInput, target: string): HookDecision => {
|
|
|
191
191
|
};
|
|
192
192
|
|
|
193
193
|
const outsideDeny = (input: HookInput, worktree: string, target: string): HookDecision => {
|
|
194
|
-
const unblock = `Edit only under ${worktree} and run git there, one verb per call: \`workit --cwd ${worktree}
|
|
194
|
+
const unblock = `Edit only under ${worktree} and run git there, one verb per call: \`workit git --cwd ${worktree} commit -m … -- <paths>\`, \`workit check --cwd ${worktree} <name>\` (or \`git -C ${worktree} …\`); a command whose writes Workit cannot see is judged by the directory it runs in. Anything outside it is a follow-up for your report.`;
|
|
195
195
|
return {
|
|
196
196
|
kind: "deny",
|
|
197
197
|
reason: `workit worktree confinement: implementer ${input.session.agentId} is bound to the worktree ${worktree}, and ${shown(target)} is outside it. ${unblock}`,
|
|
@@ -201,7 +201,7 @@ const outsideDeny = (input: HookInput, worktree: string, target: string): HookDe
|
|
|
201
201
|
|
|
202
202
|
const unknownDirDeny = (input: HookInput, worktree: string | null): HookDecision => {
|
|
203
203
|
const wt = worktree ?? "<worktree>";
|
|
204
|
-
const unblock = `Workit cannot resolve \`cd -\`, \`cd "$X"\` or \`popd\`: write by absolute path, \`git -C ${wt}\` or \`workit --cwd ${wt}\`.`;
|
|
204
|
+
const unblock = `Workit cannot resolve \`cd -\`, \`cd "$X"\` or \`popd\`: write by absolute path, \`git -C ${wt}\` or \`workit <verb> --cwd ${wt}\`.`;
|
|
205
205
|
return {
|
|
206
206
|
kind: "deny",
|
|
207
207
|
reason: `workit worktree confinement: implementer ${input.session.agentId} writes after an unknown directory change. ${unblock}`,
|
package/src/hooks/write-gate.ts
CHANGED
|
@@ -509,7 +509,7 @@ const segmentTargets = (
|
|
|
509
509
|
}
|
|
510
510
|
const inner = depth < 4 ? (wrappedScript(words) ?? (strict ? shellScript(words) : null)) : null;
|
|
511
511
|
if (inner !== null && dir !== undefined)
|
|
512
|
-
targets.push(...segmentTargets(dir, inner, "posix", depth + 1, strict));
|
|
512
|
+
targets.push(...segmentTargets(workitRunDir(dir, words), inner, "posix", depth + 1, strict));
|
|
513
513
|
if (strict && dir !== undefined) {
|
|
514
514
|
const runIn = dir;
|
|
515
515
|
for (const sub of depth < 4 ? (segment.subs ?? []) : [])
|
|
@@ -550,6 +550,15 @@ const segmentTargets = (
|
|
|
550
550
|
return targets;
|
|
551
551
|
};
|
|
552
552
|
|
|
553
|
+
/** Where `workit check|lock run [--cwd <dir>] -- <cmd>` runs its command: `--cwd` resolved against `dir`. */
|
|
554
|
+
const workitRunDir = (dir: string, words: string[]): string => {
|
|
555
|
+
for (const invocation of workitInvocations(words.map(quoteWord).join(" "), "posix")) {
|
|
556
|
+
const { at, args } = workitCwd(dir, invocation);
|
|
557
|
+
if (args[0] === "check" || (args[0] === "lock" && args[1] === "run")) return at;
|
|
558
|
+
}
|
|
559
|
+
return dir;
|
|
560
|
+
};
|
|
561
|
+
|
|
553
562
|
/** A workit invocation's global `--cwd <dir>` (or `--cwd=<dir>`), resolved against `dir`. */
|
|
554
563
|
const workitCwd = (dir: string, args: string[]): { at: string; args: string[] } => {
|
|
555
564
|
const { rest, cwd } = skipWorkitGlobals(args);
|
package/src/ledger.ts
CHANGED
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
// `base`, the stable patch-id of `base...head` and an exact diff hash. It is
|
|
34
34
|
// `fresh` (same head; verdicts on a dirty worktree are refused, so the head
|
|
35
35
|
// is the judged tree), `carried` (new head, same patch-id AND same exact
|
|
36
|
-
// diff
|
|
36
|
+
// diff AND, for a pass, byte-identical own files the base left alone: a
|
|
37
|
+
// rebase that only moved the base; or a trusted verdict whose
|
|
38
|
+
// follow-up change is lane none, or test-only for a unit verdict, see
|
|
39
|
+
// core/policy/carry.ts) or `stale`.
|
|
37
40
|
// - Integrity (D18): every row the CLI appends carries `prevHash` (sha256 of
|
|
38
41
|
// the previous line) and `rowHash` (sha256 over `prevHash` and the row), a
|
|
39
42
|
// running chain. A row without it after the chain began, or whose hashes do
|
|
@@ -54,8 +57,16 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
54
57
|
import fs from "node:fs";
|
|
55
58
|
import path from "node:path";
|
|
56
59
|
import { hostAwareLockTimeout } from "./core/store-lock";
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
60
|
+
import { verdictDrift, type CarryRule, type Drift } from "./core/policy/carry";
|
|
61
|
+
import { defaultBase } from "./git/default-base";
|
|
62
|
+
import {
|
|
63
|
+
GIT_TIMEOUTS,
|
|
64
|
+
currentBranch,
|
|
65
|
+
headSha,
|
|
66
|
+
patchId,
|
|
67
|
+
sameOwnChange,
|
|
68
|
+
worktreeTree,
|
|
69
|
+
} from "./git/rev";
|
|
59
70
|
import { hostSessionFromEnv, issuedBinding } from "./host-session";
|
|
60
71
|
import { bumpStandingRev } from "./hooks/standing-signal";
|
|
61
72
|
import { resolveStore } from "./store/paths";
|
|
@@ -281,47 +292,8 @@ export function branchHead(cwd: string, branch: string): string | null {
|
|
|
281
292
|
);
|
|
282
293
|
}
|
|
283
294
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
* configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
|
|
287
|
-
* plain workspace default does not override origin's default branch.
|
|
288
|
-
*/
|
|
289
|
-
const trackTrunk = (cwd: string): string | null => {
|
|
290
|
-
try {
|
|
291
|
-
const resolved = vcsConfig("resolve", cwd);
|
|
292
|
-
if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
|
|
293
|
-
return null;
|
|
294
|
-
const target = String(resolved.defaultTargetBranch ?? "").trim();
|
|
295
|
-
return target && safeRef(target) ? target : null;
|
|
296
|
-
} catch {
|
|
297
|
-
return null;
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* The default base for patch-ids and author trailers: the release track's PR
|
|
303
|
-
* target (origin's copy, else the local one) when tracks are configured, else
|
|
304
|
-
* origin's HEAD branch, else the first of origin/main, origin/master, main,
|
|
305
|
-
* master, origin/develop, develop that exists.
|
|
306
|
-
*/
|
|
307
|
-
export function defaultBase(cwd: string): string | null {
|
|
308
|
-
const trunk = trackTrunk(cwd);
|
|
309
|
-
if (trunk)
|
|
310
|
-
for (const candidate of [`origin/${trunk}`, trunk])
|
|
311
|
-
if (resolveCommit(cwd, candidate)) return candidate;
|
|
312
|
-
const originHead = git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]);
|
|
313
|
-
if (originHead && resolveCommit(cwd, originHead)) return originHead;
|
|
314
|
-
for (const candidate of [
|
|
315
|
-
"origin/main",
|
|
316
|
-
"origin/master",
|
|
317
|
-
"main",
|
|
318
|
-
"master",
|
|
319
|
-
"origin/develop",
|
|
320
|
-
"develop",
|
|
321
|
-
])
|
|
322
|
-
if (resolveCommit(cwd, candidate)) return candidate;
|
|
323
|
-
return null;
|
|
324
|
-
}
|
|
295
|
+
// The default base lives in git/default-base.ts (shared with the review lane).
|
|
296
|
+
export { defaultBase };
|
|
325
297
|
|
|
326
298
|
/**
|
|
327
299
|
* sha256 of the exact `base...head` diff with the pinned options rev.ts uses
|
|
@@ -1136,9 +1108,21 @@ export type VerdictEntry = {
|
|
|
1136
1108
|
/** current AND passing AND independent. */
|
|
1137
1109
|
accepted: boolean;
|
|
1138
1110
|
reasons: RejectReason[];
|
|
1111
|
+
/** Why a verdict on an older head still applies after a follow-up change (core/policy/carry.ts). */
|
|
1112
|
+
carry: CarryNote | null;
|
|
1139
1113
|
verdict: ReadRow;
|
|
1140
1114
|
};
|
|
1141
1115
|
|
|
1116
|
+
/** A verdict carried over a follow-up change: by which rule, from which judged head, over which files. */
|
|
1117
|
+
export type CarryNote = { rule: CarryRule; since: string; files: string[] };
|
|
1118
|
+
|
|
1119
|
+
/** A stale verdict whose follow-up change is small and off risk paths: re-verify only these files. */
|
|
1120
|
+
export type Reverify = { scope: "focused"; since: string; files: string[] };
|
|
1121
|
+
|
|
1122
|
+
/** `carried (lane none since <head>)`: how a carried verdict reads in `ledger check` and `pr status`. */
|
|
1123
|
+
export const carryLabel = (carry: CarryNote): string =>
|
|
1124
|
+
`carried (${carry.rule === "lane-none" ? "lane none" : "tests only"} since ${carry.since.slice(0, 12)})`;
|
|
1125
|
+
|
|
1142
1126
|
export type VerdictCheck = {
|
|
1143
1127
|
branch: string;
|
|
1144
1128
|
head: string | null;
|
|
@@ -1146,6 +1130,10 @@ export type VerdictCheck = {
|
|
|
1146
1130
|
patchId: string | null;
|
|
1147
1131
|
/** Newest current verdict (else newest verdict): does it still apply? */
|
|
1148
1132
|
current: { basis: VerdictBasis; verdict: ReadRow | null };
|
|
1133
|
+
/** The carry of the accepted verdict (else of the newest current one), when a follow-up change carried it. */
|
|
1134
|
+
carry: CarryNote | null;
|
|
1135
|
+
/** No verdict applies, but the newest passing one is stale only by a small change: re-verify that. */
|
|
1136
|
+
reverify: Reverify | null;
|
|
1149
1137
|
/**
|
|
1150
1138
|
* What merge gates read: an accepted strong (`verified`/`tests-verified`)
|
|
1151
1139
|
* verdict and no current independent failure. `type-check-only` never counts.
|
|
@@ -1276,7 +1264,7 @@ const derivesFrom = (derived: string, bare: string): boolean =>
|
|
|
1276
1264
|
* child that committed as `<lead>:<child>` and judges as `<child>` is still
|
|
1277
1265
|
* the author). Two derived ids never match on a shared last segment.
|
|
1278
1266
|
*/
|
|
1279
|
-
const authoredBy = (authors: Set<string>, session: string): boolean => {
|
|
1267
|
+
export const authoredBy = (authors: Set<string>, session: string): boolean => {
|
|
1280
1268
|
if (authors.has(session)) return true;
|
|
1281
1269
|
for (const author of authors)
|
|
1282
1270
|
if (derivesFrom(author, session) || derivesFrom(session, author)) return true;
|
|
@@ -1414,15 +1402,39 @@ function effectiveOf(
|
|
|
1414
1402
|
* each restack itself and records `stack.restacked` with `patchEqual` when
|
|
1415
1403
|
* the change relative to the parent is identical (same patch-id AND same
|
|
1416
1404
|
* exact diff hash, D18). A verdict carries when such observed rows link its
|
|
1417
|
-
* head to the current head.
|
|
1405
|
+
* head to the current head. With `exactIn` (the checkout), each link is
|
|
1406
|
+
* re-read with exact blobs (sameOwnChange, #334), so a row recorded before
|
|
1407
|
+
* that check carries a pass only when the branch's own files are identical
|
|
1408
|
+
* across the restack. Null keeps every observed link: a failure keeps
|
|
1409
|
+
* applying through them (failing closed the other way).
|
|
1418
1410
|
*/
|
|
1411
|
+
const restackExact = new WeakMap<ReadRow, boolean>();
|
|
1419
1412
|
function restackCarries(
|
|
1413
|
+
exactIn: string | null,
|
|
1420
1414
|
rows: readonly ReadRow[],
|
|
1421
1415
|
branch: string,
|
|
1422
1416
|
from: string | null,
|
|
1423
1417
|
to: string | null,
|
|
1424
1418
|
): boolean {
|
|
1425
1419
|
if (!from || !to) return false;
|
|
1420
|
+
const exact = (row: ReadRow, fromHead: string, head: string): boolean => {
|
|
1421
|
+
if (exactIn === null) return true;
|
|
1422
|
+
const cwd = exactIn;
|
|
1423
|
+
let known = restackExact.get(row);
|
|
1424
|
+
if (known === undefined) {
|
|
1425
|
+
known =
|
|
1426
|
+
typeof row.fromBase === "string" &&
|
|
1427
|
+
typeof row.toBase === "string" &&
|
|
1428
|
+
sameOwnChange(cwd, {
|
|
1429
|
+
from: fromHead,
|
|
1430
|
+
to: head,
|
|
1431
|
+
fromBase: row.fromBase,
|
|
1432
|
+
toBase: row.toBase,
|
|
1433
|
+
});
|
|
1434
|
+
restackExact.set(row, known);
|
|
1435
|
+
}
|
|
1436
|
+
return known;
|
|
1437
|
+
};
|
|
1426
1438
|
const edges = new Map<string, string[]>();
|
|
1427
1439
|
for (const row of rows)
|
|
1428
1440
|
if (
|
|
@@ -1432,7 +1444,8 @@ function restackCarries(
|
|
|
1432
1444
|
row.branch === branch &&
|
|
1433
1445
|
row.patchEqual === true &&
|
|
1434
1446
|
typeof row.fromHead === "string" &&
|
|
1435
|
-
row.head
|
|
1447
|
+
row.head &&
|
|
1448
|
+
exact(row, row.fromHead, row.head)
|
|
1436
1449
|
)
|
|
1437
1450
|
edges.set(row.fromHead, [...(edges.get(row.fromHead) ?? []), row.head]);
|
|
1438
1451
|
const seen = new Set<string>([from]);
|
|
@@ -1471,9 +1484,73 @@ export function checkVerdicts(
|
|
|
1471
1484
|
return key;
|
|
1472
1485
|
};
|
|
1473
1486
|
const keyFor = (base: string | null) => keysAt(base, head);
|
|
1474
|
-
|
|
1487
|
+
// The branch's own change since a trusted verdict's head, read once per
|
|
1488
|
+
// row (#334). A row the gates cannot trust never carries.
|
|
1489
|
+
const driftCache = new Map<ReadRow, Drift | null>();
|
|
1490
|
+
const driftOf = (row: ReadRow): Drift | null => {
|
|
1491
|
+
if (driftCache.has(row)) return driftCache.get(row) ?? null;
|
|
1492
|
+
let drift: Drift | null = null;
|
|
1493
|
+
const toBase = row.base ?? fallbackBase;
|
|
1494
|
+
const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
|
|
1495
|
+
const fromBase = row.baseSha ?? toBaseSha;
|
|
1496
|
+
if (
|
|
1497
|
+
row.head &&
|
|
1498
|
+
head &&
|
|
1499
|
+
row.head !== head &&
|
|
1500
|
+
toBaseSha &&
|
|
1501
|
+
fromBase &&
|
|
1502
|
+
!untrusted(reasonsOf(row))
|
|
1503
|
+
)
|
|
1504
|
+
drift = verdictDrift(cwd, {
|
|
1505
|
+
from: row.head,
|
|
1506
|
+
to: head,
|
|
1507
|
+
fromBase,
|
|
1508
|
+
toBase: toBaseSha,
|
|
1509
|
+
kind: typeof row.kind === "string" ? row.kind : "review",
|
|
1510
|
+
});
|
|
1511
|
+
driftCache.set(row, drift);
|
|
1512
|
+
return drift;
|
|
1513
|
+
};
|
|
1514
|
+
// The same patch-id and diff hash are not enough to carry a pass (#334):
|
|
1515
|
+
// both drop hunk line numbers, so an edit moved within a file keeps them.
|
|
1516
|
+
// The branch's own files must be identical blobs too (sameOwnChange). A
|
|
1517
|
+
// failure keeps the looser carry: it fails closed by applying more.
|
|
1518
|
+
const exactCache = new Map<ReadRow, boolean>();
|
|
1519
|
+
const exactCarry = (row: ReadRow): boolean => {
|
|
1520
|
+
if (FAILING.has(String(row.result))) return true;
|
|
1521
|
+
let exact = exactCache.get(row);
|
|
1522
|
+
if (exact === undefined) {
|
|
1523
|
+
const toBase = row.base ?? fallbackBase;
|
|
1524
|
+
const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
|
|
1525
|
+
const fromBase = row.baseSha ?? toBaseSha;
|
|
1526
|
+
exact = Boolean(
|
|
1527
|
+
row.head &&
|
|
1528
|
+
head &&
|
|
1529
|
+
toBaseSha &&
|
|
1530
|
+
fromBase &&
|
|
1531
|
+
sameOwnChange(cwd, { from: row.head, to: head, fromBase, toBase: toBaseSha }),
|
|
1532
|
+
);
|
|
1533
|
+
exactCache.set(row, exact);
|
|
1534
|
+
}
|
|
1535
|
+
return exact;
|
|
1536
|
+
};
|
|
1537
|
+
const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
|
|
1475
1538
|
const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
|
|
1476
|
-
return basis === "
|
|
1539
|
+
return basis === "carried" && !exactCarry(row) ? "stale" : basis;
|
|
1540
|
+
};
|
|
1541
|
+
const restacked = (row: ReadRow): boolean =>
|
|
1542
|
+
restackCarries(FAILING.has(String(row.result)) ? null : cwd, rows, branch, row.head, head);
|
|
1543
|
+
/** The follow-up carry of a verdict the exact keys and restacks do not carry. */
|
|
1544
|
+
const carryOf = (row: ReadRow): CarryNote | null => {
|
|
1545
|
+
if (keyBasis(row) !== "stale") return null;
|
|
1546
|
+
if (restacked(row)) return null;
|
|
1547
|
+
const drift = driftOf(row);
|
|
1548
|
+
return drift?.carries ? { rule: drift.rule, since: drift.since, files: drift.files } : null;
|
|
1549
|
+
};
|
|
1550
|
+
const basisOf = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
|
|
1551
|
+
const basis = keyBasis(row);
|
|
1552
|
+
if (basis !== "stale") return basis;
|
|
1553
|
+
return restacked(row) || carryOf(row) ? "carried" : basis;
|
|
1477
1554
|
};
|
|
1478
1555
|
// A newer row is judged under the failure's own base, so a row recorded
|
|
1479
1556
|
// with a different --base cannot make unchanged code look changed. When
|
|
@@ -1487,7 +1564,7 @@ export function checkVerdicts(
|
|
|
1487
1564
|
const keys = stored ? { patchId: row.patchId, diffHash: row.diffHash } : keysAt(base, row.head);
|
|
1488
1565
|
return (
|
|
1489
1566
|
verdictBasis(failed, { head: row.head, ...keys }) !== "stale" ||
|
|
1490
|
-
restackCarries(rows, branch, failed.head, row.head)
|
|
1567
|
+
restackCarries(null, rows, branch, failed.head, row.head)
|
|
1491
1568
|
);
|
|
1492
1569
|
};
|
|
1493
1570
|
const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head }, scope);
|
|
@@ -1566,6 +1643,7 @@ export function checkVerdicts(
|
|
|
1566
1643
|
independent: independence.length === 0,
|
|
1567
1644
|
accepted: reasons.length === 0,
|
|
1568
1645
|
reasons,
|
|
1646
|
+
carry: basis === "carried" ? carryOf(row) : null,
|
|
1569
1647
|
verdict: row,
|
|
1570
1648
|
});
|
|
1571
1649
|
// The author's own failure never displaces an independent pass, but it
|
|
@@ -1622,15 +1700,38 @@ export function checkVerdicts(
|
|
|
1622
1700
|
? newestCurrent.reasons
|
|
1623
1701
|
: (["type_check_only"] as RejectReason[]),
|
|
1624
1702
|
};
|
|
1625
|
-
|
|
1703
|
+
// The author's latest pass or failure on the current code wins, across
|
|
1704
|
+
// kinds: a newer failure cancels an older pass (review finding mvkcoxgb).
|
|
1705
|
+
const latestSelf = failing
|
|
1626
1706
|
? undefined
|
|
1627
1707
|
: verdicts.findLast(
|
|
1628
1708
|
(entry) =>
|
|
1629
1709
|
entry.current &&
|
|
1630
1710
|
!entry.independent &&
|
|
1631
1711
|
!untrusted(entry.reasons) &&
|
|
1632
|
-
STRONG_RESULTS.has(String(entry.verdict.result))
|
|
1712
|
+
(STRONG_RESULTS.has(String(entry.verdict.result)) ||
|
|
1713
|
+
FAILING.has(String(entry.verdict.result))),
|
|
1633
1714
|
);
|
|
1715
|
+
const selfEntry =
|
|
1716
|
+
latestSelf && STRONG_RESULTS.has(String(latestSelf.verdict.result)) ? latestSelf : undefined;
|
|
1717
|
+
const carryEntry =
|
|
1718
|
+
verdicts.find((entry) => entry.verdict === accepted.verdict && entry.accepted) ?? newestCurrent;
|
|
1719
|
+
// A focused re-verify: nothing applies now, and the newest trusted pass is
|
|
1720
|
+
// stale only by a small change off risk paths (the drift's lane is self).
|
|
1721
|
+
const lastPass =
|
|
1722
|
+
accepted.accepted || failing || tampered.length
|
|
1723
|
+
? undefined
|
|
1724
|
+
: verdicts.findLast(
|
|
1725
|
+
(entry) =>
|
|
1726
|
+
!entry.current &&
|
|
1727
|
+
!untrusted(entry.reasons) &&
|
|
1728
|
+
STRONG_RESULTS.has(String(entry.verdict.result)),
|
|
1729
|
+
);
|
|
1730
|
+
const drift = lastPass ? driftOf(lastPass.verdict) : null;
|
|
1731
|
+
const reverify: Reverify | null =
|
|
1732
|
+
drift && !drift.carries && drift.focused && drift.files
|
|
1733
|
+
? { scope: "focused", since: drift.since, files: drift.files }
|
|
1734
|
+
: null;
|
|
1634
1735
|
return {
|
|
1635
1736
|
branch,
|
|
1636
1737
|
head,
|
|
@@ -1639,6 +1740,8 @@ export function checkVerdicts(
|
|
|
1639
1740
|
base: newestCurrent?.verdict.base ?? fallbackBase,
|
|
1640
1741
|
patchId: keyFor(newestCurrent?.verdict.base ?? fallbackBase).patchId,
|
|
1641
1742
|
current: { basis: newestCurrent?.basis ?? "none", verdict: newestCurrent?.verdict ?? null },
|
|
1743
|
+
carry: carryEntry?.carry ?? null,
|
|
1744
|
+
reverify,
|
|
1642
1745
|
accepted,
|
|
1643
1746
|
verdicts,
|
|
1644
1747
|
authors: [...authors].toSorted(),
|