@jwilger/pi-development-system 0.17.0 → 0.19.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 +9 -0
- package/package.json +1 -1
- package/prompts/devsys-review.md +10 -0
- package/skills/code-review/SKILL.md +65 -0
- package/src/context/status.ts +13 -1
- package/src/core/review-flow.ts +166 -0
- package/src/core/review-packet.ts +111 -0
- package/src/core/review.ts +178 -0
- package/src/core/types.ts +4 -0
- package/src/gates/commit-guard.ts +30 -5
- package/src/jev/questions/review.ts +125 -0
- package/src/review/digest.ts +62 -0
- package/src/review/review-tools.ts +288 -0
- package/src/state/session-state.ts +27 -2
|
@@ -14,6 +14,7 @@ import { registerRedFirstGuard } from "../src/gates/red-first-guard.ts";
|
|
|
14
14
|
import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
|
|
15
15
|
import { registerTestGuard } from "../src/gates/test-guard.ts";
|
|
16
16
|
import { createJevHolder } from "../src/jev/holder.ts";
|
|
17
|
+
import { createReviewRecordTool, createReviewStartTool } from "../src/review/review-tools.ts";
|
|
17
18
|
import { registerCiCommand } from "../src/state/ci-command.ts";
|
|
18
19
|
import { loadConfig } from "../src/state/config.ts";
|
|
19
20
|
import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
|
|
@@ -103,6 +104,14 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
103
104
|
pi.registerTool(createRequestApprovalTool({ pi, approvals }));
|
|
104
105
|
pi.registerTool(createModelsTool());
|
|
105
106
|
pi.registerTool(createRouteTaskTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
107
|
+
const reviewDeps = {
|
|
108
|
+
state,
|
|
109
|
+
jev: (ctx: ExtensionContext) => jevHolder.forContext(ctx),
|
|
110
|
+
exec: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) =>
|
|
111
|
+
pi.exec(command, args, options),
|
|
112
|
+
} satisfies Parameters<typeof createReviewStartTool>[0];
|
|
113
|
+
pi.registerTool(createReviewStartTool(reviewDeps));
|
|
114
|
+
pi.registerTool(createReviewRecordTool(reviewDeps));
|
|
106
115
|
registerModelsCommand(pi);
|
|
107
116
|
piSubagent(pi);
|
|
108
117
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run a fresh-context review round on the active slice and record the result
|
|
3
|
+
argument-hint: "[slice] [diffRange]"
|
|
4
|
+
---
|
|
5
|
+
Review the work in progress with the code-review skill.
|
|
6
|
+
|
|
7
|
+
1. Call `devsys_review_start` (slice and diff range: $ARGUMENTS; omit either to use the active slice and `HEAD`).
|
|
8
|
+
2. Run the `agent_spawn` payload it returns, unchanged.
|
|
9
|
+
3. Pass the reviewer's packet verbatim to `devsys_review_record`.
|
|
10
|
+
4. Report `review: N/R clean` and the next action. If the next action is `fix-findings`, list each blocking and should-fix finding with its `path:line`.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: code-review
|
|
3
|
+
description: How to review a slice before it is committed in this development system - fresh-context reviewer per round, Jev-chosen lenses, the review packet format, what resets the clean streak, and driving rounds with devsys_review_start and devsys_review_record. Use before committing a slice, when asked to review, or when interpreting reviewer findings.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Code review
|
|
7
|
+
|
|
8
|
+
Every slice is reviewed by a **fresh-context reviewer** before it is committed.
|
|
9
|
+
The reviewer has not seen your reasoning, so it judges the diff, not your intent.
|
|
10
|
+
Review is a soft gate (`review.unsatisfied`): skipping it needs a recorded departure.
|
|
11
|
+
|
|
12
|
+
## Run a round
|
|
13
|
+
|
|
14
|
+
1. `devsys_review_start` (slice defaults to the active slice; `diffRange` defaults
|
|
15
|
+
to `HEAD`: everything uncommitted, untracked files included). It computes the
|
|
16
|
+
diff digest, lets Jev choose lenses, and returns an exact `agent_spawn` payload.
|
|
17
|
+
2. Run that `agent_spawn` unchanged. The reviewer is a top-level agent, not a
|
|
18
|
+
child of this conversation, so it does not inherit your context.
|
|
19
|
+
3. Pass the reviewer's packet, verbatim, to `devsys_review_record` with the
|
|
20
|
+
`diffDigest` the start reply gave (`packets: [...]`; all lens packets of one
|
|
21
|
+
round in one call). A packet is recorded once, in the round its header names;
|
|
22
|
+
if the diff changed since the start, the round is refused: start again.
|
|
23
|
+
4. Read the reply: `review: N/R clean` and `next:`.
|
|
24
|
+
- `fix-findings`: fix every blocking and should-fix finding, then start a new round.
|
|
25
|
+
- `review`: the streak is short (or the diff changed with findings); start another round.
|
|
26
|
+
- `stale-diff`: the diff changed after a satisfied review; one more round.
|
|
27
|
+
- `done`: commit.
|
|
28
|
+
|
|
29
|
+
## The packet
|
|
30
|
+
|
|
31
|
+
```markdown
|
|
32
|
+
## Review — <slice> — round <n> — lenses: <a, b>
|
|
33
|
+
### Sources inspected
|
|
34
|
+
- <path:line ranges>
|
|
35
|
+
### Findings
|
|
36
|
+
- [blocking|should-fix|nit] <lens> `<path>:<line>` — <one sentence> — <why it matters>
|
|
37
|
+
### Verdict
|
|
38
|
+
no-blocking | blocking
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A malformed packet is rejected with the reason; ask the reviewer to resend it.
|
|
42
|
+
The verdict must agree with the findings.
|
|
43
|
+
|
|
44
|
+
## Severity and the clean streak
|
|
45
|
+
|
|
46
|
+
- **blocking**: a demonstrable defect or a broken non-negotiable.
|
|
47
|
+
- **should-fix**: a real defect or missing test with a realistic trigger.
|
|
48
|
+
- **nit**: style or report-only. Nits never reset the streak; they go to
|
|
49
|
+
`docs/decisions/followups.md`.
|
|
50
|
+
- **false-positive**: refuted; ignored.
|
|
51
|
+
|
|
52
|
+
Jev may raise a finding's severity when it is at least 0.8 confident; it never
|
|
53
|
+
lowers a blocking or should-fix finding (it only suggests, in the reply, and you
|
|
54
|
+
re-check it yourself). A round is clean when it has no blocking or should-fix findings.
|
|
55
|
+
The default is three consecutive clean rounds (`review.required_clean_rounds`).
|
|
56
|
+
Only real findings reset the count; a changed diff alone does not, so a small fix
|
|
57
|
+
commit keeps the clean rounds already earned unless findings come back.
|
|
58
|
+
|
|
59
|
+
## Rules of thumb
|
|
60
|
+
|
|
61
|
+
- Fix the findings before the next round. Do not argue a finding away; if it is
|
|
62
|
+
wrong, the reviewer or Jev marks it a false positive with evidence.
|
|
63
|
+
- Do not widen a review into an audit of the tree. The reviewer reads the diff and
|
|
64
|
+
what it depends on.
|
|
65
|
+
- Never edit the packet to make it pass.
|
package/src/context/status.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reviewLabel, reviewOf } from "../core/review-flow.ts";
|
|
1
2
|
import type { DevsysState } from "../core/types.ts";
|
|
2
3
|
|
|
3
4
|
export const STATUS_KEY = "devsys";
|
|
@@ -12,8 +13,18 @@ const ciLabel = (state: DevsysState): string | undefined => {
|
|
|
12
13
|
return `ci ${ci.status}${sha}`;
|
|
13
14
|
};
|
|
14
15
|
|
|
16
|
+
const activeReview = (state: DevsysState): string | undefined => {
|
|
17
|
+
const review = state.activeSlice === undefined ? undefined : reviewOf(state, state.activeSlice);
|
|
18
|
+
return review === undefined ? undefined : reviewLabel(review);
|
|
19
|
+
};
|
|
20
|
+
|
|
15
21
|
export const renderStatusLine = (state: DevsysState, jevModel?: string): string =>
|
|
16
|
-
[
|
|
22
|
+
[
|
|
23
|
+
`devsys: ${state.phase}`,
|
|
24
|
+
`jev ${jevLabel(state, jevModel)}`,
|
|
25
|
+
ciLabel(state),
|
|
26
|
+
activeReview(state)?.replace("review: ", "review ").replace(" clean", ""),
|
|
27
|
+
]
|
|
17
28
|
.filter((part) => part !== undefined)
|
|
18
29
|
.join(" · ");
|
|
19
30
|
|
|
@@ -26,4 +37,5 @@ export const renderStatus = (state: DevsysState, jevModel?: string): string =>
|
|
|
26
37
|
`- open departures: ${state.openDepartures.length}`,
|
|
27
38
|
`- jev: ${jevLabel(state, jevModel)}`,
|
|
28
39
|
`- ci: ${ciLabel(state)?.slice(3) ?? "unknown"}`,
|
|
40
|
+
...(activeReview(state) === undefined ? [] : [`- ${activeReview(state)}`]),
|
|
29
41
|
].join("\n");
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cleanStreak,
|
|
3
|
+
type Finding,
|
|
4
|
+
nextAction,
|
|
5
|
+
type ReviewState,
|
|
6
|
+
type Severity,
|
|
7
|
+
} from "./review.ts";
|
|
8
|
+
import type { ReviewPacket } from "./review-packet.ts";
|
|
9
|
+
import type { DevsysState, SliceRef } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
/** Pure helpers around review state; the tools in src/review do the I/O. */
|
|
12
|
+
|
|
13
|
+
export const reviewOf = (state: DevsysState, slice: SliceRef): ReviewState | undefined =>
|
|
14
|
+
state.reviews?.find((r) => r.slice === slice);
|
|
15
|
+
|
|
16
|
+
export const upsertReview = (state: DevsysState, review: ReviewState): DevsysState => {
|
|
17
|
+
const others = (state.reviews ?? []).filter((r) => r.slice !== review.slice);
|
|
18
|
+
return { ...state, reviews: [...others, review] };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const reviewLabel = (review: ReviewState): string =>
|
|
22
|
+
`review: ${cleanStreak(review)}/${review.required} clean`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Why a commit on this slice lacks a satisfied review, or `undefined` when the review is complete
|
|
26
|
+
* on the diff as it is now (the `review.unsatisfied` soft gate is raised from this).
|
|
27
|
+
*/
|
|
28
|
+
export type DiffNow = {
|
|
29
|
+
readonly digest: string;
|
|
30
|
+
readonly files?: Readonly<Record<string, string>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Every file in the current diff was in the last round's diff, byte for byte: a part of what was reviewed. */
|
|
34
|
+
const coveredByLastRound = (review: ReviewState, now: DiffNow): boolean => {
|
|
35
|
+
const reviewed = review.rounds.at(-1)?.files;
|
|
36
|
+
if (reviewed === undefined || now.files === undefined) return false;
|
|
37
|
+
return Object.entries(now.files).every(([path, digest]) => reviewed[path] === digest);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function reviewGap(state: DevsysState, slice: SliceRef, now: DiffNow): string | undefined {
|
|
41
|
+
const review = reviewOf(state, slice);
|
|
42
|
+
if (review === undefined || review.rounds.length === 0) {
|
|
43
|
+
return `no review has been recorded for slice ${slice}`;
|
|
44
|
+
}
|
|
45
|
+
switch (nextAction(review, now.digest)) {
|
|
46
|
+
case "done":
|
|
47
|
+
return undefined;
|
|
48
|
+
case "fix-findings":
|
|
49
|
+
return `the last review round of slice ${slice} has blocking or should-fix findings still to fix`;
|
|
50
|
+
case "stale-diff":
|
|
51
|
+
// Committing a reviewed change in parts leaves a smaller diff made only of reviewed files.
|
|
52
|
+
if (coveredByLastRound(review, now)) return undefined;
|
|
53
|
+
return `the diff has changed since slice ${slice} was last reviewed; review it again`;
|
|
54
|
+
case "review":
|
|
55
|
+
return `slice ${slice} has ${cleanStreak(review)}/${review.required} clean review rounds`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A unified diff cut into one text per file, keyed by the file's new path. */
|
|
60
|
+
export function splitDiffByFile(diff: string): Record<string, string> {
|
|
61
|
+
const out: Record<string, string> = {};
|
|
62
|
+
let path: string | undefined;
|
|
63
|
+
let lines: string[] = [];
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (path !== undefined) out[path] = lines.join("\n");
|
|
66
|
+
};
|
|
67
|
+
for (const line of diff.split("\n")) {
|
|
68
|
+
const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
|
|
69
|
+
if (header !== null) {
|
|
70
|
+
flush();
|
|
71
|
+
path = header[1] ?? line;
|
|
72
|
+
lines = [];
|
|
73
|
+
}
|
|
74
|
+
lines.push(line);
|
|
75
|
+
}
|
|
76
|
+
flush();
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Packets from several lens reviewers in one round become one round of findings. */
|
|
81
|
+
export function combinePackets(packets: readonly ReviewPacket[]): {
|
|
82
|
+
lenses: string[];
|
|
83
|
+
findings: Finding[];
|
|
84
|
+
} {
|
|
85
|
+
const lenses = [...new Set(packets.flatMap((p) => p.lenses))];
|
|
86
|
+
const findings = packets.flatMap((packet, p) =>
|
|
87
|
+
packet.findings.map((f) => ({ ...f, id: `${f.id}.${p + 1}` })),
|
|
88
|
+
);
|
|
89
|
+
return { lenses, findings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Jev confidence needed before its severity replaces the reviewer's. */
|
|
93
|
+
export const SEVERITY_ADOPT_CONFIDENCE = 0.8;
|
|
94
|
+
|
|
95
|
+
const counts = (severity: Severity): boolean =>
|
|
96
|
+
severity === "blocking" || severity === "should-fix";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Take Jev's severity only when it is confident and different, and never to lower a finding that
|
|
100
|
+
* counts (blocking/should-fix) to one that does not: Jev reads a clipped diff, the reviewer read
|
|
101
|
+
* the code. That disagreement is reported as a suggestion and the reviewer's severity stands.
|
|
102
|
+
*/
|
|
103
|
+
export function adoptSeverity(
|
|
104
|
+
finding: Finding,
|
|
105
|
+
judged: { severity: Severity; confidence: number } | undefined,
|
|
106
|
+
): { finding: Finding; note?: string } {
|
|
107
|
+
if (
|
|
108
|
+
judged === undefined ||
|
|
109
|
+
judged.confidence < SEVERITY_ADOPT_CONFIDENCE ||
|
|
110
|
+
judged.severity === finding.severity
|
|
111
|
+
) {
|
|
112
|
+
return { finding };
|
|
113
|
+
}
|
|
114
|
+
const confidence = judged.confidence.toFixed(2);
|
|
115
|
+
if (counts(finding.severity) && !counts(judged.severity)) {
|
|
116
|
+
return {
|
|
117
|
+
finding,
|
|
118
|
+
note: `${finding.id}: Jev suggests ${judged.severity} instead of ${finding.severity} (confidence ${confidence}); the reviewer's severity stands, re-check the finding yourself`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
finding: { ...finding, severity: judged.severity },
|
|
123
|
+
note: `${finding.id}: Jev moved severity ${finding.severity} → ${judged.severity} (confidence ${confidence})`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const where = (f: Finding): string =>
|
|
128
|
+
f.path === undefined ? "" : ` \`${f.path}${f.line === undefined ? "" : `:${f.line}`}\``;
|
|
129
|
+
|
|
130
|
+
/** The nits of a round, as a section for docs/decisions/followups.md. */
|
|
131
|
+
export function renderFollowups(
|
|
132
|
+
slice: SliceRef,
|
|
133
|
+
round: number,
|
|
134
|
+
findings: readonly Finding[],
|
|
135
|
+
): string | undefined {
|
|
136
|
+
const nits = findings.filter((f) => f.severity === "nit");
|
|
137
|
+
if (nits.length === 0) return undefined;
|
|
138
|
+
const lines = nits.map((f) => `- ${f.lens}${where(f)} — ${f.summary}`);
|
|
139
|
+
return `\n## From ${slice} review round ${round}\n\n${lines.join("\n")}\n`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The task text for a fresh reviewer: slice, lenses, how to see the diff, and the packet to return. */
|
|
143
|
+
export function reviewerTask(input: {
|
|
144
|
+
slice: SliceRef;
|
|
145
|
+
round: number;
|
|
146
|
+
lenses: readonly string[];
|
|
147
|
+
diffRange: string;
|
|
148
|
+
}): string {
|
|
149
|
+
const lenses = input.lenses.join(", ");
|
|
150
|
+
return [
|
|
151
|
+
`Review slice ${input.slice}, round ${input.round}, through these lenses: ${lenses}.`,
|
|
152
|
+
`See the change with \`git diff ${input.diffRange}\` (and \`git diff --stat ${input.diffRange}\`), plus any untracked files listed by \`git status --short\` (git diff does not show them; read them directly). Read the callers, callees and tests it depends on, no more.`,
|
|
153
|
+
"Do not modify files; report demonstrable defects with a realistic trigger and the smallest local repair.",
|
|
154
|
+
"Return exactly the review packet from your instructions, with exactly this header (slice and round must not change):",
|
|
155
|
+
"",
|
|
156
|
+
`## Review — ${input.slice} — round ${input.round} — lenses: ${lenses}`,
|
|
157
|
+
"### Sources inspected",
|
|
158
|
+
"- <path:line ranges>",
|
|
159
|
+
"### Findings",
|
|
160
|
+
"- [blocking|should-fix|nit] <lens> `<path>:<line>` — <one sentence> — <why it matters>",
|
|
161
|
+
"### Verdict",
|
|
162
|
+
"no-blocking | blocking",
|
|
163
|
+
"",
|
|
164
|
+
"Severity: blocking = demonstrable defect or broken non-negotiable; should-fix = real defect or missing test with a realistic trigger; nit = style or report-only. Zero findings is a valid result.",
|
|
165
|
+
].join("\n");
|
|
166
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type Finding, SEVERITIES, type Severity } from "./review.ts";
|
|
2
|
+
import { isParseError, type ParseError, parseError } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export type ReviewPacket = {
|
|
5
|
+
readonly slice: string;
|
|
6
|
+
readonly round: number;
|
|
7
|
+
readonly lenses: ReadonlyArray<string>;
|
|
8
|
+
readonly sources: ReadonlyArray<string>;
|
|
9
|
+
readonly findings: ReadonlyArray<Finding>;
|
|
10
|
+
readonly verdict: "no-blocking" | "blocking";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SEP = "[—–-]";
|
|
14
|
+
const HEADER = new RegExp(
|
|
15
|
+
`^##\\s+Review\\s+${SEP}\\s+(.+?)\\s+${SEP}\\s+round\\s+(\\S+)\\s+${SEP}\\s+lenses:\\s*(.*)$`,
|
|
16
|
+
"m",
|
|
17
|
+
);
|
|
18
|
+
const FINDING = /^-\s*\[([^\]]+)\]\s+(\S+)\s+(?:`([^`]+)`\s+)?[—–-]\s+(.+)$/;
|
|
19
|
+
|
|
20
|
+
const section = (text: string, name: string): string | undefined => {
|
|
21
|
+
const match = new RegExp(`^###\\s+${name}\\s*$`, "im").exec(text);
|
|
22
|
+
if (match === null) return undefined;
|
|
23
|
+
const rest = text.slice(match.index + match[0].length);
|
|
24
|
+
const next = /^###?\s/m.exec(rest);
|
|
25
|
+
return next === null ? rest : rest.slice(0, next.index);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const isSeverity = (value: string): value is Severity =>
|
|
29
|
+
(SEVERITIES as readonly string[]).includes(value);
|
|
30
|
+
|
|
31
|
+
const locate = (target: string | undefined): { path?: string; line?: number } => {
|
|
32
|
+
if (target === undefined) return {};
|
|
33
|
+
const m = /^(.*?):(\d+)(?:-\d+)?$/.exec(target);
|
|
34
|
+
return m === null ? { path: target } : { path: m[1] ?? target, line: Number(m[2]) };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function parseFinding(line: string, lens: string[], index: number): Finding | ParseError {
|
|
38
|
+
const m = FINDING.exec(line);
|
|
39
|
+
if (m === null) {
|
|
40
|
+
return parseError(
|
|
41
|
+
`unparseable finding "${line}": expected "- [severity] <lens> \`path:line\` — <summary> — <why>"`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const severity = m[1] ?? "";
|
|
45
|
+
if (!isSeverity(severity)) {
|
|
46
|
+
return parseError(
|
|
47
|
+
`unknown severity "${severity}": use blocking, should-fix, nit or false-positive`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const findingLens = m[2] ?? "";
|
|
51
|
+
if (!lens.includes(findingLens)) lens.push(findingLens);
|
|
52
|
+
return {
|
|
53
|
+
id: `${findingLens}-${index}`,
|
|
54
|
+
severity,
|
|
55
|
+
...locate(m[3]),
|
|
56
|
+
summary: (m[4] ?? "").trim(),
|
|
57
|
+
lens: findingLens,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse the reviewer packet of plan Appendix A. Strict about structure, tolerant of dash style. */
|
|
62
|
+
export function parseReviewPacket(text: string): ReviewPacket | ParseError {
|
|
63
|
+
const header = HEADER.exec(text);
|
|
64
|
+
if (header === null) {
|
|
65
|
+
return parseError('missing packet header "## Review — <slice> — round <n> — lenses: <a, b>"');
|
|
66
|
+
}
|
|
67
|
+
const round = Number(header[2]);
|
|
68
|
+
if (!Number.isInteger(round) || round < 1) {
|
|
69
|
+
return parseError(`round "${header[2]}" is not a positive integer`);
|
|
70
|
+
}
|
|
71
|
+
const lenses = (header[3] ?? "")
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((l) => l.trim())
|
|
74
|
+
.filter((l) => l !== "");
|
|
75
|
+
const verdictText = section(text, "Verdict")?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
76
|
+
if (verdictText !== "no-blocking" && verdictText !== "blocking") {
|
|
77
|
+
return parseError(
|
|
78
|
+
'missing or invalid verdict: end the packet with "### Verdict" then no-blocking or blocking',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const findings: Finding[] = [];
|
|
82
|
+
const lines = (section(text, "Findings") ?? "")
|
|
83
|
+
.split("\n")
|
|
84
|
+
.map((l) => l.trim())
|
|
85
|
+
.filter((l) => l !== "" && !/^-?\s*none\.?$/i.test(l));
|
|
86
|
+
for (const [i, line] of lines.entries()) {
|
|
87
|
+
const finding = parseFinding(line, lenses, i + 1);
|
|
88
|
+
if (isParseError(finding)) return finding;
|
|
89
|
+
findings.push(finding);
|
|
90
|
+
}
|
|
91
|
+
const hasBlocking = findings.some(
|
|
92
|
+
(f) => f.severity === "blocking" || f.severity === "should-fix",
|
|
93
|
+
);
|
|
94
|
+
if (hasBlocking !== (verdictText === "blocking")) {
|
|
95
|
+
return parseError(
|
|
96
|
+
`verdict "${verdictText}" contradicts the findings: blocking/should-fix findings mean "blocking", otherwise "no-blocking"`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const sources = (section(text, "Sources inspected") ?? "")
|
|
100
|
+
.split("\n")
|
|
101
|
+
.map((l) => l.replace(/^-\s*/, "").trim())
|
|
102
|
+
.filter((l) => l !== "");
|
|
103
|
+
return {
|
|
104
|
+
slice: (header[1] ?? "").trim(),
|
|
105
|
+
round,
|
|
106
|
+
lenses,
|
|
107
|
+
sources,
|
|
108
|
+
findings,
|
|
109
|
+
verdict: verdictText,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { type ParseError, parseError, type SliceRef } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Severity of one review finding. Only blocking and should-fix make a round unclean. */
|
|
4
|
+
export type Severity = "blocking" | "should-fix" | "nit" | "false-positive";
|
|
5
|
+
|
|
6
|
+
export const SEVERITIES: readonly Severity[] = ["blocking", "should-fix", "nit", "false-positive"];
|
|
7
|
+
|
|
8
|
+
export type Finding = {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly severity: Severity;
|
|
11
|
+
readonly path?: string;
|
|
12
|
+
readonly line?: number;
|
|
13
|
+
readonly summary: string;
|
|
14
|
+
readonly lens: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ReviewRound = {
|
|
18
|
+
readonly n: number;
|
|
19
|
+
readonly lenses: ReadonlyArray<string>;
|
|
20
|
+
readonly findings: ReadonlyArray<Finding>;
|
|
21
|
+
readonly reviewedAt: string;
|
|
22
|
+
/** Digest of the diff the round reviewed (see `digestOf` in src/review/digest.ts). */
|
|
23
|
+
readonly diffDigest: string;
|
|
24
|
+
/** Per-file digests of the reviewed diff, so a commit of part of it is still covered. */
|
|
25
|
+
readonly files?: Readonly<Record<string, string>>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ReviewState = {
|
|
29
|
+
readonly slice: SliceRef;
|
|
30
|
+
readonly rounds: ReadonlyArray<ReviewRound>;
|
|
31
|
+
/** Consecutive clean rounds needed (config `review.required_clean_rounds`, at least `min_rounds`). */
|
|
32
|
+
readonly required: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ReviewAction = "review" | "fix-findings" | "done" | "stale-diff";
|
|
36
|
+
|
|
37
|
+
export const isClean = (round: ReviewRound): boolean =>
|
|
38
|
+
round.findings.every((f) => f.severity !== "blocking" && f.severity !== "should-fix");
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Trailing clean rounds. Real findings reset it; a changed diff alone does not, so a trivial fix
|
|
42
|
+
* commit keeps the clean rounds already earned unless findings reappear (decision D11).
|
|
43
|
+
*/
|
|
44
|
+
export function cleanStreak(state: ReviewState): number {
|
|
45
|
+
let streak = 0;
|
|
46
|
+
for (let i = state.rounds.length - 1; i >= 0; i--) {
|
|
47
|
+
const round = state.rounds[i];
|
|
48
|
+
if (round === undefined || !isClean(round)) break;
|
|
49
|
+
streak += 1;
|
|
50
|
+
}
|
|
51
|
+
return streak;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const isSatisfied = (state: ReviewState): boolean =>
|
|
55
|
+
state.rounds.length > 0 && cleanStreak(state) >= state.required;
|
|
56
|
+
|
|
57
|
+
/** What the coordinator should do next, given the digest of the diff as it is now. */
|
|
58
|
+
export function nextAction(state: ReviewState, diffDigestNow: string): ReviewAction {
|
|
59
|
+
const last = state.rounds.at(-1);
|
|
60
|
+
if (last === undefined) return "review";
|
|
61
|
+
if (!isClean(last)) return last.diffDigest === diffDigestNow ? "fix-findings" : "review";
|
|
62
|
+
if (!isSatisfied(state)) return "review";
|
|
63
|
+
return last.diffDigest === diffDigestNow ? "done" : "stale-diff";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const startReview = (slice: SliceRef, required: number): ReviewState => ({
|
|
67
|
+
slice,
|
|
68
|
+
required,
|
|
69
|
+
rounds: [],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const addRound = (state: ReviewState, round: Omit<ReviewRound, "n">): ReviewState => ({
|
|
73
|
+
...state,
|
|
74
|
+
rounds: [...state.rounds, { ...round, n: state.rounds.length + 1 }],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Review lenses Jev can select (plan I6.2). A lens applies at probability ≥ LENS_THRESHOLD. */
|
|
78
|
+
export const LENSES = [
|
|
79
|
+
"security",
|
|
80
|
+
"concurrency",
|
|
81
|
+
"types",
|
|
82
|
+
"tests",
|
|
83
|
+
"api-contract",
|
|
84
|
+
"data-migration",
|
|
85
|
+
"ux",
|
|
86
|
+
"performance",
|
|
87
|
+
] as const;
|
|
88
|
+
export type Lens = (typeof LENSES)[number];
|
|
89
|
+
export const LENS_THRESHOLD = 0.5;
|
|
90
|
+
|
|
91
|
+
/** Lenses that apply when Jev is offline: the ones every change deserves. */
|
|
92
|
+
export const DEFAULT_LENSES: ReadonlyArray<Lens> = ["types", "tests"];
|
|
93
|
+
|
|
94
|
+
export const selectLenses = (probabilities: Readonly<Record<Lens, number>>): Lens[] =>
|
|
95
|
+
LENSES.filter((lens) => probabilities[lens] >= LENS_THRESHOLD);
|
|
96
|
+
|
|
97
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
98
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
99
|
+
|
|
100
|
+
function parseFinding(input: unknown): Finding | ParseError {
|
|
101
|
+
if (!isRecord(input)) return parseError("finding must be an object");
|
|
102
|
+
const { id, severity, lens, summary, path, line } = input;
|
|
103
|
+
if (typeof id !== "string" || typeof lens !== "string" || typeof summary !== "string") {
|
|
104
|
+
return parseError("finding needs string id, lens and summary");
|
|
105
|
+
}
|
|
106
|
+
if (!SEVERITIES.includes(severity as Severity)) {
|
|
107
|
+
return parseError(`unknown severity: ${String(severity)}`);
|
|
108
|
+
}
|
|
109
|
+
if (path !== undefined && typeof path !== "string")
|
|
110
|
+
return parseError("finding path must be a string");
|
|
111
|
+
if (line !== undefined && typeof line !== "number")
|
|
112
|
+
return parseError("finding line must be a number");
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
lens,
|
|
116
|
+
summary,
|
|
117
|
+
severity: severity as Severity,
|
|
118
|
+
...(path !== undefined ? { path } : {}),
|
|
119
|
+
...(line !== undefined ? { line } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseFiles(input: unknown): Record<string, string> | undefined | null {
|
|
124
|
+
if (input === undefined) return undefined;
|
|
125
|
+
if (!isRecord(input)) return null;
|
|
126
|
+
const out: Record<string, string> = {};
|
|
127
|
+
for (const [path, digest] of Object.entries(input)) {
|
|
128
|
+
if (typeof digest !== "string") return null;
|
|
129
|
+
out[path] = digest;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseRound(input: unknown): ReviewRound | ParseError {
|
|
135
|
+
if (!isRecord(input)) return parseError("round must be an object");
|
|
136
|
+
const { n, lenses, findings, reviewedAt, diffDigest } = input;
|
|
137
|
+
if (typeof n !== "number" || typeof reviewedAt !== "string" || typeof diffDigest !== "string") {
|
|
138
|
+
return parseError("round needs number n and string reviewedAt and diffDigest");
|
|
139
|
+
}
|
|
140
|
+
if (!Array.isArray(lenses) || !lenses.every((l) => typeof l === "string")) {
|
|
141
|
+
return parseError("round lenses must be an array of strings");
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(findings)) return parseError("round findings must be an array");
|
|
144
|
+
const parsed: Finding[] = [];
|
|
145
|
+
for (const raw of findings) {
|
|
146
|
+
const finding = parseFinding(raw);
|
|
147
|
+
if ("kind" in finding) return finding;
|
|
148
|
+
parsed.push(finding);
|
|
149
|
+
}
|
|
150
|
+
const files = parseFiles(input.files);
|
|
151
|
+
if (files === null) return parseError("round files must map paths to digest strings");
|
|
152
|
+
return {
|
|
153
|
+
n,
|
|
154
|
+
lenses: lenses as string[],
|
|
155
|
+
findings: parsed,
|
|
156
|
+
reviewedAt,
|
|
157
|
+
diffDigest,
|
|
158
|
+
...(files === undefined ? {} : { files }),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Boundary parse for a persisted review (one slice's rounds). */
|
|
163
|
+
export function parseReviewState(input: unknown): ReviewState | ParseError {
|
|
164
|
+
if (!isRecord(input)) return parseError("review must be an object");
|
|
165
|
+
const { slice, required, rounds } = input;
|
|
166
|
+
if (typeof slice !== "string" || slice === "") return parseError("review slice must be a string");
|
|
167
|
+
if (typeof required !== "number" || !Number.isInteger(required) || required < 1) {
|
|
168
|
+
return parseError("review required must be an integer of at least 1");
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(rounds)) return parseError("review rounds must be an array");
|
|
171
|
+
const parsed: ReviewRound[] = [];
|
|
172
|
+
for (const raw of rounds) {
|
|
173
|
+
const round = parseRound(raw);
|
|
174
|
+
if ("kind" in round) return round;
|
|
175
|
+
parsed.push(round);
|
|
176
|
+
}
|
|
177
|
+
return { slice: slice as SliceRef, required, rounds: parsed };
|
|
178
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReviewState } from "./review.ts";
|
|
2
|
+
|
|
1
3
|
/** Pure domain types for the development system. No I/O lives here. */
|
|
2
4
|
|
|
3
5
|
export type ParseError = { readonly kind: "parse-error"; readonly message: string };
|
|
@@ -82,6 +84,8 @@ export type DevsysState = {
|
|
|
82
84
|
readonly ci?: CiState;
|
|
83
85
|
readonly profiles?: ReadonlyArray<Profile>;
|
|
84
86
|
readonly lastTestRun?: TestRun;
|
|
87
|
+
/** One review record per slice (see src/core/review.ts). */
|
|
88
|
+
readonly reviews?: ReadonlyArray<ReviewState>;
|
|
85
89
|
};
|
|
86
90
|
|
|
87
91
|
export const initialState = (): DevsysState => ({
|
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
parseConventionalCommit,
|
|
14
14
|
} from "../core/commit-message.ts";
|
|
15
15
|
import type { Exec } from "../core/exec.ts";
|
|
16
|
+
import { reviewGap } from "../core/review-flow.ts";
|
|
16
17
|
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
17
18
|
import type { Jev } from "../jev/client.ts";
|
|
18
19
|
import { judgeCommit, MIX_THRESHOLD, RATIONALE_FLOOR } from "../jev/questions/commit.ts";
|
|
20
|
+
import { snapshotDiff } from "../review/digest.ts";
|
|
19
21
|
import type { SessionState } from "../state/session-state.ts";
|
|
20
22
|
import { departureUse } from "./departure-use.ts";
|
|
21
23
|
|
|
@@ -36,6 +38,7 @@ const gateId = (id: string): GateId => {
|
|
|
36
38
|
|
|
37
39
|
const RATIONALE = gateId("commit.rationale");
|
|
38
40
|
const MIXED = gateId("commit.mixed-change");
|
|
41
|
+
const REVIEW = gateId("review.unsatisfied");
|
|
39
42
|
|
|
40
43
|
const readMessageFile = (cwd: string, path: string): string | undefined => {
|
|
41
44
|
try {
|
|
@@ -115,11 +118,33 @@ async function jevNeeds(
|
|
|
115
118
|
return needs;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
/** While work is in flight, committing needs the slice review satisfied on the diff as it is now. */
|
|
122
|
+
async function reviewNeeds(deps: CommitGuardDeps, ctx: ExtensionContext): Promise<Need[]> {
|
|
123
|
+
const { phase, activeSlice } = deps.state.get();
|
|
124
|
+
if ((phase !== "implementing" && phase !== "reviewing") || activeSlice === undefined) return [];
|
|
125
|
+
const snap = await snapshotDiff(deps.exec, ctx.cwd, "HEAD");
|
|
126
|
+
// An unreadable diff cannot be compared; the gate asks for a departure rather than guessing.
|
|
127
|
+
const gap = reviewGap(
|
|
128
|
+
deps.state.get(),
|
|
129
|
+
activeSlice,
|
|
130
|
+
snap.ok ? { digest: snap.value.digest, files: snap.value.files } : { digest: "unknown" },
|
|
131
|
+
);
|
|
132
|
+
return gap === undefined ? [] : [{ gate: REVIEW, why: gap }];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const reviewReason = (need: Need): string =>
|
|
136
|
+
`${need.gate}: ${need.why}. Each slice is reviewed by a fresh-context reviewer before it is committed. ` +
|
|
137
|
+
"Run devsys_review_start, spawn the reviewer it describes, record the packet with devsys_review_record " +
|
|
138
|
+
"and repeat until the review is satisfied. If skipping review is deliberate call devsys_record_departure " +
|
|
139
|
+
`with gate "${need.gate}", what you are doing instead, why, and the cost if wrong; then retry.`;
|
|
140
|
+
|
|
118
141
|
const blockReason = (need: Need): string =>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
need.gate === REVIEW
|
|
143
|
+
? reviewReason(need)
|
|
144
|
+
: `${need.gate}: ${need.why}. Commit messages carry their rationale and structural and behavioural ` +
|
|
145
|
+
"changes go in separate commits. Fix the commit (split it, or write the why in the body), or if " +
|
|
146
|
+
`departing is deliberate call devsys_record_departure with gate "${need.gate}", what you are doing ` +
|
|
147
|
+
"instead, why, and the cost if wrong; then retry.";
|
|
123
148
|
|
|
124
149
|
const forbiddenReason = (found: readonly string[]): string =>
|
|
125
150
|
`commit.forbidden-trailer: this commit carries an AI attribution (${found.join("; ")}). ` +
|
|
@@ -162,7 +187,7 @@ export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
|
162
187
|
if (forbidden.length > 0) return { block: true, reason: forbiddenReason(forbidden) };
|
|
163
188
|
|
|
164
189
|
const known = messages.flatMap((m) => (m === undefined ? [] : [m]));
|
|
165
|
-
const all: Need[] =
|
|
190
|
+
const all: Need[] = await reviewNeeds(deps, ctx);
|
|
166
191
|
// A -F file that cannot be read now (written by this same command, or stdin) cannot be checked.
|
|
167
192
|
if (extractions.some((e, i) => e.kind === "file" && messages[i] === undefined)) {
|
|
168
193
|
all.push({
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { ClassifierBoolQuestion, ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
|
|
2
|
+
import { redactSecrets } from "../../core/redact.ts";
|
|
3
|
+
import { err, ok, type Result } from "../../core/result.ts";
|
|
4
|
+
import { LENSES, type Lens, SEVERITIES, type Severity } from "../../core/review.ts";
|
|
5
|
+
import type { Jev, JevError } from "../client.ts";
|
|
6
|
+
|
|
7
|
+
const lensQuestion = (instructions: string, yes: string, no: string): ClassifierBoolQuestion => ({
|
|
8
|
+
type: "bool",
|
|
9
|
+
instructions,
|
|
10
|
+
criteria: { true: yes, false: no },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const LENS_QUESTIONS: Readonly<Record<Lens, ClassifierBoolQuestion>> = {
|
|
14
|
+
security: lensQuestion(
|
|
15
|
+
"Does the change in `diffSample` (stat in `diffStat`) touch authentication, authorisation, secrets, cryptography, input parsing from untrusted sources, shell or SQL construction, file paths from users, or network exposure?",
|
|
16
|
+
"It handles untrusted input, credentials, permissions or external exposure",
|
|
17
|
+
"It does not touch any security-relevant surface",
|
|
18
|
+
),
|
|
19
|
+
concurrency: lensQuestion(
|
|
20
|
+
"Does the change introduce or alter concurrency: threads, async ordering, locks, shared mutable state, retries with side effects, queues, or race-prone file or process handling?",
|
|
21
|
+
"Ordering, sharing or timing between concurrent actors matters to correctness",
|
|
22
|
+
"The code is sequential and has no shared mutable state at risk",
|
|
23
|
+
),
|
|
24
|
+
types: lensQuestion(
|
|
25
|
+
"Does the change add or alter domain types, parsing of external input, error types, state machines or public function signatures where illegal states could become representable?",
|
|
26
|
+
"It models domain data, parses input, or changes types or errors",
|
|
27
|
+
"It changes no types, parsing or error handling",
|
|
28
|
+
),
|
|
29
|
+
tests: lensQuestion(
|
|
30
|
+
"Does the change alter behaviour that tests should pin, or alter tests themselves (added, edited, loosened, skipped or deleted)?",
|
|
31
|
+
"Behaviour or tests changed and test adequacy deserves a look",
|
|
32
|
+
"Docs, formatting or configuration only; no behaviour or tests changed",
|
|
33
|
+
),
|
|
34
|
+
"api-contract": lensQuestion(
|
|
35
|
+
"Does the change alter a public interface: exported functions, CLI flags, tool schemas, file formats, configuration keys, wire formats or documented behaviour that other code or users rely on?",
|
|
36
|
+
"Something other code or users depend on changed shape or meaning",
|
|
37
|
+
"Only internals changed; no external contract is affected",
|
|
38
|
+
),
|
|
39
|
+
"data-migration": lensQuestion(
|
|
40
|
+
"Does the change alter persisted data shape: schemas, stored entries, migrations, serialised formats, or how existing stored data is read?",
|
|
41
|
+
"Existing stored data must still be read, migrated or preserved correctly",
|
|
42
|
+
"No persisted data shape changes",
|
|
43
|
+
),
|
|
44
|
+
ux: lensQuestion(
|
|
45
|
+
"Does the change alter what a person sees or does: messages, prompts, commands, output formats, error text, documentation of workflow, or interaction flow?",
|
|
46
|
+
"A human-facing message, flow or output changed",
|
|
47
|
+
"Nothing a person reads or interacts with changed",
|
|
48
|
+
),
|
|
49
|
+
performance: lensQuestion(
|
|
50
|
+
"Does the change alter work done per request, per item or per run: loops over large inputs, I/O in hot paths, caching, polling, or algorithmic complexity?",
|
|
51
|
+
"Cost grows with input size or call frequency in a way that matters",
|
|
52
|
+
"No meaningful change to time or memory behaviour",
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const SEVERITY_QUESTION: ClassifierChoiceQuestion = {
|
|
57
|
+
type: "choice",
|
|
58
|
+
instructions:
|
|
59
|
+
"Given the review `finding` and the surrounding `diffContext`, how severe is it? Judge by the consequence of shipping it, and by whether the reviewer showed a realistic triggering input. A finding with no demonstrated trigger, or one the code already guards against, is a false positive.",
|
|
60
|
+
criteria: {
|
|
61
|
+
blocking:
|
|
62
|
+
"A demonstrable defect or broken principle: wrong behaviour, data loss, security hole, or a defeated safeguard on a realistic input",
|
|
63
|
+
"should-fix":
|
|
64
|
+
"A real defect or missing test with a realistic trigger, but limited impact or easy workaround",
|
|
65
|
+
nit: "Style, naming, a contrived trigger, or a suggestion with no demonstrated impact",
|
|
66
|
+
"false-positive": "The claim is wrong or the code already handles the case",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const DIFF_SAMPLE_MAX = 8000;
|
|
71
|
+
const clip = (text: string, max: number): string =>
|
|
72
|
+
redactSecrets(text.slice(0, max * 2)).slice(0, max);
|
|
73
|
+
|
|
74
|
+
export type LensInput = {
|
|
75
|
+
diffStat: string;
|
|
76
|
+
diffSample: string;
|
|
77
|
+
profiles: readonly string[];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Probability per lens that it applies. One narrow question per lens, asked together. */
|
|
81
|
+
export async function judgeLenses(
|
|
82
|
+
jev: Jev,
|
|
83
|
+
input: LensInput,
|
|
84
|
+
): Promise<Result<Record<Lens, number>, JevError>> {
|
|
85
|
+
const asked = await jev.ask(
|
|
86
|
+
{
|
|
87
|
+
diffStat: clip(input.diffStat, 2000),
|
|
88
|
+
diffSample: clip(input.diffSample, DIFF_SAMPLE_MAX),
|
|
89
|
+
profiles: input.profiles.slice(0, 5).map((p) => clip(p, 40)),
|
|
90
|
+
},
|
|
91
|
+
LENS_QUESTIONS,
|
|
92
|
+
);
|
|
93
|
+
if (!asked.ok) return asked;
|
|
94
|
+
const out = {} as Record<Lens, number>;
|
|
95
|
+
for (const lens of LENSES) {
|
|
96
|
+
const answer = asked.value[lens];
|
|
97
|
+
if (answer?.type !== "bool" || !Number.isFinite(answer.probability)) {
|
|
98
|
+
return err({ kind: "provider", message: `missing or malformed answer for lens ${lens}` });
|
|
99
|
+
}
|
|
100
|
+
out[lens] = answer.probability;
|
|
101
|
+
}
|
|
102
|
+
return ok(out);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type SeverityInput = { finding: string; diffContext: string };
|
|
106
|
+
|
|
107
|
+
export async function judgeSeverity(
|
|
108
|
+
jev: Jev,
|
|
109
|
+
input: SeverityInput,
|
|
110
|
+
): Promise<Result<{ severity: Severity; confidence: number }, JevError>> {
|
|
111
|
+
const asked = await jev.ask(
|
|
112
|
+
{ finding: clip(input.finding, 2000), diffContext: clip(input.diffContext, 6000) },
|
|
113
|
+
{ severity: SEVERITY_QUESTION },
|
|
114
|
+
);
|
|
115
|
+
if (!asked.ok) return asked;
|
|
116
|
+
const answer = asked.value.severity;
|
|
117
|
+
if (answer?.type !== "choice") {
|
|
118
|
+
return err({ kind: "provider", message: "missing severity answer" });
|
|
119
|
+
}
|
|
120
|
+
const label = SEVERITIES.find((s) => s === answer.choice);
|
|
121
|
+
if (label === undefined) {
|
|
122
|
+
return err({ kind: "provider", message: `unknown severity label "${answer.choice}"` });
|
|
123
|
+
}
|
|
124
|
+
return ok({ severity: label, confidence: answer.confidence });
|
|
125
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Exec } from "../core/exec.ts";
|
|
3
|
+
import { err, ok, type Result } from "../core/result.ts";
|
|
4
|
+
import { splitDiffByFile } from "../core/review-flow.ts";
|
|
5
|
+
|
|
6
|
+
/** A short, stable identity for some text. Pure. */
|
|
7
|
+
export const digestOf = (text: string): string =>
|
|
8
|
+
createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
9
|
+
|
|
10
|
+
export type DiffSnapshot = {
|
|
11
|
+
/** Identity of everything reviewed: the tracked diff plus the content of untracked files. */
|
|
12
|
+
digest: string;
|
|
13
|
+
/** Per-file digests, tracked and untracked, keyed by path. */
|
|
14
|
+
files: Record<string, string>;
|
|
15
|
+
stat: string;
|
|
16
|
+
sample: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const MAX_UNTRACKED = 200;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The change in `range` (default `HEAD`: everything not yet committed) with its digest. `git diff`
|
|
23
|
+
* leaves out untracked files, so for a range that includes the work tree they are listed and
|
|
24
|
+
* content-hashed too; otherwise a new file would be committed unreviewed.
|
|
25
|
+
*/
|
|
26
|
+
export async function snapshotDiff(
|
|
27
|
+
exec: Exec,
|
|
28
|
+
cwd: string,
|
|
29
|
+
range: string,
|
|
30
|
+
): Promise<Result<DiffSnapshot, string>> {
|
|
31
|
+
try {
|
|
32
|
+
const [diff, stat, others] = await Promise.all([
|
|
33
|
+
exec("git", ["diff", range], { cwd, timeout: 15_000 }),
|
|
34
|
+
exec("git", ["diff", "--stat", range], { cwd, timeout: 15_000 }),
|
|
35
|
+
range === "HEAD"
|
|
36
|
+
? exec("git", ["ls-files", "--others", "--exclude-standard"], { cwd, timeout: 15_000 })
|
|
37
|
+
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
|
38
|
+
]);
|
|
39
|
+
if (diff.code !== 0) return err(diff.stderr.trim() || `git diff ${range} failed`);
|
|
40
|
+
const untracked = others.code === 0 ? others.stdout.split("\n").filter((p) => p !== "") : [];
|
|
41
|
+
const files: Record<string, string> = {};
|
|
42
|
+
for (const [path, text] of Object.entries(splitDiffByFile(diff.stdout))) {
|
|
43
|
+
files[path] = digestOf(text);
|
|
44
|
+
}
|
|
45
|
+
if (untracked.length > 0) {
|
|
46
|
+
const shown = untracked.slice(0, MAX_UNTRACKED);
|
|
47
|
+
const hashes = await exec("git", ["hash-object", "--", ...shown], { cwd, timeout: 15_000 });
|
|
48
|
+
const lines = hashes.code === 0 ? hashes.stdout.split("\n").filter((l) => l !== "") : [];
|
|
49
|
+
for (const [i, path] of shown.entries()) files[path] = `new:${lines[i] ?? "unhashed"}`;
|
|
50
|
+
}
|
|
51
|
+
const names = Object.keys(files).sort();
|
|
52
|
+
const untrackedStat = untracked.map((p) => ` ${p} (untracked)`).join("\n");
|
|
53
|
+
return ok({
|
|
54
|
+
digest: digestOf(names.map((p) => `${p}\0${files[p]}`).join("\n")),
|
|
55
|
+
files,
|
|
56
|
+
stat: [stat.stdout.trimEnd(), untrackedStat].filter((p) => p !== "").join("\n"),
|
|
57
|
+
sample: diff.stdout.slice(0, 16_000),
|
|
58
|
+
});
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
return err(cause instanceof Error ? cause.message : String(cause));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { type Static, Type } from "typebox";
|
|
5
|
+
import type { Exec } from "../core/exec.ts";
|
|
6
|
+
import { resolveSlot } from "../core/models.ts";
|
|
7
|
+
import {
|
|
8
|
+
addRound,
|
|
9
|
+
DEFAULT_LENSES,
|
|
10
|
+
type Finding,
|
|
11
|
+
nextAction,
|
|
12
|
+
type ReviewState,
|
|
13
|
+
selectLenses,
|
|
14
|
+
startReview,
|
|
15
|
+
} from "../core/review.ts";
|
|
16
|
+
import {
|
|
17
|
+
adoptSeverity,
|
|
18
|
+
combinePackets,
|
|
19
|
+
renderFollowups,
|
|
20
|
+
reviewerTask,
|
|
21
|
+
reviewLabel,
|
|
22
|
+
reviewOf,
|
|
23
|
+
upsertReview,
|
|
24
|
+
} from "../core/review-flow.ts";
|
|
25
|
+
import { parseReviewPacket, type ReviewPacket } from "../core/review-packet.ts";
|
|
26
|
+
import { isParseError, type SliceRef } from "../core/types.ts";
|
|
27
|
+
import type { Jev } from "../jev/client.ts";
|
|
28
|
+
import { judgeLenses, judgeSeverity } from "../jev/questions/review.ts";
|
|
29
|
+
import { CONFIG_FILE, loadConfig } from "../state/config.ts";
|
|
30
|
+
import { availableModels } from "../state/models-command.ts";
|
|
31
|
+
import type { SessionState } from "../state/session-state.ts";
|
|
32
|
+
import { snapshotDiff } from "./digest.ts";
|
|
33
|
+
|
|
34
|
+
export type ReviewToolDeps = {
|
|
35
|
+
state: SessionState;
|
|
36
|
+
jev: (ctx: ExtensionContext) => Jev;
|
|
37
|
+
exec: Exec;
|
|
38
|
+
now?: () => Date;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const reply = (payload: string, isError = false) => ({
|
|
42
|
+
content: [{ type: "text" as const, text: payload }],
|
|
43
|
+
details: undefined,
|
|
44
|
+
isError,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const sliceOf = (given: string | undefined, state: SessionState): SliceRef | undefined => {
|
|
48
|
+
const slice = given?.trim() || state.get().activeSlice;
|
|
49
|
+
return slice === undefined || slice === "" ? undefined : (slice as SliceRef);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const NO_SLICE =
|
|
53
|
+
"no slice: pass `slice`, or start work on a slice first (there is no active slice)";
|
|
54
|
+
|
|
55
|
+
const StartParameters = Type.Object({
|
|
56
|
+
slice: Type.Optional(
|
|
57
|
+
Type.String({ description: "Slice being reviewed; defaults to the active slice." }),
|
|
58
|
+
),
|
|
59
|
+
diffRange: Type.Optional(
|
|
60
|
+
Type.String({
|
|
61
|
+
description: "Git revision range to review; defaults to HEAD (all uncommitted changes).",
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** One path segment is at most 64 chars (src/subagents/orch/paths.ts); keep room for round and attempt. */
|
|
67
|
+
const pathSafe = (slice: string): string =>
|
|
68
|
+
slice
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
71
|
+
.replace(/^-+|-+$/g, "")
|
|
72
|
+
.slice(0, 32);
|
|
73
|
+
|
|
74
|
+
/** `devsys_review_start`: Jev picks lenses for the diff; the reply carries the agent_spawn payload for a fresh reviewer. */
|
|
75
|
+
export function createReviewStartTool(
|
|
76
|
+
deps: ReviewToolDeps,
|
|
77
|
+
): ToolDefinition<typeof StartParameters> {
|
|
78
|
+
return {
|
|
79
|
+
name: "devsys_review_start",
|
|
80
|
+
label: "Start review round",
|
|
81
|
+
description:
|
|
82
|
+
"Begin a review round for a slice: computes the diff digest, chooses review lenses, and returns the agent_spawn payload for a fresh-context reviewer. Run that spawn, then pass the reviewer's packet to devsys_review_record.",
|
|
83
|
+
promptSnippet: "Start a fresh-context review round for a slice",
|
|
84
|
+
parameters: StartParameters,
|
|
85
|
+
exposure: "direct",
|
|
86
|
+
async execute(
|
|
87
|
+
_id,
|
|
88
|
+
params: Static<typeof StartParameters>,
|
|
89
|
+
_signal,
|
|
90
|
+
_onUpdate,
|
|
91
|
+
ctx: ExtensionContext,
|
|
92
|
+
) {
|
|
93
|
+
const slice = sliceOf(params.slice, deps.state);
|
|
94
|
+
if (slice === undefined) return reply(NO_SLICE, true);
|
|
95
|
+
const config = await loadConfig(ctx.cwd);
|
|
96
|
+
if (!config.ok) return reply(`${CONFIG_FILE}: ${config.error.message}`, true);
|
|
97
|
+
const range = params.diffRange?.trim() || "HEAD";
|
|
98
|
+
const snap = await snapshotDiff(deps.exec, ctx.cwd, range);
|
|
99
|
+
if (!snap.ok) return reply(`cannot read the diff for ${range}: ${snap.error}`, true);
|
|
100
|
+
if (snap.value.stat.trim() === "")
|
|
101
|
+
return reply(`the diff for ${range} is empty; nothing to review`, true);
|
|
102
|
+
|
|
103
|
+
const judged = await judgeLenses(deps.jev(ctx), {
|
|
104
|
+
diffStat: snap.value.stat,
|
|
105
|
+
diffSample: snap.value.sample,
|
|
106
|
+
profiles: deps.state.get().profiles ?? [],
|
|
107
|
+
});
|
|
108
|
+
const selected = judged.ok ? selectLenses(judged.value) : [];
|
|
109
|
+
const lenses = selected.length > 0 ? selected : [...DEFAULT_LENSES];
|
|
110
|
+
const basis = judged.ok
|
|
111
|
+
? selected.length > 0
|
|
112
|
+
? "Jev chose the lenses."
|
|
113
|
+
: "Jev found no specific lens; using the defaults."
|
|
114
|
+
: `Jev unavailable (${judged.error.kind}); using the default lenses.`;
|
|
115
|
+
|
|
116
|
+
const required = Math.max(
|
|
117
|
+
config.value.review.requiredCleanRounds,
|
|
118
|
+
config.value.review.minRounds,
|
|
119
|
+
);
|
|
120
|
+
const existing = reviewOf(deps.state.get(), slice) ?? startReview(slice, required);
|
|
121
|
+
const review: ReviewState = { ...existing, required };
|
|
122
|
+
deps.state.update((s) => upsertReview(s, review));
|
|
123
|
+
const action = nextAction(review, snap.value.digest);
|
|
124
|
+
if (action === "done") {
|
|
125
|
+
return reply(
|
|
126
|
+
`${reviewLabel(review)}. Review of ${slice} is already satisfied on this diff; nothing to run.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const round = review.rounds.length + 1;
|
|
131
|
+
const resolved = resolveSlot(
|
|
132
|
+
config.value.models,
|
|
133
|
+
"reviewer",
|
|
134
|
+
availableModels(ctx.modelRegistry),
|
|
135
|
+
);
|
|
136
|
+
const spawn = {
|
|
137
|
+
// A fresh path per attempt: a failed spawn must not leave a thread that blocks the retry.
|
|
138
|
+
path: `/review-${pathSafe(slice)}-r${round}-${(deps.now?.() ?? new Date()).getTime().toString(36)}`,
|
|
139
|
+
type: "reviewer",
|
|
140
|
+
task: reviewerTask({ slice, round, lenses, diffRange: range }),
|
|
141
|
+
...(resolved.ok ? { model: resolved.value.model } : {}),
|
|
142
|
+
thinkingLevel: "high",
|
|
143
|
+
wait: true,
|
|
144
|
+
};
|
|
145
|
+
return reply(
|
|
146
|
+
[
|
|
147
|
+
`${reviewLabel(review)}; round ${round}; diff ${snap.value.digest}. ${basis}`,
|
|
148
|
+
`lenses: ${lenses.join(", ")}`,
|
|
149
|
+
`Spawn the reviewer with agent_spawn using exactly this payload, then pass its packet to devsys_review_record with diffDigest "${snap.value.digest}":`,
|
|
150
|
+
JSON.stringify(spawn),
|
|
151
|
+
].join("\n"),
|
|
152
|
+
);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const RecordParameters = Type.Object({
|
|
158
|
+
slice: Type.Optional(
|
|
159
|
+
Type.String({ description: "Slice reviewed; defaults to the active slice." }),
|
|
160
|
+
),
|
|
161
|
+
packets: Type.Array(Type.String(), {
|
|
162
|
+
description: "Reviewer packets, verbatim markdown: one per lens reviewer, all for one round.",
|
|
163
|
+
}),
|
|
164
|
+
diffRange: Type.Optional(
|
|
165
|
+
Type.String({ description: "The range that was reviewed; defaults to HEAD." }),
|
|
166
|
+
),
|
|
167
|
+
diffDigest: Type.String({
|
|
168
|
+
description:
|
|
169
|
+
"The diff digest devsys_review_start reported for this round; the round is refused if the diff has changed since.",
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
async function adjusted(
|
|
174
|
+
deps: ReviewToolDeps,
|
|
175
|
+
ctx: ExtensionContext,
|
|
176
|
+
findings: readonly Finding[],
|
|
177
|
+
diffContext: string,
|
|
178
|
+
): Promise<{ findings: Finding[]; notes: string[] }> {
|
|
179
|
+
const out: Finding[] = [];
|
|
180
|
+
const notes: string[] = [];
|
|
181
|
+
for (const f of findings) {
|
|
182
|
+
const judged = await judgeSeverity(deps.jev(ctx), {
|
|
183
|
+
finding: `[${f.severity}] ${f.lens} ${f.path ?? ""}${f.line === undefined ? "" : `:${f.line}`} — ${f.summary}`,
|
|
184
|
+
diffContext,
|
|
185
|
+
});
|
|
186
|
+
const next = adoptSeverity(f, judged.ok ? judged.value : undefined);
|
|
187
|
+
out.push(next.finding);
|
|
188
|
+
if (next.note !== undefined) notes.push(next.note);
|
|
189
|
+
}
|
|
190
|
+
return { findings: out, notes };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `devsys_review_record`: parse packets, let Jev adjust severities, record the round, file the nits. */
|
|
194
|
+
export function createReviewRecordTool(
|
|
195
|
+
deps: ReviewToolDeps,
|
|
196
|
+
): ToolDefinition<typeof RecordParameters> {
|
|
197
|
+
return {
|
|
198
|
+
name: "devsys_review_record",
|
|
199
|
+
label: "Record review round",
|
|
200
|
+
description:
|
|
201
|
+
"Record one review round from the reviewer's packet(s): parses findings, lets Jev adjust severity when confident, files nits in docs/decisions/followups.md, and returns the clean-round count and the next action.",
|
|
202
|
+
promptSnippet: "Record a reviewer packet as a review round",
|
|
203
|
+
parameters: RecordParameters,
|
|
204
|
+
exposure: "direct",
|
|
205
|
+
async execute(
|
|
206
|
+
_id,
|
|
207
|
+
params: Static<typeof RecordParameters>,
|
|
208
|
+
_signal,
|
|
209
|
+
_onUpdate,
|
|
210
|
+
ctx: ExtensionContext,
|
|
211
|
+
) {
|
|
212
|
+
const slice = sliceOf(params.slice, deps.state);
|
|
213
|
+
if (slice === undefined) return reply(NO_SLICE, true);
|
|
214
|
+
if (params.packets.length === 0)
|
|
215
|
+
return reply("no packets: pass the reviewer's packet markdown in `packets`", true);
|
|
216
|
+
const packets: ReviewPacket[] = [];
|
|
217
|
+
for (const [i, raw] of params.packets.entries()) {
|
|
218
|
+
const parsed = parseReviewPacket(raw);
|
|
219
|
+
if (isParseError(parsed))
|
|
220
|
+
return reply(`packet ${i + 1} is malformed: ${parsed.message}`, true);
|
|
221
|
+
packets.push(parsed);
|
|
222
|
+
}
|
|
223
|
+
const config = await loadConfig(ctx.cwd);
|
|
224
|
+
if (!config.ok) return reply(`${CONFIG_FILE}: ${config.error.message}`, true);
|
|
225
|
+
const snap = await snapshotDiff(deps.exec, ctx.cwd, params.diffRange?.trim() || "HEAD");
|
|
226
|
+
if (!snap.ok) return reply(`cannot read the diff: ${snap.error}`, true);
|
|
227
|
+
|
|
228
|
+
if (params.diffDigest !== snap.value.digest) {
|
|
229
|
+
return reply(
|
|
230
|
+
`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.`,
|
|
231
|
+
true,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const required = Math.max(
|
|
235
|
+
config.value.review.requiredCleanRounds,
|
|
236
|
+
config.value.review.minRounds,
|
|
237
|
+
);
|
|
238
|
+
const before = reviewOf(deps.state.get(), slice) ?? startReview(slice, required);
|
|
239
|
+
const expected = before.rounds.length + 1;
|
|
240
|
+
const wrong = packets.findIndex((p) => p.round !== expected || p.slice !== slice);
|
|
241
|
+
if (wrong !== -1) {
|
|
242
|
+
return reply(
|
|
243
|
+
`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.`,
|
|
244
|
+
true,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const merged = combinePackets(packets);
|
|
249
|
+
const { findings, notes } = await adjusted(deps, ctx, merged.findings, snap.value.sample);
|
|
250
|
+
const review = addRound(
|
|
251
|
+
{ ...before, required },
|
|
252
|
+
{
|
|
253
|
+
lenses: merged.lenses,
|
|
254
|
+
findings,
|
|
255
|
+
reviewedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
256
|
+
diffDigest: snap.value.digest,
|
|
257
|
+
files: snap.value.files,
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// File the nits first: if this fails nothing is recorded and a retry cannot count the round twice.
|
|
262
|
+
const followups = renderFollowups(slice, review.rounds.length, findings);
|
|
263
|
+
if (followups !== undefined) {
|
|
264
|
+
try {
|
|
265
|
+
const dir = join(ctx.cwd, "docs", "decisions");
|
|
266
|
+
await mkdir(dir, { recursive: true });
|
|
267
|
+
await appendFile(join(dir, "followups.md"), followups);
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
return reply(
|
|
270
|
+
`could not write docs/decisions/followups.md (${cause instanceof Error ? cause.message : String(cause)}); the round was not recorded, retry`,
|
|
271
|
+
true,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
deps.state.update((s) => upsertReview(s, review));
|
|
276
|
+
const counts = (sev: Finding["severity"]) =>
|
|
277
|
+
findings.filter((f) => f.severity === sev).length;
|
|
278
|
+
return reply(
|
|
279
|
+
[
|
|
280
|
+
`Round ${review.rounds.length} recorded: ${counts("blocking")} blocking, ${counts("should-fix")} should-fix, ${counts("nit")} nit, ${counts("false-positive")} false-positive.`,
|
|
281
|
+
reviewLabel(review),
|
|
282
|
+
...notes,
|
|
283
|
+
`next: ${nextAction(review, snap.value.digest)}`,
|
|
284
|
+
].join("\n"),
|
|
285
|
+
);
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { parseDeparture } from "../core/departure.ts";
|
|
3
|
+
import { parseReviewState, type ReviewState } from "../core/review.ts";
|
|
3
4
|
import {
|
|
4
5
|
CI_STATUSES,
|
|
5
6
|
type CiState,
|
|
@@ -65,11 +66,32 @@ function parseTestRun(input: unknown): TestRun | ParseError {
|
|
|
65
66
|
return { at, exitCode, summary };
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function parseReviews(input: unknown): ReviewState[] | ParseError {
|
|
70
|
+
if (!Array.isArray(input)) return parseError("reviews must be an array");
|
|
71
|
+
const out: ReviewState[] = [];
|
|
72
|
+
for (const raw of input) {
|
|
73
|
+
const review = parseReviewState(raw);
|
|
74
|
+
if (isParseError(review)) return review;
|
|
75
|
+
out.push(review);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
/** Boundary parse for persisted state: the only place state is cast from `unknown`. */
|
|
69
81
|
export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
70
82
|
if (!isRecord(input)) return parseError("devsys state must be an object");
|
|
71
|
-
const {
|
|
72
|
-
|
|
83
|
+
const {
|
|
84
|
+
phase,
|
|
85
|
+
sizing,
|
|
86
|
+
activeSlice,
|
|
87
|
+
openDepartures,
|
|
88
|
+
jev,
|
|
89
|
+
lastPushAt,
|
|
90
|
+
ci,
|
|
91
|
+
profiles,
|
|
92
|
+
lastTestRun,
|
|
93
|
+
reviews,
|
|
94
|
+
} = input;
|
|
73
95
|
if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
|
|
74
96
|
if (!JEV.includes(jev as JevStatus)) return parseError(`unknown jev status: ${String(jev)}`);
|
|
75
97
|
if (!Array.isArray(openDepartures)) return parseError("openDepartures must be an array");
|
|
@@ -94,6 +116,8 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
|
94
116
|
if (isParseError(parsedProfiles)) return parsedProfiles;
|
|
95
117
|
const parsedRun = lastTestRun === undefined ? undefined : parseTestRun(lastTestRun);
|
|
96
118
|
if (isParseError(parsedRun)) return parsedRun;
|
|
119
|
+
const parsedReviews = reviews === undefined ? undefined : parseReviews(reviews);
|
|
120
|
+
if (isParseError(parsedReviews)) return parsedReviews;
|
|
97
121
|
return {
|
|
98
122
|
phase: phase as Phase,
|
|
99
123
|
jev: jev as JevStatus,
|
|
@@ -104,6 +128,7 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
|
104
128
|
...(parsedCi !== undefined ? { ci: parsedCi } : {}),
|
|
105
129
|
...(parsedProfiles !== undefined ? { profiles: parsedProfiles } : {}),
|
|
106
130
|
...(parsedRun !== undefined ? { lastTestRun: parsedRun } : {}),
|
|
131
|
+
...(parsedReviews !== undefined ? { reviews: parsedReviews } : {}),
|
|
107
132
|
};
|
|
108
133
|
}
|
|
109
134
|
|