@jwilger/pi-development-system 0.46.1 → 0.48.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/extensions/development-system.ts +2 -0
- package/package.json +1 -1
- package/skills/strict-lints/SKILL.md +18 -0
- package/src/context/turn-verifier.ts +47 -31
- package/src/core/git-intent.ts +6 -1
- package/src/core/push-command.ts +12 -7
- package/src/core/review-packet.ts +47 -39
- package/src/core/test-paths.ts +11 -13
- package/src/core/test-runner.ts +13 -10
- package/src/core/test-weakening.ts +3 -1
- package/src/gates/departure-use.ts +33 -15
- package/src/gates/lint-suppression-guard.ts +5 -10
- package/src/gates/red-first-guard.ts +5 -10
- package/src/gates/test-guard.ts +107 -100
- package/src/review/digest.ts +48 -29
- package/src/review/review-tools.ts +183 -114
- package/src/subagents/prefs/config.ts +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// pi-lens-ignore: high-import-coupling -- composition root: it imports every module it wires, by design
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { cadenceLine, DEFAULT_PUSH_MINUTES } from "../src/context/cadence.ts";
|
|
@@ -34,6 +35,7 @@ const nonNegotiables = readFileSync(
|
|
|
34
35
|
);
|
|
35
36
|
|
|
36
37
|
/** Composition root: wires modules, holds no logic. */
|
|
38
|
+
// pi-lens-ignore: high-fan-out -- composition root: wiring every module is its whole job
|
|
37
39
|
export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
38
40
|
const state = createSessionState(pi);
|
|
39
41
|
const approvals = createApprovalStore(pi);
|
package/package.json
CHANGED
|
@@ -24,6 +24,23 @@ finding from every later reader, so it must carry its reason.
|
|
|
24
24
|
clippy; biome recommended-plus; TypeScript `strict`. Loosening a project rule is a
|
|
25
25
|
recorded decision, not an edit.
|
|
26
26
|
|
|
27
|
+
## No warning stands
|
|
28
|
+
|
|
29
|
+
A warning is an error that has not been triaged yet. Before every commit:
|
|
30
|
+
|
|
31
|
+
- Biome, `tsc`, knip and markdownlint pass with nothing reported (lefthook and CI run them).
|
|
32
|
+
- `lens_diagnostics` (pi-lens) shows no warnings for the files you changed. Run it with
|
|
33
|
+
`mode=full` on those paths after a refresh; a cached finding that looks wrong is a stale
|
|
34
|
+
cache to refresh (touch the file and re-run), never one to wave off.
|
|
35
|
+
- Each finding is fixed, or suppressed on the line above with a reason
|
|
36
|
+
(`// pi-lens-ignore: high-fan-out -- composition root: wiring is its job`).
|
|
37
|
+
- Enable stricter rules by the behaviour they prevent (silent failure, panics, shadowing,
|
|
38
|
+
unreasoned `allow`), not whole categories that contradict each other. Warn while
|
|
39
|
+
working; deny in CI.
|
|
40
|
+
- Vendored code under `src/subagents` is on a ratchet: `test/subagents/nocheck-files.ts`
|
|
41
|
+
lists the files still skipping type checks and may only shrink. A file you touch there
|
|
42
|
+
is made clean and dropped from the list.
|
|
43
|
+
|
|
27
44
|
## What the extension does
|
|
28
45
|
|
|
29
46
|
Editing a file so that it adds `#[allow(`, `#[expect(`, `// biome-ignore`,
|
|
@@ -44,3 +61,4 @@ departure naming why a bare one is right. Suppressions already in the file are n
|
|
|
44
61
|
- [ ] Suppression is one line with a reason of 15+ characters
|
|
45
62
|
- [ ] `expect` used where available
|
|
46
63
|
- [ ] No lint rule loosened in project config without a recorded decision
|
|
64
|
+
- [ ] `lens_diagnostics` shows no warnings on the changed files (refreshed, not cached)
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
ToolResultEvent,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
3
7
|
import { redactSecrets } from "../core/redact.ts";
|
|
4
8
|
import { exitCodeOf, summarizeOutput } from "../core/test-runner.ts";
|
|
5
9
|
import type { DevsysState, Phase } from "../core/types.ts";
|
|
@@ -77,6 +81,46 @@ const note = (content: string) => ({
|
|
|
77
81
|
display: true,
|
|
78
82
|
});
|
|
79
83
|
|
|
84
|
+
/** What a finished tool call proves, for Jev: the target, the output tail and (for bash) the real exit code. */
|
|
85
|
+
function evidenceOf(event: ToolResultEvent): ToolEvidence {
|
|
86
|
+
const text = event.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n");
|
|
87
|
+
const summary = summarizeOutput(text);
|
|
88
|
+
const command = typeof event.input.command === "string" ? event.input.command : undefined;
|
|
89
|
+
// exitCodeOf sees through `npm test | tail`, which would otherwise show a failing run as exit 0.
|
|
90
|
+
const exitCode = exitCodeOf({
|
|
91
|
+
isError: event.isError,
|
|
92
|
+
text,
|
|
93
|
+
structured: event.structuredContent,
|
|
94
|
+
...(command === undefined ? {} : { command }),
|
|
95
|
+
});
|
|
96
|
+
// Name what ran or which file: a check that passes silently (`tsc --noEmit`) prints nothing.
|
|
97
|
+
const target = redactSecrets(
|
|
98
|
+
command ?? (typeof event.input.path === "string" ? event.input.path : ""),
|
|
99
|
+
)
|
|
100
|
+
.replace(/\s+/g, " ")
|
|
101
|
+
.slice(0, TARGET_MAX);
|
|
102
|
+
const described = target === "" ? summary : `${target} → ${summary}`;
|
|
103
|
+
return event.toolName === "bash"
|
|
104
|
+
? { tool: "bash", summary: described, exitCode }
|
|
105
|
+
: { tool: event.toolName, summary: described };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The corrective notes a judged turn earns: an unverified claim, and drift not yet covered by a departure. */
|
|
109
|
+
function correctionsFor(
|
|
110
|
+
judged: { unverifiedClaim: number; driftFromSlice: number },
|
|
111
|
+
state: DevsysState,
|
|
112
|
+
) {
|
|
113
|
+
const slice = state.activeSlice;
|
|
114
|
+
const drifted =
|
|
115
|
+
judged.driftFromSlice >= VERIFIER_THRESHOLD &&
|
|
116
|
+
slice !== undefined &&
|
|
117
|
+
!expansionRecorded(state, slice);
|
|
118
|
+
return [
|
|
119
|
+
...(judged.unverifiedClaim >= VERIFIER_THRESHOLD ? [note(claimMessage())] : []),
|
|
120
|
+
...(drifted && slice !== undefined ? [note(driftMessage(slice))] : []),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
80
124
|
/**
|
|
81
125
|
* At turn end, asks Jev whether the final message claims results no tool call supports, or wanders
|
|
82
126
|
* from the active slice, and forces ONE corrective continuation. Bounded: never on a turn that
|
|
@@ -98,28 +142,7 @@ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
|
|
|
98
142
|
justCorrected = false;
|
|
99
143
|
});
|
|
100
144
|
deps.pi.on("tool_result", (event) => {
|
|
101
|
-
|
|
102
|
-
const summary = summarizeOutput(text);
|
|
103
|
-
const command = typeof event.input.command === "string" ? event.input.command : undefined;
|
|
104
|
-
// exitCodeOf sees through `npm test | tail`, which would otherwise show a failing run as exit 0.
|
|
105
|
-
const exitCode = exitCodeOf({
|
|
106
|
-
isError: event.isError,
|
|
107
|
-
text,
|
|
108
|
-
structured: event.structuredContent,
|
|
109
|
-
...(command === undefined ? {} : { command }),
|
|
110
|
-
});
|
|
111
|
-
// Name what ran or which file: a check that passes silently (`tsc --noEmit`) prints nothing.
|
|
112
|
-
const target = redactSecrets(
|
|
113
|
-
command ?? (typeof event.input.path === "string" ? event.input.path : ""),
|
|
114
|
-
)
|
|
115
|
-
.replace(/\s+/g, " ")
|
|
116
|
-
.slice(0, TARGET_MAX);
|
|
117
|
-
const described = target === "" ? summary : `${target} → ${summary}`;
|
|
118
|
-
const item: ToolEvidence =
|
|
119
|
-
event.toolName === "bash"
|
|
120
|
-
? { tool: "bash", summary: described, exitCode }
|
|
121
|
-
: { tool: event.toolName, summary: described };
|
|
122
|
-
evidence = [...evidence, item].slice(-MAX_EVIDENCE);
|
|
145
|
+
evidence = [...evidence, evidenceOf(event)].slice(-MAX_EVIDENCE);
|
|
123
146
|
});
|
|
124
147
|
|
|
125
148
|
deps.pi.on("turn_end", async (event, ctx) => {
|
|
@@ -142,14 +165,7 @@ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
|
|
|
142
165
|
...(state.activeSlice === undefined ? {} : { activeSlice: state.activeSlice }),
|
|
143
166
|
});
|
|
144
167
|
if (!judged.ok) return undefined;
|
|
145
|
-
const entries =
|
|
146
|
-
...(judged.value.unverifiedClaim >= VERIFIER_THRESHOLD ? [note(claimMessage())] : []),
|
|
147
|
-
...(judged.value.driftFromSlice >= VERIFIER_THRESHOLD &&
|
|
148
|
-
state.activeSlice !== undefined &&
|
|
149
|
-
!expansionRecorded(state, state.activeSlice)
|
|
150
|
-
? [note(driftMessage(state.activeSlice))]
|
|
151
|
-
: []),
|
|
152
|
-
];
|
|
168
|
+
const entries = correctionsFor(judged.value, state);
|
|
153
169
|
if (entries.length === 0) return undefined;
|
|
154
170
|
corrected += 1;
|
|
155
171
|
justCorrected = true;
|
package/src/core/git-intent.ts
CHANGED
|
@@ -24,7 +24,8 @@ const worst = (a: GitIntent, b: GitIntent): GitIntent =>
|
|
|
24
24
|
SEVERITY.indexOf(a) <= SEVERITY.indexOf(b) ? a : b;
|
|
25
25
|
|
|
26
26
|
/** Splits a command string on newlines that are outside quotes; backslash-newline is a continuation. */
|
|
27
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
|
|
27
|
+
// biome-ignore-start lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
|
|
28
|
+
// pi-lens-ignore: high-complexity -- a shell lexer is one flat branch per character class; splitting it would scatter one state machine across helpers
|
|
28
29
|
export function splitLines(command: string): string[] {
|
|
29
30
|
const lines: string[] = [];
|
|
30
31
|
let current = "";
|
|
@@ -56,6 +57,7 @@ export function splitLines(command: string): string[] {
|
|
|
56
57
|
lines.push(current);
|
|
57
58
|
return lines;
|
|
58
59
|
}
|
|
60
|
+
// biome-ignore-end lint/complexity/noExcessiveCognitiveComplexity: end of the shell lexer above
|
|
59
61
|
|
|
60
62
|
const isRedirect = (op: string): boolean => /^[<>]/.test(op) && !op.endsWith("(");
|
|
61
63
|
|
|
@@ -216,6 +218,7 @@ function splitGlobals(rest: string[]): {
|
|
|
216
218
|
return { globals, sub: rest[j], args: rest.slice(j + 1) };
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
// pi-lens-ignore: high-fan-out -- the classifier dispatches one git word to many small predicates; it is a table, not coordination
|
|
219
222
|
function classifySegment(tokens: string[]): GitIntent {
|
|
220
223
|
const { index: i, hookSkip } = skipPrefix(tokens);
|
|
221
224
|
if (i < 0) return "ordinary";
|
|
@@ -251,6 +254,7 @@ function classifySegment(tokens: string[]): GitIntent {
|
|
|
251
254
|
* The parts of a line bash may still execute or treat as syntax: single-quoted text and
|
|
252
255
|
* backslash-escaped characters are dropped; double-quoted text is kept unless `dropDouble`.
|
|
253
256
|
*/
|
|
257
|
+
// pi-lens-ignore: high-complexity -- a shell lexer is one flat branch per character class; splitting it would scatter one state machine across helpers
|
|
254
258
|
export function live(line: string, dropDouble: boolean): string {
|
|
255
259
|
let out = "";
|
|
256
260
|
let quote: "'" | '"' | undefined;
|
|
@@ -292,6 +296,7 @@ export function substitutions(text: string): string[] {
|
|
|
292
296
|
const HEREDOC = /^<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/;
|
|
293
297
|
|
|
294
298
|
/** The delimiter of a heredoc started by an unquoted `<<` on this line, if any. */
|
|
299
|
+
// pi-lens-ignore: high-complexity -- a shell lexer is one flat branch per character class; splitting it would scatter one state machine across helpers
|
|
295
300
|
export function heredocDelimiter(line: string): string | undefined {
|
|
296
301
|
let quote: "'" | '"' | undefined;
|
|
297
302
|
for (let i = 0; i < line.length; i++) {
|
package/src/core/push-command.ts
CHANGED
|
@@ -34,18 +34,23 @@ type ParsedArgs = {
|
|
|
34
34
|
|
|
35
35
|
const VALUE_PREFIX = "--repo=";
|
|
36
36
|
|
|
37
|
+
function applyFlag(parsed: ParsedArgs, a: string): void {
|
|
38
|
+
if (a.startsWith(VALUE_PREFIX)) parsed.repo = a.slice(VALUE_PREFIX.length);
|
|
39
|
+
else if (a === "--all" || a === "--mirror") parsed.all = true;
|
|
40
|
+
else if (a === "--tags") parsed.tags = true;
|
|
41
|
+
else if (!a.startsWith("-")) parsed.positional.push(a);
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
38
45
|
const parsed: ParsedArgs = { positional: [], all: false, tags: false, repo: undefined };
|
|
39
46
|
let valueFor: "repo" | "skip" | undefined;
|
|
40
47
|
for (const a of args) {
|
|
41
|
-
if (valueFor
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
else if (a
|
|
48
|
+
if (valueFor !== undefined) {
|
|
49
|
+
if (valueFor === "repo") parsed.repo = a;
|
|
50
|
+
valueFor = undefined;
|
|
51
|
+
} else if (a === "--repo") valueFor = "repo";
|
|
45
52
|
else if (VALUE_OPTIONS.has(a)) valueFor = "skip";
|
|
46
|
-
else
|
|
47
|
-
else if (a === "--tags") parsed.tags = true;
|
|
48
|
-
else if (!a.startsWith("-")) parsed.positional.push(a);
|
|
53
|
+
else applyFlag(parsed, a);
|
|
49
54
|
}
|
|
50
55
|
return parsed;
|
|
51
56
|
}
|
|
@@ -64,6 +64,45 @@ function parseFinding(line: string, lens: string[], index: number): Finding | Pa
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
const FINDINGS_MISSING =
|
|
68
|
+
'missing "### Findings" section: write "- none" under it when there are no findings';
|
|
69
|
+
const NO_FINDINGS = /^(?:-\s*)?[([]?(?:none|no findings?)[)\]]?\.?$/i;
|
|
70
|
+
|
|
71
|
+
/** The packet's verdict word, or the parse error explaining what is wrong with it. */
|
|
72
|
+
function verdictOf(text: string): "blocking" | "no-blocking" | ParseError {
|
|
73
|
+
const word = section(text, "Verdict")?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
74
|
+
if (word === "no-blocking" || word === "blocking") return word;
|
|
75
|
+
return parseError(
|
|
76
|
+
'missing or invalid verdict: end the packet with "### Verdict" then no-blocking or blocking',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every finding line of the Findings section; a line that is not a finding is an error, never dropped. */
|
|
81
|
+
function findingsOf(text: string, lenses: string[]): Finding[] | ParseError {
|
|
82
|
+
const body = section(text, "Findings", AFTER_FINDINGS);
|
|
83
|
+
if (body === undefined) return parseError(FINDINGS_MISSING);
|
|
84
|
+
const lines = body
|
|
85
|
+
.split("\n")
|
|
86
|
+
.flatMap((l) => (isDetail(l) ? [] : [l.trim()]))
|
|
87
|
+
.filter((l) => l !== "" && !NO_FINDINGS.test(l));
|
|
88
|
+
const findings: Finding[] = [];
|
|
89
|
+
for (const [i, line] of lines.entries()) {
|
|
90
|
+
const finding = parseFinding(line, lenses, i + 1);
|
|
91
|
+
if (isParseError(finding)) return finding;
|
|
92
|
+
findings.push(finding);
|
|
93
|
+
}
|
|
94
|
+
return findings;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isBlocking = (f: Finding): boolean =>
|
|
98
|
+
f.severity === "blocking" || f.severity === "should-fix";
|
|
99
|
+
|
|
100
|
+
const listOf = (text: string): string[] =>
|
|
101
|
+
text
|
|
102
|
+
.split(",")
|
|
103
|
+
.map((l) => l.trim())
|
|
104
|
+
.filter((l) => l !== "");
|
|
105
|
+
|
|
67
106
|
/** Parse the reviewer packet of plan Appendix A. Strict about structure, tolerant of dash style. */
|
|
68
107
|
export function parseReviewPacket(text: string): ReviewPacket | ParseError {
|
|
69
108
|
const header = HEADER.exec(text);
|
|
@@ -74,50 +113,19 @@ export function parseReviewPacket(text: string): ReviewPacket | ParseError {
|
|
|
74
113
|
if (!Number.isInteger(round) || round < 1) {
|
|
75
114
|
return parseError(`round "${header[2]}" is not a positive integer`);
|
|
76
115
|
}
|
|
77
|
-
const lenses = (header[3] ?? "")
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
return parseError(
|
|
84
|
-
'missing or invalid verdict: end the packet with "### Verdict" then no-blocking or blocking',
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
const findings: Finding[] = [];
|
|
88
|
-
const findingsText = section(text, "Findings", AFTER_FINDINGS);
|
|
89
|
-
if (findingsText === undefined) {
|
|
116
|
+
const lenses = listOf(header[3] ?? "");
|
|
117
|
+
const verdict = verdictOf(text);
|
|
118
|
+
if (isParseError(verdict)) return verdict;
|
|
119
|
+
const findings = findingsOf(text, lenses);
|
|
120
|
+
if (isParseError(findings)) return findings;
|
|
121
|
+
if (findings.some(isBlocking) !== (verdict === "blocking")) {
|
|
90
122
|
return parseError(
|
|
91
|
-
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
const lines = findingsText
|
|
95
|
-
.split("\n")
|
|
96
|
-
.flatMap((l) => (isDetail(l) ? [] : [l.trim()]))
|
|
97
|
-
.filter((l) => l !== "" && !/^(?:-\s*)?[([]?(?:none|no findings?)[)\]]?\.?$/i.test(l));
|
|
98
|
-
for (const [i, line] of lines.entries()) {
|
|
99
|
-
const finding = parseFinding(line, lenses, i + 1);
|
|
100
|
-
if (isParseError(finding)) return finding;
|
|
101
|
-
findings.push(finding);
|
|
102
|
-
}
|
|
103
|
-
const hasBlocking = findings.some(
|
|
104
|
-
(f) => f.severity === "blocking" || f.severity === "should-fix",
|
|
105
|
-
);
|
|
106
|
-
if (hasBlocking !== (verdictText === "blocking")) {
|
|
107
|
-
return parseError(
|
|
108
|
-
`verdict "${verdictText}" contradicts the findings: blocking/should-fix findings mean "blocking", otherwise "no-blocking"`,
|
|
123
|
+
`verdict "${verdict}" contradicts the findings: blocking/should-fix findings mean "blocking", otherwise "no-blocking"`,
|
|
109
124
|
);
|
|
110
125
|
}
|
|
111
126
|
const sources = (section(text, "Sources inspected") ?? "")
|
|
112
127
|
.split("\n")
|
|
113
128
|
.map((l) => l.replace(/^-\s*/, "").trim())
|
|
114
129
|
.filter((l) => l !== "");
|
|
115
|
-
return {
|
|
116
|
-
slice: (header[1] ?? "").trim(),
|
|
117
|
-
round,
|
|
118
|
-
lenses,
|
|
119
|
-
sources,
|
|
120
|
-
findings,
|
|
121
|
-
verdict: verdictText,
|
|
122
|
-
};
|
|
130
|
+
return { slice: (header[1] ?? "").trim(), round, lenses, sources, findings, verdict };
|
|
123
131
|
}
|
package/src/core/test-paths.ts
CHANGED
|
@@ -18,22 +18,20 @@ const PATTERNS: readonly RegExp[] = [
|
|
|
18
18
|
/_tests\.rs$/,
|
|
19
19
|
];
|
|
20
20
|
|
|
21
|
+
/** Whether `rest` matches some suffix of `path`; a `*` may not cross a `/` when `withinSegment`. */
|
|
22
|
+
function matchesSuffix(rest: string, path: string, withinSegment: boolean): boolean {
|
|
23
|
+
for (let i = 0; i <= path.length; i++) {
|
|
24
|
+
if (matchGlob(rest, path.slice(i))) return true;
|
|
25
|
+
if (withinSegment && (path[i] === "/" || i === path.length)) return false;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
/** Glob match without building a RegExp: `**` spans directories, `*` stays within a segment. */
|
|
22
31
|
function matchGlob(glob: string, path: string): boolean {
|
|
23
32
|
if (glob === "") return path === "";
|
|
24
|
-
if (glob.startsWith("**"))
|
|
25
|
-
|
|
26
|
-
for (let i = 0; i <= path.length; i++) if (matchGlob(rest, path.slice(i))) return true;
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
if (glob.startsWith("*")) {
|
|
30
|
-
const rest = glob.slice(1);
|
|
31
|
-
for (let i = 0; i <= path.length; i++) {
|
|
32
|
-
if (matchGlob(rest, path.slice(i))) return true;
|
|
33
|
-
if (path[i] === "/" || i === path.length) return false;
|
|
34
|
-
}
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
33
|
+
if (glob.startsWith("**")) return matchesSuffix(glob.slice(2), path, false);
|
|
34
|
+
if (glob.startsWith("*")) return matchesSuffix(glob.slice(1), path, true);
|
|
37
35
|
return path !== "" && path[0] === glob[0] && matchGlob(glob.slice(1), path.slice(1));
|
|
38
36
|
}
|
|
39
37
|
|
package/src/core/test-runner.ts
CHANGED
|
@@ -9,17 +9,20 @@ const SUMMARY_MAX = 300;
|
|
|
9
9
|
|
|
10
10
|
/** Words after wrappers (`time`, `timeout 60`, `env`), assignments and `+toolchain` selectors. */
|
|
11
11
|
function commandWords(words: readonly string[]): string[] {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (isAssignment(word))
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
const flagOrDuration = (w: string | undefined): boolean => /^(?:-|\d+[smhd]?$)/.test(w ?? "");
|
|
13
|
+
let next = 0;
|
|
14
|
+
for (const [index, word] of words.entries()) {
|
|
15
|
+
if (index < next) continue;
|
|
16
|
+
if (isAssignment(word)) {
|
|
17
|
+
next = index + 1;
|
|
18
|
+
} else if (WRAPPERS.has(basename(word)) || basename(word) === "timeout") {
|
|
19
|
+
next = index + 1;
|
|
20
|
+
while (flagOrDuration(words[next])) next += 1;
|
|
21
|
+
} else {
|
|
22
|
+
return words.slice(index);
|
|
23
|
+
}
|
|
21
24
|
}
|
|
22
|
-
return
|
|
25
|
+
return [];
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
function isRunner(words: readonly string[]): boolean {
|
|
@@ -125,7 +125,8 @@ type Segment = { words: string[]; truncates: string[] };
|
|
|
125
125
|
type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
|
|
126
126
|
|
|
127
127
|
/** Turns unquoted newlines into `;` (and joins backslash continuations) so shell-quote sees every command. */
|
|
128
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
|
|
128
|
+
// biome-ignore-start lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
|
|
129
|
+
// pi-lens-ignore: high-complexity -- a shell lexer is one flat branch per character class; splitting it would scatter one state machine across helpers
|
|
129
130
|
function splitLines(command: string): string {
|
|
130
131
|
let out = "";
|
|
131
132
|
let quote: "'" | '"' | undefined;
|
|
@@ -155,6 +156,7 @@ function splitLines(command: string): string {
|
|
|
155
156
|
}
|
|
156
157
|
return out;
|
|
157
158
|
}
|
|
159
|
+
// biome-ignore-end lint/complexity/noExcessiveCognitiveComplexity: end of the shell lexer above
|
|
158
160
|
|
|
159
161
|
/** Boundary decode of shell-quote's output into domain tokens; globs count as words. */
|
|
160
162
|
function tokenize(command: string): Token[] {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
2
2
|
import type { SessionState } from "../state/session-state.ts";
|
|
3
3
|
|
|
4
4
|
/** Open recorded departures for one soft gate: whether one applies now, and spending a once-scoped one. */
|
|
5
|
-
export type DepartureUse = {
|
|
5
|
+
export type DepartureUse = {
|
|
6
|
+
hasOpen(): boolean;
|
|
7
|
+
consume(): void;
|
|
8
|
+
/** True when an open departure covers this call; spends it when it is once-scoped. */
|
|
9
|
+
covers(): boolean;
|
|
10
|
+
};
|
|
6
11
|
|
|
7
12
|
export function departureUse(state: SessionState, gate: GateId): DepartureUse {
|
|
8
13
|
const applicable = () => {
|
|
@@ -11,18 +16,31 @@ export function departureUse(state: SessionState, gate: GateId): DepartureUse {
|
|
|
11
16
|
(d) => d.gate === gate && (d.scope.kind !== "slice" || d.scope.slice === activeSlice),
|
|
12
17
|
);
|
|
13
18
|
};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}));
|
|
26
|
-
},
|
|
19
|
+
const hasOpen = () => applicable().length > 0;
|
|
20
|
+
const consume = () => {
|
|
21
|
+
const open = applicable();
|
|
22
|
+
// A broader departure covers the call without being spent; only a lone once-scoped one is used up.
|
|
23
|
+
if (open.some((d) => d.scope.kind !== "once")) return;
|
|
24
|
+
const used = open[0];
|
|
25
|
+
if (used === undefined) return;
|
|
26
|
+
state.update((s) => ({
|
|
27
|
+
...s,
|
|
28
|
+
openDepartures: s.openDepartures.filter((d) => d.id !== used.id),
|
|
29
|
+
}));
|
|
27
30
|
};
|
|
31
|
+
const covers = () => {
|
|
32
|
+
if (!hasOpen()) return false;
|
|
33
|
+
consume();
|
|
34
|
+
return true;
|
|
35
|
+
};
|
|
36
|
+
return { hasOpen, consume, covers };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The parsed gate id with its departures; `undefined` when the id is malformed (the guard stays off). */
|
|
40
|
+
export function openGate(
|
|
41
|
+
state: SessionState,
|
|
42
|
+
id: string,
|
|
43
|
+
): { gate: GateId; departure: DepartureUse } | undefined {
|
|
44
|
+
const gate = parseGateId(id);
|
|
45
|
+
return isParseError(gate) ? undefined : { gate, departure: departureUse(state, gate) };
|
|
28
46
|
}
|
|
@@ -10,9 +10,8 @@ import { findUnreasonedSuppressions, MIN_RATIONALE } from "../core/lint-suppress
|
|
|
10
10
|
import { classifyPath } from "../core/path-class.ts";
|
|
11
11
|
import { normalizeRepoPath } from "../core/test-paths.ts";
|
|
12
12
|
import { applyEdits, normalizeText } from "../core/test-weakening.ts";
|
|
13
|
-
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
14
13
|
import type { SessionState } from "../state/session-state.ts";
|
|
15
|
-
import {
|
|
14
|
+
import { openGate } from "./departure-use.ts";
|
|
16
15
|
|
|
17
16
|
export type LintSuppressionGuardDeps = { pi: ExtensionAPI; state: SessionState };
|
|
18
17
|
|
|
@@ -54,20 +53,16 @@ function resultingText(
|
|
|
54
53
|
* (at least {@link MIN_RATIONALE} characters) or a recorded departure.
|
|
55
54
|
*/
|
|
56
55
|
export function registerLintSuppressionGuard(deps: LintSuppressionGuardDeps): void {
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
const
|
|
60
|
-
const departure = departureUse(deps.state, gate);
|
|
56
|
+
const opened = openGate(deps.state, GATE_ID);
|
|
57
|
+
if (opened === undefined) return;
|
|
58
|
+
const { departure } = opened;
|
|
61
59
|
|
|
62
60
|
deps.pi.on("tool_call", (event, ctx) => {
|
|
63
61
|
const change = resultingText(event, ctx.cwd);
|
|
64
62
|
if (change === undefined) return undefined;
|
|
65
63
|
const found = findUnreasonedSuppressions(change.before, change.after);
|
|
66
64
|
if (found.length === 0) return undefined;
|
|
67
|
-
if (departure.
|
|
68
|
-
departure.consume();
|
|
69
|
-
return undefined;
|
|
70
|
-
}
|
|
65
|
+
if (departure.covers()) return undefined;
|
|
71
66
|
const list = found.map((s) => `${s.marker} (line ${s.line})`).join(", ");
|
|
72
67
|
return {
|
|
73
68
|
block: true,
|
|
@@ -4,9 +4,8 @@ import { resolve } from "node:path";
|
|
|
4
4
|
import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { classifyPath } from "../core/path-class.ts";
|
|
6
6
|
import { normalizeRepoPath } from "../core/test-paths.ts";
|
|
7
|
-
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
8
7
|
import type { SessionState } from "../state/session-state.ts";
|
|
9
|
-
import {
|
|
8
|
+
import { openGate } from "./departure-use.ts";
|
|
10
9
|
|
|
11
10
|
export type RedFirstGuardDeps = { pi: ExtensionAPI; state: SessionState };
|
|
12
11
|
|
|
@@ -63,10 +62,9 @@ const JUDGED_EXEMPTIONS =
|
|
|
63
62
|
* judged exemptions go through one recorded departure, which covers its whole slice.
|
|
64
63
|
*/
|
|
65
64
|
export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
const
|
|
69
|
-
const departure = departureUse(deps.state, gate);
|
|
65
|
+
const opened = openGate(deps.state, GATE_ID);
|
|
66
|
+
if (opened === undefined) return;
|
|
67
|
+
const { departure } = opened;
|
|
70
68
|
|
|
71
69
|
deps.pi.on("tool_call", (event, ctx) => {
|
|
72
70
|
if (!(isToolCallEventType("edit", event) || isToolCallEventType("write", event)))
|
|
@@ -77,10 +75,7 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
|
|
|
77
75
|
const path = normalizeRepoPath(ctx.cwd, event.input.path, homedir());
|
|
78
76
|
if (classifyPath(path) !== "source") return undefined;
|
|
79
77
|
if (touchesInlineTest(event.input, () => existing(ctx.cwd, path))) return undefined;
|
|
80
|
-
if (departure.
|
|
81
|
-
departure.consume();
|
|
82
|
-
return undefined;
|
|
83
|
-
}
|
|
78
|
+
if (departure.covers()) return undefined;
|
|
84
79
|
const seen =
|
|
85
80
|
lastTestRun === undefined
|
|
86
81
|
? "no test run has been observed in this session"
|
package/src/gates/test-guard.ts
CHANGED
|
@@ -99,129 +99,136 @@ function jevVerdict(
|
|
|
99
99
|
: undefined;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
const parsedGate = (): GateId | undefined => {
|
|
103
|
+
const parsed = parseGateId(GATE_ID);
|
|
104
|
+
return isParseError(parsed) ? undefined : parsed;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const judge = async (
|
|
108
|
+
deps: TestGuardDeps,
|
|
109
|
+
change: Change,
|
|
110
|
+
ctx: ExtensionContext,
|
|
111
|
+
): Promise<Verdict> => {
|
|
112
|
+
const deleted = change.after === undefined && change.rewritten === undefined;
|
|
113
|
+
const after = change.after ?? change.rewritten ?? "";
|
|
114
|
+
const signals = deleted ? NO_SIGNALS : weakeningSignals(change.before, after);
|
|
115
|
+
if (!deleted && isHarmlessAddition(change.before, after, signals)) return { kind: "allow" };
|
|
116
|
+
const deterministic = describeSignals(deleted, signals);
|
|
117
|
+
const judged = await judgeTestChange(deps.jev(ctx), {
|
|
118
|
+
path: change.path,
|
|
119
|
+
before: change.before,
|
|
120
|
+
after: after === "" ? undefined : redactSecrets(after),
|
|
121
|
+
});
|
|
122
|
+
const fromJev = judged.ok ? jevVerdict(judged.value, deterministic !== undefined) : undefined;
|
|
123
|
+
if (fromJev !== undefined) return fromJev;
|
|
124
|
+
return deterministic !== undefined
|
|
125
|
+
? { kind: "require-departure", why: `this change ${deterministic}` }
|
|
126
|
+
: { kind: "allow" };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const bashChanges = (command: string, cwd: string): Change[] =>
|
|
130
|
+
bashMutations(command).flatMap(({ path: written, kind }): Change[] => {
|
|
131
|
+
const target = repoPath(cwd, written);
|
|
132
|
+
// Missing plain paths have nothing to weaken; globs and directories may still hide tests.
|
|
133
|
+
const touched = GLOB.test(target) || existsSync(join(cwd, target));
|
|
134
|
+
if (!(isTestPath(target) && touched)) return [];
|
|
135
|
+
const rewritten = kind === "overwrite" ? `(rewritten by shell command) ${command}` : undefined;
|
|
136
|
+
return [
|
|
137
|
+
{ path: target, before: readIfExists(join(cwd, target)) ?? "", after: undefined, rewritten },
|
|
138
|
+
];
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const editChange = (
|
|
142
|
+
path: string,
|
|
143
|
+
before: string,
|
|
144
|
+
edits: ReadonlyArray<{ oldText: string; newText: string }>,
|
|
145
|
+
): Change => {
|
|
146
|
+
const after = applyEdits(before, edits);
|
|
147
|
+
if (after !== undefined) return { path, before, after };
|
|
148
|
+
// The edit text does not match statically; let Jev read what the agent intends to write.
|
|
149
|
+
const intended = edits.map((e) => e.newText).join("\n");
|
|
150
|
+
return { path, before, after: undefined, rewritten: `(edit not matched statically) ${intended}` };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const fileChanges = (event: ToolCallEvent, cwd: string): Change[] => {
|
|
154
|
+
if (!(isToolCallEventType("write", event) || isToolCallEventType("edit", event))) return [];
|
|
155
|
+
const path = repoPath(cwd, event.input.path);
|
|
156
|
+
const raw = isTestPath(path) ? readIfExists(join(cwd, path)) : undefined;
|
|
157
|
+
if (raw === undefined) return [];
|
|
158
|
+
const before = normalizeText(raw);
|
|
159
|
+
return isToolCallEventType("write", event)
|
|
160
|
+
? [{ path, before, after: normalizeText(event.input.content) }]
|
|
161
|
+
: [editChange(path, before, event.input.edits)];
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] =>
|
|
165
|
+
isToolCallEventType("bash", event)
|
|
166
|
+
? bashChanges(event.input.command, ctx.cwd)
|
|
167
|
+
: fileChanges(event, ctx.cwd);
|
|
168
|
+
|
|
169
|
+
type Call = { deps: TestGuardDeps; gate: GateId; event: ToolCallEvent; ctx: ExtensionContext };
|
|
170
|
+
|
|
171
|
+
const hardStop = async (
|
|
172
|
+
{ deps, gate, event, ctx }: Call,
|
|
173
|
+
change: Change,
|
|
174
|
+
why: string,
|
|
175
|
+
): Promise<{ block: true; reason: string } | undefined> => {
|
|
176
|
+
const command = `${isToolCallEventType("bash", event) ? "delete" : "change"} ${change.path}`;
|
|
177
|
+
if (deps.approvals.consume(gate, command)) return undefined;
|
|
178
|
+
const outcome = await requestHardStop({
|
|
179
|
+
pi: deps.pi,
|
|
180
|
+
ctx,
|
|
181
|
+
gate,
|
|
182
|
+
command,
|
|
183
|
+
why,
|
|
184
|
+
costIfWrong: "the tests verify less behaviour, so regressions can ship undetected",
|
|
185
|
+
toolCallId: event.toolCallId,
|
|
186
|
+
now: deps.now,
|
|
187
|
+
});
|
|
188
|
+
if (outcome.kind === "approved") return undefined;
|
|
189
|
+
return {
|
|
190
|
+
block: true,
|
|
191
|
+
reason:
|
|
192
|
+
outcome.kind === "unavailable"
|
|
193
|
+
? `hard stop ${GATE_ID}: ${why} (${change.path}); requires user approval; run interactively`
|
|
194
|
+
: `hard stop ${GATE_ID}: the user declined this change to ${change.path}. Fix the code instead of weakening the test.`,
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const departureReason = (needing: ReadonlyArray<{ change: Change; why: string }>): string =>
|
|
199
|
+
`${GATE_ID}: ${needing[0]?.why} (${needing.map((n) => n.change.path).join(", ")}). Tests are the oracle; ` +
|
|
200
|
+
"weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure " +
|
|
201
|
+
`with gate "${GATE_ID}", what you are doing instead, why, and the cost if wrong; then retry. ` +
|
|
202
|
+
"Otherwise fix the code, not the test.";
|
|
203
|
+
|
|
102
204
|
/**
|
|
103
205
|
* Soft gate `tests.weaken`: deleting, skipping, emptying or loosening tests needs a recorded
|
|
104
206
|
* departure; a change Jev reads as gaming a failing gate escalates to a hard stop.
|
|
105
207
|
*/
|
|
106
208
|
export function registerTestGuard(deps: TestGuardDeps): void {
|
|
107
|
-
const gate
|
|
108
|
-
const parsed = parseGateId(GATE_ID);
|
|
109
|
-
return isParseError(parsed) ? undefined : parsed;
|
|
110
|
-
})();
|
|
209
|
+
const gate = parsedGate();
|
|
111
210
|
if (gate === undefined) return;
|
|
112
|
-
|
|
113
211
|
const departure = departureUse(deps.state, gate);
|
|
114
212
|
|
|
115
|
-
const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
|
|
116
|
-
const deleted = change.after === undefined && change.rewritten === undefined;
|
|
117
|
-
const after = change.after ?? change.rewritten ?? "";
|
|
118
|
-
const signals = deleted ? NO_SIGNALS : weakeningSignals(change.before, after);
|
|
119
|
-
if (!deleted && isHarmlessAddition(change.before, after, signals)) return { kind: "allow" };
|
|
120
|
-
const deterministic = describeSignals(deleted, signals);
|
|
121
|
-
const judged = await judgeTestChange(deps.jev(ctx), {
|
|
122
|
-
path: change.path,
|
|
123
|
-
before: change.before,
|
|
124
|
-
after: after === "" ? undefined : redactSecrets(after),
|
|
125
|
-
});
|
|
126
|
-
const fromJev = judged.ok ? jevVerdict(judged.value, deterministic !== undefined) : undefined;
|
|
127
|
-
if (fromJev !== undefined) return fromJev;
|
|
128
|
-
return deterministic !== undefined
|
|
129
|
-
? { kind: "require-departure", why: `this change ${deterministic}` }
|
|
130
|
-
: { kind: "allow" };
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
|
|
134
|
-
if (isToolCallEventType("bash", event)) {
|
|
135
|
-
const { command } = event.input;
|
|
136
|
-
return bashMutations(command).flatMap(({ path: written, kind }): Change[] => {
|
|
137
|
-
const target = repoPath(ctx.cwd, written);
|
|
138
|
-
// Missing plain paths have nothing to weaken; globs and directories may still hide tests.
|
|
139
|
-
const touched = GLOB.test(target) || existsSync(join(ctx.cwd, target));
|
|
140
|
-
if (!(isTestPath(target) && touched)) return [];
|
|
141
|
-
const rewritten =
|
|
142
|
-
kind === "overwrite" ? `(rewritten by shell command) ${command}` : undefined;
|
|
143
|
-
return [
|
|
144
|
-
{
|
|
145
|
-
path: target,
|
|
146
|
-
before: readIfExists(join(ctx.cwd, target)) ?? "",
|
|
147
|
-
after: undefined,
|
|
148
|
-
rewritten,
|
|
149
|
-
},
|
|
150
|
-
];
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
if (!(isToolCallEventType("write", event) || isToolCallEventType("edit", event))) return [];
|
|
154
|
-
const path = repoPath(ctx.cwd, event.input.path);
|
|
155
|
-
const raw = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
|
|
156
|
-
if (raw === undefined) return [];
|
|
157
|
-
const before = normalizeText(raw);
|
|
158
|
-
if (isToolCallEventType("write", event)) {
|
|
159
|
-
return [{ path, before, after: normalizeText(event.input.content) }];
|
|
160
|
-
}
|
|
161
|
-
const after = applyEdits(before, event.input.edits);
|
|
162
|
-
if (after !== undefined) return [{ path, before, after }];
|
|
163
|
-
// The edit text does not match statically; let Jev read what the agent intends to write.
|
|
164
|
-
const intended = event.input.edits.map((e) => e.newText).join("\n");
|
|
165
|
-
return [
|
|
166
|
-
{ path, before, after: undefined, rewritten: `(edit not matched statically) ${intended}` },
|
|
167
|
-
];
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const hardStop = async (
|
|
171
|
-
event: ToolCallEvent,
|
|
172
|
-
ctx: ExtensionContext,
|
|
173
|
-
change: Change,
|
|
174
|
-
why: string,
|
|
175
|
-
): Promise<{ block: true; reason: string } | undefined> => {
|
|
176
|
-
const command = `${isToolCallEventType("bash", event) ? "delete" : "change"} ${change.path}`;
|
|
177
|
-
if (deps.approvals.consume(gate, command)) return undefined;
|
|
178
|
-
const outcome = await requestHardStop({
|
|
179
|
-
pi: deps.pi,
|
|
180
|
-
ctx,
|
|
181
|
-
gate,
|
|
182
|
-
command,
|
|
183
|
-
why,
|
|
184
|
-
costIfWrong: "the tests verify less behaviour, so regressions can ship undetected",
|
|
185
|
-
toolCallId: event.toolCallId,
|
|
186
|
-
now: deps.now,
|
|
187
|
-
});
|
|
188
|
-
if (outcome.kind === "approved") return undefined;
|
|
189
|
-
return {
|
|
190
|
-
block: true,
|
|
191
|
-
reason:
|
|
192
|
-
outcome.kind === "unavailable"
|
|
193
|
-
? `hard stop ${GATE_ID}: ${why} (${change.path}); requires user approval; run interactively`
|
|
194
|
-
: `hard stop ${GATE_ID}: the user declined this change to ${change.path}. Fix the code instead of weakening the test.`,
|
|
195
|
-
};
|
|
196
|
-
};
|
|
197
|
-
|
|
198
213
|
deps.pi.on("tool_call", async (event, ctx) => {
|
|
199
214
|
const verdicts: Array<{ change: Change; verdict: Verdict }> = [];
|
|
200
215
|
for (const change of collectChanges(event, ctx)) {
|
|
201
|
-
verdicts.push({ change, verdict: await judge(change, ctx) });
|
|
216
|
+
verdicts.push({ change, verdict: await judge(deps, change, ctx) });
|
|
202
217
|
}
|
|
203
218
|
for (const { change, verdict } of verdicts) {
|
|
204
219
|
if (verdict.kind !== "hard-stop") continue;
|
|
205
|
-
const blocked = await hardStop(event, ctx, change, verdict.why);
|
|
220
|
+
const blocked = await hardStop({ deps, gate, event, ctx }, change, verdict.why);
|
|
206
221
|
if (blocked !== undefined) return blocked;
|
|
207
222
|
}
|
|
208
223
|
const needing = verdicts.flatMap(({ change, verdict }) =>
|
|
209
224
|
verdict.kind === "require-departure" ? [{ change, why: verdict.why }] : [],
|
|
210
225
|
);
|
|
211
|
-
|
|
212
|
-
if (first === undefined) return undefined;
|
|
226
|
+
if (needing.length === 0) return undefined;
|
|
213
227
|
if (departure.hasOpen()) {
|
|
214
228
|
// One departure covers every path in this single tool call.
|
|
215
229
|
departure.consume();
|
|
216
230
|
return undefined;
|
|
217
231
|
}
|
|
218
|
-
return {
|
|
219
|
-
block: true,
|
|
220
|
-
reason:
|
|
221
|
-
`${GATE_ID}: ${first.why} (${needing.map((n) => n.change.path).join(", ")}). Tests are the oracle; ` +
|
|
222
|
-
"weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure " +
|
|
223
|
-
`with gate "${GATE_ID}", what you are doing instead, why, and the cost if wrong; then retry. ` +
|
|
224
|
-
"Otherwise fix the code, not the test.",
|
|
225
|
-
};
|
|
232
|
+
return { block: true, reason: departureReason(needing) };
|
|
226
233
|
});
|
|
227
234
|
}
|
package/src/review/digest.ts
CHANGED
|
@@ -37,6 +37,45 @@ const PLAIN_DIFF = [
|
|
|
37
37
|
"--ignore-submodules=dirty",
|
|
38
38
|
];
|
|
39
39
|
|
|
40
|
+
type GitReads = { diff: string; stat: string; listed: string[] };
|
|
41
|
+
|
|
42
|
+
const NO_OUTPUT = { code: 0, stdout: "", stderr: "" };
|
|
43
|
+
|
|
44
|
+
/** The three git reads a snapshot is made of, or the error that stopped them. */
|
|
45
|
+
async function readGit(exec: Exec, cwd: string, range: string): Promise<Result<GitReads, string>> {
|
|
46
|
+
// Plain, parseable output whatever the user's git config says: an external diff driver or
|
|
47
|
+
// forced colour removes the `diff --git` headers the per-file digests are cut from.
|
|
48
|
+
const [diff, stat, others] = await Promise.all([
|
|
49
|
+
exec("git", [...PLAIN_DIFF, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
|
|
50
|
+
exec("git", [...PLAIN_DIFF, "--stat", range], { cwd, timeout: 15_000 }),
|
|
51
|
+
// Any range that ends in the work tree (HEAD, HEAD~1, a branch) leaves untracked files out of git diff.
|
|
52
|
+
range.includes("..")
|
|
53
|
+
? Promise.resolve(NO_OUTPUT)
|
|
54
|
+
: exec(
|
|
55
|
+
"git",
|
|
56
|
+
["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"],
|
|
57
|
+
{ cwd, timeout: 15_000 },
|
|
58
|
+
),
|
|
59
|
+
]);
|
|
60
|
+
if (diff.code !== 0) return err(diff.stderr.trim() || `git diff ${range} failed`);
|
|
61
|
+
if (others.code !== 0) return err(others.stderr.trim() || "git ls-files --others failed");
|
|
62
|
+
return ok({
|
|
63
|
+
diff: diff.stdout,
|
|
64
|
+
stat: stat.stdout,
|
|
65
|
+
listed: others.stdout.split("\0").filter((p) => p !== ""),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Per-file digests of the tracked diff; an unparseable non-empty diff is an error, never an empty digest. */
|
|
70
|
+
function trackedDigests(diff: string): Result<Record<string, string>, string> {
|
|
71
|
+
const files: Record<string, string> = {};
|
|
72
|
+
for (const [path, text] of Object.entries(splitDiffByFile(diff))) files[path] = fileDigest(text);
|
|
73
|
+
if (diff.trim() !== "" && Object.keys(files).length === 0) {
|
|
74
|
+
return err("could not read the diff: no per-file sections were found");
|
|
75
|
+
}
|
|
76
|
+
return ok(files);
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
/**
|
|
41
80
|
* The change in `range` (default `HEAD`: everything not yet committed) with its digest. `git diff`
|
|
42
81
|
* leaves out untracked files, so for a range that includes the work tree they are listed and
|
|
@@ -48,41 +87,21 @@ export async function snapshotDiff(
|
|
|
48
87
|
range: string,
|
|
49
88
|
): Promise<Result<DiffSnapshot, string>> {
|
|
50
89
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const [diff, stat, others] = await Promise.all([
|
|
54
|
-
exec("git", [...PLAIN_DIFF, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
|
|
55
|
-
exec("git", [...PLAIN_DIFF, "--stat", range], { cwd, timeout: 15_000 }),
|
|
56
|
-
// Any range that ends in the work tree (HEAD, HEAD~1, a branch) leaves untracked files out of git diff.
|
|
57
|
-
!range.includes("..")
|
|
58
|
-
? exec(
|
|
59
|
-
"git",
|
|
60
|
-
["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"],
|
|
61
|
-
{ cwd, timeout: 15_000 },
|
|
62
|
-
)
|
|
63
|
-
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
|
64
|
-
]);
|
|
65
|
-
if (diff.code !== 0) return err(diff.stderr.trim() || `git diff ${range} failed`);
|
|
66
|
-
if (others.code !== 0) return err(others.stderr.trim() || "git ls-files --others failed");
|
|
67
|
-
const listed = others.stdout.split("\0").filter((p) => p !== "");
|
|
90
|
+
const git = await readGit(exec, cwd, range);
|
|
91
|
+
if (!git.ok) return git;
|
|
68
92
|
// A nested repository is listed as `dir/` and has no blob to hash.
|
|
69
|
-
const nested = listed.filter((p) => p.endsWith("/"));
|
|
70
|
-
const untracked = listed.filter((p) => !p.endsWith("/"));
|
|
93
|
+
const nested = git.value.listed.filter((p) => p.endsWith("/"));
|
|
94
|
+
const untracked = git.value.listed.filter((p) => !p.endsWith("/"));
|
|
71
95
|
if (untracked.length > MAX_UNTRACKED) {
|
|
72
96
|
return err(
|
|
73
97
|
`${untracked.length} untracked files (more than ${MAX_UNTRACKED}); stage (git add) or ignore some so they can be reviewed`,
|
|
74
98
|
);
|
|
75
99
|
}
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
files[path] = fileDigest(text);
|
|
79
|
-
}
|
|
80
|
-
if (diff.stdout.trim() !== "" && Object.keys(files).length === 0) {
|
|
81
|
-
return err("could not read the diff: no per-file sections were found");
|
|
82
|
-
}
|
|
100
|
+
const tracked = trackedDigests(git.value.diff);
|
|
101
|
+
if (!tracked.ok) return tracked;
|
|
83
102
|
const hashed = await hashUntracked(exec, cwd, untracked);
|
|
84
103
|
if (!hashed.ok) return hashed;
|
|
85
|
-
|
|
104
|
+
const files = { ...tracked.value, ...hashed.value };
|
|
86
105
|
const names = Object.keys(files).sort((a, b) => a.localeCompare(b));
|
|
87
106
|
const untrackedStat = [
|
|
88
107
|
...untracked.map((p) => ` ${p} (untracked)`),
|
|
@@ -91,8 +110,8 @@ export async function snapshotDiff(
|
|
|
91
110
|
return ok({
|
|
92
111
|
digest: digestOf(names.map((p) => `${p}\0${files[p]}`).join("\n")),
|
|
93
112
|
files,
|
|
94
|
-
stat: [stat.
|
|
95
|
-
sample: diff.
|
|
113
|
+
stat: [git.value.stat.trimEnd(), untrackedStat].filter((p) => p !== "").join("\n"),
|
|
114
|
+
sample: git.value.diff.slice(0, 16_000),
|
|
96
115
|
});
|
|
97
116
|
} catch (cause) {
|
|
98
117
|
return err(cause instanceof Error ? cause.message : String(cause));
|
|
@@ -118,6 +118,92 @@ function spawnPayload(input: {
|
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
type Reply = ReturnType<typeof reply>;
|
|
122
|
+
type Config = Extract<Awaited<ReturnType<typeof loadConfig>>, { ok: true }>["value"];
|
|
123
|
+
type Snapshot = Extract<Awaited<ReturnType<typeof snapshotDiff>>, { ok: true }>["value"];
|
|
124
|
+
type Prepared = { slice: SliceRef; config: Config; range: string; snap: Snapshot };
|
|
125
|
+
|
|
126
|
+
const requiredRounds = (config: Config): number =>
|
|
127
|
+
Math.max(config.review.requiredCleanRounds, config.review.minRounds);
|
|
128
|
+
|
|
129
|
+
const rangeOf = (given: string | undefined): string => given?.trim() || "HEAD";
|
|
130
|
+
|
|
131
|
+
/** The slice, config and diff snapshot both tools need, or the error reply that explains why not. */
|
|
132
|
+
async function prepare(
|
|
133
|
+
deps: ReviewToolDeps,
|
|
134
|
+
ctx: ExtensionContext,
|
|
135
|
+
given: { slice?: string | undefined; diffRange?: string | undefined },
|
|
136
|
+
failure: (range: string, error: string) => string,
|
|
137
|
+
): Promise<{ ok: true; value: Prepared } | { ok: false; reply: Reply }> {
|
|
138
|
+
const slice = sliceOf(given.slice, deps.state);
|
|
139
|
+
if (slice === undefined) return { ok: false, reply: reply(NO_SLICE, true) };
|
|
140
|
+
const config = await loadConfig(ctx.cwd);
|
|
141
|
+
if (!config.ok)
|
|
142
|
+
return { ok: false, reply: reply(`${CONFIG_FILE}: ${config.error.message}`, true) };
|
|
143
|
+
const range = rangeOf(given.diffRange);
|
|
144
|
+
const snap = await snapshotDiff(deps.exec, ctx.cwd, range);
|
|
145
|
+
if (!snap.ok) return { ok: false, reply: reply(failure(range, snap.error), true) };
|
|
146
|
+
return { ok: true, value: { slice, config: config.value, range, snap: snap.value } };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rangeNote = (range: string): string => (range === "HEAD" ? "" : ` ${GATE_RANGE_NOTE}`);
|
|
150
|
+
|
|
151
|
+
/** The reply for a round that must not start: already satisfied, or findings still open. */
|
|
152
|
+
function startRefusal(review: ReviewState, p: Prepared): Reply | undefined {
|
|
153
|
+
const action = nextAction(review, p.snap.digest);
|
|
154
|
+
if (action === "done") {
|
|
155
|
+
return reply(
|
|
156
|
+
`${reviewLabel(review)}. Review of ${p.slice} is already satisfied on this diff; nothing to run.${rangeNote(p.range)}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (action === "fix-findings") {
|
|
160
|
+
return reply(
|
|
161
|
+
`${reviewLabel(review)}. Round ${review.rounds.length} of ${p.slice} has blocking or should-fix findings and the diff has not changed since: fix them first, then start again. ` +
|
|
162
|
+
"Re-running the review on unchanged code does not clear a finding. If a finding is wrong, record a `review.unsatisfied` departure with the evidence (devsys_record_departure).",
|
|
163
|
+
true,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function startRound(
|
|
170
|
+
deps: ReviewToolDeps,
|
|
171
|
+
ctx: ExtensionContext,
|
|
172
|
+
p: Prepared,
|
|
173
|
+
): Promise<Reply> {
|
|
174
|
+
const judged = await judgeLenses(deps.jev(ctx), {
|
|
175
|
+
diffStat: p.snap.stat,
|
|
176
|
+
diffSample: p.snap.sample,
|
|
177
|
+
profiles: deps.state.get().profiles ?? [],
|
|
178
|
+
});
|
|
179
|
+
const { lenses, basis } = chooseLenses(judged);
|
|
180
|
+
const required = requiredRounds(p.config);
|
|
181
|
+
const existing = reviewOf(deps.state.get(), p.slice) ?? startReview(p.slice, required);
|
|
182
|
+
const review: ReviewState = { ...existing, required };
|
|
183
|
+
deps.state.update((s) => upsertReview(s, review));
|
|
184
|
+
const refusal = startRefusal(review, p);
|
|
185
|
+
if (refusal !== undefined) return refusal;
|
|
186
|
+
|
|
187
|
+
const round = review.rounds.length + 1;
|
|
188
|
+
const spawn = spawnPayload({
|
|
189
|
+
slice: p.slice,
|
|
190
|
+
round,
|
|
191
|
+
lenses,
|
|
192
|
+
range: p.range,
|
|
193
|
+
model: resolveSlot(p.config.models, "reviewer", availableModels(ctx.modelRegistry)),
|
|
194
|
+
now: deps.now?.() ?? new Date(),
|
|
195
|
+
});
|
|
196
|
+
const rangeArg = p.range === "HEAD" ? "" : ` and diffRange "${p.range}"`;
|
|
197
|
+
return reply(
|
|
198
|
+
[
|
|
199
|
+
`${reviewLabel(review)}; round ${round}; diff ${p.snap.digest}. ${basis}`,
|
|
200
|
+
`lenses: ${lenses.join(", ")}`,
|
|
201
|
+
`Spawn the reviewer with agent_spawn using exactly this payload, then pass its packet to devsys_review_record with slice "${p.slice}", diffDigest "${p.snap.digest}"${rangeArg}:`,
|
|
202
|
+
JSON.stringify(spawn),
|
|
203
|
+
].join("\n"),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
121
207
|
/** `devsys_review_start`: Jev picks lenses for the diff; the reply carries the agent_spawn payload for a fresh reviewer. */
|
|
122
208
|
export function createReviewStartTool(
|
|
123
209
|
deps: ReviewToolDeps,
|
|
@@ -137,62 +223,16 @@ export function createReviewStartTool(
|
|
|
137
223
|
_onUpdate,
|
|
138
224
|
ctx: ExtensionContext,
|
|
139
225
|
) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const snap = await snapshotDiff(deps.exec, ctx.cwd, range);
|
|
146
|
-
if (!snap.ok) return reply(`cannot read the diff for ${range}: ${snap.error}`, true);
|
|
147
|
-
if (snap.value.stat.trim() === "")
|
|
148
|
-
return reply(`the diff for ${range} is empty; nothing to review`, true);
|
|
149
|
-
|
|
150
|
-
const judged = await judgeLenses(deps.jev(ctx), {
|
|
151
|
-
diffStat: snap.value.stat,
|
|
152
|
-
diffSample: snap.value.sample,
|
|
153
|
-
profiles: deps.state.get().profiles ?? [],
|
|
154
|
-
});
|
|
155
|
-
const { lenses, basis } = chooseLenses(judged);
|
|
156
|
-
|
|
157
|
-
const required = Math.max(
|
|
158
|
-
config.value.review.requiredCleanRounds,
|
|
159
|
-
config.value.review.minRounds,
|
|
160
|
-
);
|
|
161
|
-
const existing = reviewOf(deps.state.get(), slice) ?? startReview(slice, required);
|
|
162
|
-
const review: ReviewState = { ...existing, required };
|
|
163
|
-
deps.state.update((s) => upsertReview(s, review));
|
|
164
|
-
const action = nextAction(review, snap.value.digest);
|
|
165
|
-
if (action === "done") {
|
|
166
|
-
return reply(
|
|
167
|
-
`${reviewLabel(review)}. Review of ${slice} is already satisfied on this diff; nothing to run.${range === "HEAD" ? "" : ` ${GATE_RANGE_NOTE}`}`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (action === "fix-findings") {
|
|
172
|
-
return reply(
|
|
173
|
-
`${reviewLabel(review)}. Round ${review.rounds.length} of ${slice} has blocking or should-fix findings and the diff has not changed since: fix them first, then start again. ` +
|
|
174
|
-
"Re-running the review on unchanged code does not clear a finding. If a finding is wrong, record a `review.unsatisfied` departure with the evidence (devsys_record_departure).",
|
|
175
|
-
true,
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const round = review.rounds.length + 1;
|
|
180
|
-
const spawn = spawnPayload({
|
|
181
|
-
slice,
|
|
182
|
-
round,
|
|
183
|
-
lenses,
|
|
184
|
-
range,
|
|
185
|
-
model: resolveSlot(config.value.models, "reviewer", availableModels(ctx.modelRegistry)),
|
|
186
|
-
now: deps.now?.() ?? new Date(),
|
|
187
|
-
});
|
|
188
|
-
return reply(
|
|
189
|
-
[
|
|
190
|
-
`${reviewLabel(review)}; round ${round}; diff ${snap.value.digest}. ${basis}`,
|
|
191
|
-
`lenses: ${lenses.join(", ")}`,
|
|
192
|
-
`Spawn the reviewer with agent_spawn using exactly this payload, then pass its packet to devsys_review_record with slice "${slice}", diffDigest "${snap.value.digest}"${range === "HEAD" ? "" : ` and diffRange "${range}"`}:`,
|
|
193
|
-
JSON.stringify(spawn),
|
|
194
|
-
].join("\n"),
|
|
226
|
+
const prepared = await prepare(
|
|
227
|
+
deps,
|
|
228
|
+
ctx,
|
|
229
|
+
params,
|
|
230
|
+
(range, error) => `cannot read the diff for ${range}: ${error}`,
|
|
195
231
|
);
|
|
232
|
+
if (!prepared.ok) return prepared.reply;
|
|
233
|
+
if (prepared.value.snap.stat.trim() === "")
|
|
234
|
+
return reply(`the diff for ${prepared.value.range} is empty; nothing to review`, true);
|
|
235
|
+
return startRound(deps, ctx, prepared.value);
|
|
196
236
|
},
|
|
197
237
|
};
|
|
198
238
|
}
|
|
@@ -233,7 +273,82 @@ async function adjusted(
|
|
|
233
273
|
return { findings: out, notes };
|
|
234
274
|
}
|
|
235
275
|
|
|
236
|
-
/**
|
|
276
|
+
/** Parses every packet, or the reply naming the first malformed one. */
|
|
277
|
+
function parsePackets(
|
|
278
|
+
raw: readonly string[],
|
|
279
|
+
): { ok: true; value: ReviewPacket[] } | { ok: false; reply: Reply } {
|
|
280
|
+
if (raw.length === 0)
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
reply: reply("no packets: pass the reviewer's packet markdown in `packets`", true),
|
|
284
|
+
};
|
|
285
|
+
const packets: ReviewPacket[] = [];
|
|
286
|
+
for (const [i, text] of raw.entries()) {
|
|
287
|
+
const parsed = parseReviewPacket(text);
|
|
288
|
+
if (isParseError(parsed))
|
|
289
|
+
return { ok: false, reply: reply(`packet ${i + 1} is malformed: ${parsed.message}`, true) };
|
|
290
|
+
packets.push(parsed);
|
|
291
|
+
}
|
|
292
|
+
return { ok: true, value: packets };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The refusal for a packet written for another slice or round, if there is one. */
|
|
296
|
+
function wrongPacket(packets: readonly ReviewPacket[], slice: SliceRef, expected: number) {
|
|
297
|
+
const wrong = packets.findIndex((p) => p.round !== expected || p.slice !== slice);
|
|
298
|
+
if (wrong === -1) return undefined;
|
|
299
|
+
return reply(
|
|
300
|
+
`packet ${wrong + 1} is for slice "${packets[wrong]?.slice}" round ${packets[wrong]?.round}; this is round ${expected} of slice "${slice}". A packet is recorded once, in the round it was written for; ask the reviewer for a packet with the right header.`,
|
|
301
|
+
true,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const countOf = (findings: readonly Finding[], sev: Finding["severity"]): number =>
|
|
306
|
+
findings.filter((f) => f.severity === sev).length;
|
|
307
|
+
|
|
308
|
+
function recordSummary(
|
|
309
|
+
review: ReviewState,
|
|
310
|
+
findings: readonly Finding[],
|
|
311
|
+
notes: readonly string[],
|
|
312
|
+
p: Prepared,
|
|
313
|
+
): string {
|
|
314
|
+
return [
|
|
315
|
+
`Round ${review.rounds.length} recorded: ${countOf(findings, "blocking")} blocking, ${countOf(findings, "should-fix")} should-fix, ${countOf(findings, "nit")} nit, ${countOf(findings, "false-positive")} false-positive.`,
|
|
316
|
+
reviewLabel(review),
|
|
317
|
+
...notes,
|
|
318
|
+
...nitLines(findings),
|
|
319
|
+
`next: ${nextAction(review, p.snap.digest)}`,
|
|
320
|
+
...(p.range === "HEAD" ? [] : [GATE_RANGE_NOTE]),
|
|
321
|
+
].join("\n");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function recordRound(
|
|
325
|
+
deps: ReviewToolDeps,
|
|
326
|
+
ctx: ExtensionContext,
|
|
327
|
+
p: Prepared,
|
|
328
|
+
packets: readonly ReviewPacket[],
|
|
329
|
+
): Promise<Reply> {
|
|
330
|
+
const required = requiredRounds(p.config);
|
|
331
|
+
const before = reviewOf(deps.state.get(), p.slice) ?? startReview(p.slice, required);
|
|
332
|
+
const refusal = wrongPacket(packets, p.slice, before.rounds.length + 1);
|
|
333
|
+
if (refusal !== undefined) return refusal;
|
|
334
|
+
|
|
335
|
+
const merged = combinePackets(packets);
|
|
336
|
+
const { findings, notes } = await adjusted(deps, ctx, merged.findings, p.snap.sample);
|
|
337
|
+
const review = addRound(
|
|
338
|
+
{ ...before, required },
|
|
339
|
+
{
|
|
340
|
+
lenses: merged.lenses,
|
|
341
|
+
findings,
|
|
342
|
+
reviewedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
343
|
+
diffDigest: p.snap.digest,
|
|
344
|
+
files: p.snap.files,
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
deps.state.update((s) => upsertReview(s, review));
|
|
348
|
+
return reply(recordSummary(review, findings, notes, p));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** `devsys_review_record`: parse packets, let Jev adjust severities, record the round. */
|
|
237
352
|
export function createReviewRecordTool(
|
|
238
353
|
deps: ReviewToolDeps,
|
|
239
354
|
): ToolDefinition<typeof RecordParameters> {
|
|
@@ -252,69 +367,23 @@ export function createReviewRecordTool(
|
|
|
252
367
|
_onUpdate,
|
|
253
368
|
ctx: ExtensionContext,
|
|
254
369
|
) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return reply(`packet ${i + 1} is malformed: ${parsed.message}`, true);
|
|
264
|
-
packets.push(parsed);
|
|
265
|
-
}
|
|
266
|
-
const config = await loadConfig(ctx.cwd);
|
|
267
|
-
if (!config.ok) return reply(`${CONFIG_FILE}: ${config.error.message}`, true);
|
|
268
|
-
const range = params.diffRange?.trim() || "HEAD";
|
|
269
|
-
const snap = await snapshotDiff(deps.exec, ctx.cwd, range);
|
|
270
|
-
if (!snap.ok) return reply(`cannot read the diff: ${snap.error}`, true);
|
|
271
|
-
|
|
272
|
-
if (params.diffDigest !== snap.value.digest) {
|
|
273
|
-
return reply(
|
|
274
|
-
`the diff changed since the review started (reviewed ${params.diffDigest}, now ${snap.value.digest}); the reviewer did not see the current code. Run devsys_review_start again.`,
|
|
275
|
-
true,
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
const required = Math.max(
|
|
279
|
-
config.value.review.requiredCleanRounds,
|
|
280
|
-
config.value.review.minRounds,
|
|
370
|
+
if (sliceOf(params.slice, deps.state) === undefined) return reply(NO_SLICE, true);
|
|
371
|
+
const packets = parsePackets(params.packets);
|
|
372
|
+
if (!packets.ok) return packets.reply;
|
|
373
|
+
const prepared = await prepare(
|
|
374
|
+
deps,
|
|
375
|
+
ctx,
|
|
376
|
+
params,
|
|
377
|
+
(_range, error) => `cannot read the diff: ${error}`,
|
|
281
378
|
);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const wrong = packets.findIndex((p) => p.round !== expected || p.slice !== slice);
|
|
285
|
-
if (wrong !== -1) {
|
|
379
|
+
if (!prepared.ok) return prepared.reply;
|
|
380
|
+
if (params.diffDigest !== prepared.value.snap.digest) {
|
|
286
381
|
return reply(
|
|
287
|
-
`
|
|
382
|
+
`the diff changed since the review started (reviewed ${params.diffDigest}, now ${prepared.value.snap.digest}); the reviewer did not see the current code. Run devsys_review_start again.`,
|
|
288
383
|
true,
|
|
289
384
|
);
|
|
290
385
|
}
|
|
291
|
-
|
|
292
|
-
const merged = combinePackets(packets);
|
|
293
|
-
const { findings, notes } = await adjusted(deps, ctx, merged.findings, snap.value.sample);
|
|
294
|
-
const reviewed = snap.value;
|
|
295
|
-
const review = addRound(
|
|
296
|
-
{ ...before, required },
|
|
297
|
-
{
|
|
298
|
-
lenses: merged.lenses,
|
|
299
|
-
findings,
|
|
300
|
-
reviewedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
301
|
-
diffDigest: reviewed.digest,
|
|
302
|
-
files: reviewed.files,
|
|
303
|
-
},
|
|
304
|
-
);
|
|
305
|
-
deps.state.update((s) => upsertReview(s, review));
|
|
306
|
-
const counts = (sev: Finding["severity"]) =>
|
|
307
|
-
findings.filter((f) => f.severity === sev).length;
|
|
308
|
-
return reply(
|
|
309
|
-
[
|
|
310
|
-
`Round ${review.rounds.length} recorded: ${counts("blocking")} blocking, ${counts("should-fix")} should-fix, ${counts("nit")} nit, ${counts("false-positive")} false-positive.`,
|
|
311
|
-
reviewLabel(review),
|
|
312
|
-
...notes,
|
|
313
|
-
...nitLines(findings),
|
|
314
|
-
`next: ${nextAction(review, reviewed.digest)}`,
|
|
315
|
-
...(range === "HEAD" ? [] : [GATE_RANGE_NOTE]),
|
|
316
|
-
].join("\n"),
|
|
317
|
-
);
|
|
386
|
+
return recordRound(deps, ctx, prepared.value, packets.value);
|
|
318
387
|
},
|
|
319
388
|
};
|
|
320
389
|
}
|
|
@@ -500,7 +500,7 @@ export function diffAgentSettings(base: AgentType, draft: AgentType): AgentSetti
|
|
|
500
500
|
const after = draft[key];
|
|
501
501
|
if (JSON.stringify(before ?? null) === JSON.stringify(after ?? null)) continue;
|
|
502
502
|
if (after === undefined) override[key] = null as never;
|
|
503
|
-
else (override[key]
|
|
503
|
+
else Object.assign(override, { [key]: structuredClone(after) });
|
|
504
504
|
}
|
|
505
505
|
void CLEARABLE_OVERRIDE_FIELDS;
|
|
506
506
|
return override;
|