@jwilger/pi-development-system 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/review-flow.ts +2 -1
- package/src/review/digest.ts +40 -9
- package/src/review/review-tools.ts +19 -13
package/package.json
CHANGED
package/src/core/review-flow.ts
CHANGED
|
@@ -65,7 +65,8 @@ export function splitDiffByFile(diff: string): Record<string, string> {
|
|
|
65
65
|
if (path !== undefined) out[path] = lines.join("\n");
|
|
66
66
|
};
|
|
67
67
|
for (const line of diff.split("\n")) {
|
|
68
|
-
|
|
68
|
+
// Git quotes unusual paths ("b/na\303\257ve.md"); strip the quotes rather than lose the section.
|
|
69
|
+
const header = /^diff --git "?a\/.*?"? "?b\/(.*?)"?$/.exec(line);
|
|
69
70
|
if (header !== null) {
|
|
70
71
|
flush();
|
|
71
72
|
path = header[1] ?? line;
|
package/src/review/digest.ts
CHANGED
|
@@ -29,27 +29,50 @@ export async function snapshotDiff(
|
|
|
29
29
|
range: string,
|
|
30
30
|
): Promise<Result<DiffSnapshot, string>> {
|
|
31
31
|
try {
|
|
32
|
+
// Plain, parseable output whatever the user's git config says: an external diff driver or
|
|
33
|
+
// forced colour removes the `diff --git` headers the per-file digests are cut from.
|
|
34
|
+
const plain = ["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-color"];
|
|
32
35
|
const [diff, stat, others] = await Promise.all([
|
|
33
|
-
exec("git", ["
|
|
34
|
-
exec("git", [
|
|
36
|
+
exec("git", [...plain, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
|
|
37
|
+
exec("git", [...plain, "--stat", range], { cwd, timeout: 15_000 }),
|
|
35
38
|
range === "HEAD"
|
|
36
39
|
? exec("git", ["ls-files", "--others", "--exclude-standard"], { cwd, timeout: 15_000 })
|
|
37
40
|
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
|
38
41
|
]);
|
|
39
42
|
if (diff.code !== 0) return err(diff.stderr.trim() || `git diff ${range} failed`);
|
|
40
|
-
|
|
43
|
+
if (others.code !== 0) return err(others.stderr.trim() || "git ls-files --others failed");
|
|
44
|
+
const listed = others.stdout.split("\n").filter((p) => p !== "");
|
|
45
|
+
// A nested repository is listed as `dir/` and has no blob to hash.
|
|
46
|
+
const nested = listed.filter((p) => p.endsWith("/"));
|
|
47
|
+
const untracked = listed.filter((p) => !p.endsWith("/"));
|
|
48
|
+
if (untracked.length > MAX_UNTRACKED) {
|
|
49
|
+
return err(
|
|
50
|
+
`${untracked.length} untracked files (more than ${MAX_UNTRACKED}); commit or ignore some so they can be reviewed`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
41
53
|
const files: Record<string, string> = {};
|
|
42
54
|
for (const [path, text] of Object.entries(splitDiffByFile(diff.stdout))) {
|
|
43
|
-
files[path] =
|
|
55
|
+
files[path] = fileDigest(text);
|
|
56
|
+
}
|
|
57
|
+
if (diff.stdout.trim() !== "" && Object.keys(files).length === 0) {
|
|
58
|
+
return err("could not read the diff: no per-file sections were found");
|
|
44
59
|
}
|
|
45
60
|
if (untracked.length > 0) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
61
|
+
const hashes = await exec("git", ["hash-object", "--", ...untracked], {
|
|
62
|
+
cwd,
|
|
63
|
+
timeout: 15_000,
|
|
64
|
+
});
|
|
65
|
+
const lines = hashes.stdout.split("\n").filter((l) => l !== "");
|
|
66
|
+
if (hashes.code !== 0 || lines.length !== untracked.length) {
|
|
67
|
+
return err(hashes.stderr.trim() || "git hash-object could not hash every untracked file");
|
|
68
|
+
}
|
|
69
|
+
for (const [i, path] of untracked.entries()) files[path] = `new:${lines[i]}`;
|
|
50
70
|
}
|
|
51
71
|
const names = Object.keys(files).sort();
|
|
52
|
-
const untrackedStat =
|
|
72
|
+
const untrackedStat = [
|
|
73
|
+
...untracked.map((p) => ` ${p} (untracked)`),
|
|
74
|
+
...nested.map((p) => ` ${p} (nested repository, not reviewable)`),
|
|
75
|
+
].join("\n");
|
|
53
76
|
return ok({
|
|
54
77
|
digest: digestOf(names.map((p) => `${p}\0${files[p]}`).join("\n")),
|
|
55
78
|
files,
|
|
@@ -60,3 +83,11 @@ export async function snapshotDiff(
|
|
|
60
83
|
return err(cause instanceof Error ? cause.message : String(cause));
|
|
61
84
|
}
|
|
62
85
|
}
|
|
86
|
+
|
|
87
|
+
const NEW_FILE = /^new file mode .*\nindex 0+\.\.([0-9a-f]+)/m;
|
|
88
|
+
|
|
89
|
+
/** A new file digests as its blob, the same form an untracked file gets, so `git add` changes nothing. */
|
|
90
|
+
const fileDigest = (section: string): string => {
|
|
91
|
+
const blob = NEW_FILE.exec(section)?.[1];
|
|
92
|
+
return blob === undefined ? digestOf(section) : `new:${blob}`;
|
|
93
|
+
};
|
|
@@ -247,19 +247,9 @@ export function createReviewRecordTool(
|
|
|
247
247
|
|
|
248
248
|
const merged = combinePackets(packets);
|
|
249
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
250
|
// File the nits first: if this fails nothing is recorded and a retry cannot count the round twice.
|
|
262
|
-
const followups = renderFollowups(slice,
|
|
251
|
+
const followups = renderFollowups(slice, expected, findings);
|
|
252
|
+
let reviewed = snap.value;
|
|
263
253
|
if (followups !== undefined) {
|
|
264
254
|
try {
|
|
265
255
|
const dir = join(ctx.cwd, "docs", "decisions");
|
|
@@ -271,7 +261,23 @@ export function createReviewRecordTool(
|
|
|
271
261
|
true,
|
|
272
262
|
);
|
|
273
263
|
}
|
|
264
|
+
// The file is part of the work tree, so writing it changed the diff. The round covers the
|
|
265
|
+
// reviewed code plus its own follow-up note; otherwise `done` would be stale on arrival.
|
|
266
|
+
const after = await snapshotDiff(deps.exec, ctx.cwd, params.diffRange?.trim() || "HEAD");
|
|
267
|
+
if (!after.ok)
|
|
268
|
+
return reply(`cannot read the diff after filing follow-ups: ${after.error}`, true);
|
|
269
|
+
reviewed = after.value;
|
|
274
270
|
}
|
|
271
|
+
const review = addRound(
|
|
272
|
+
{ ...before, required },
|
|
273
|
+
{
|
|
274
|
+
lenses: merged.lenses,
|
|
275
|
+
findings,
|
|
276
|
+
reviewedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
277
|
+
diffDigest: reviewed.digest,
|
|
278
|
+
files: reviewed.files,
|
|
279
|
+
},
|
|
280
|
+
);
|
|
275
281
|
deps.state.update((s) => upsertReview(s, review));
|
|
276
282
|
const counts = (sev: Finding["severity"]) =>
|
|
277
283
|
findings.filter((f) => f.severity === sev).length;
|
|
@@ -280,7 +286,7 @@ export function createReviewRecordTool(
|
|
|
280
286
|
`Round ${review.rounds.length} recorded: ${counts("blocking")} blocking, ${counts("should-fix")} should-fix, ${counts("nit")} nit, ${counts("false-positive")} false-positive.`,
|
|
281
287
|
reviewLabel(review),
|
|
282
288
|
...notes,
|
|
283
|
-
`next: ${nextAction(review,
|
|
289
|
+
`next: ${nextAction(review, reviewed.digest)}`,
|
|
284
290
|
].join("\n"),
|
|
285
291
|
);
|
|
286
292
|
},
|