@jwilger/pi-development-system 0.19.0 → 0.21.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -50,8 +50,8 @@ The verdict must agree with the findings.
50
50
  - **false-positive**: refuted; ignored.
51
51
 
52
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.
53
+ lowers a finding to a severity that does not count (it only suggests, in the
54
+ reply, and you re-check it yourself). A round is clean when it has no blocking or should-fix findings.
55
55
  The default is three consecutive clean rounds (`review.required_clean_rounds`).
56
56
  Only real findings reset the count; a changed diff alone does not, so a small fix
57
57
  commit keeps the clean rounds already earned unless findings come back.
@@ -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
- const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
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;
@@ -119,7 +119,11 @@ async function jevNeeds(
119
119
  }
120
120
 
121
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[]> {
122
+ async function reviewNeeds(
123
+ deps: CommitGuardDeps,
124
+ ctx: ExtensionContext,
125
+ command: string,
126
+ ): Promise<Need[]> {
123
127
  const { phase, activeSlice } = deps.state.get();
124
128
  if ((phase !== "implementing" && phase !== "reviewing") || activeSlice === undefined) return [];
125
129
  const snap = await snapshotDiff(deps.exec, ctx.cwd, "HEAD");
@@ -129,7 +133,45 @@ async function reviewNeeds(deps: CommitGuardDeps, ctx: ExtensionContext): Promis
129
133
  activeSlice,
130
134
  snap.ok ? { digest: snap.value.digest, files: snap.value.files } : { digest: "unknown" },
131
135
  );
132
- return gap === undefined ? [] : [{ gate: REVIEW, why: gap }];
136
+ if (gap !== undefined) return [{ gate: REVIEW, why: gap }];
137
+ // The review saw the work tree; a plain `git commit` records the index. A file staged and then edited
138
+ // again would commit the older, unreviewed content.
139
+ const stale = stagedThenEdited(await changedNames(deps.exec, ctx.cwd), command);
140
+ return stale.length === 0
141
+ ? []
142
+ : [
143
+ {
144
+ gate: REVIEW,
145
+ why: `the staged content of ${stale.join(", ")} differs from the work tree that was reviewed (edited after \`git add\`); stage it again`,
146
+ },
147
+ ];
148
+ }
149
+
150
+ /** Files staged and also changed since staging; empty when the commit stages everything itself. */
151
+ const stagedThenEdited = (
152
+ names: { staged: readonly string[]; unstaged: readonly string[] } | undefined,
153
+ command: string,
154
+ ): string[] =>
155
+ names === undefined || stagesEverything(command)
156
+ ? []
157
+ : names.staged.filter((name) => names.unstaged.includes(name));
158
+
159
+ async function changedNames(
160
+ exec: Exec,
161
+ cwd: string,
162
+ ): Promise<{ staged: string[]; unstaged: string[] } | undefined> {
163
+ try {
164
+ const base = ["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--name-only", "-z"];
165
+ const [staged, unstaged] = await Promise.all([
166
+ exec("git", [...base, "--cached"], { cwd, timeout: 10_000 }),
167
+ exec("git", base, { cwd, timeout: 10_000 }),
168
+ ]);
169
+ if (staged.code !== 0 || unstaged.code !== 0) return undefined;
170
+ const split = (text: string) => text.split("\0").filter((n) => n !== "");
171
+ return { staged: split(staged.stdout), unstaged: split(unstaged.stdout) };
172
+ } catch {
173
+ return undefined;
174
+ }
133
175
  }
134
176
 
135
177
  const reviewReason = (need: Need): string =>
@@ -187,7 +229,7 @@ export function registerCommitGuard(deps: CommitGuardDeps): void {
187
229
  if (forbidden.length > 0) return { block: true, reason: forbiddenReason(forbidden) };
188
230
 
189
231
  const known = messages.flatMap((m) => (m === undefined ? [] : [m]));
190
- const all: Need[] = await reviewNeeds(deps, ctx);
232
+ const all: Need[] = await reviewNeeds(deps, ctx, command);
191
233
  // A -F file that cannot be read now (written by this same command, or stdin) cannot be checked.
192
234
  if (extractions.some((e, i) => e.kind === "file" && messages[i] === undefined)) {
193
235
  all.push({
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { lstat, readlink } from "node:fs/promises";
3
+ import { join } from "node:path";
2
4
  import type { Exec } from "../core/exec.ts";
3
5
  import { err, ok, type Result } from "../core/result.ts";
4
6
  import { splitDiffByFile } from "../core/review-flow.ts";
@@ -29,27 +31,67 @@ export async function snapshotDiff(
29
31
  range: string,
30
32
  ): Promise<Result<DiffSnapshot, string>> {
31
33
  try {
34
+ // Plain, parseable output whatever the user's git config says: an external diff driver or
35
+ // forced colour removes the `diff --git` headers the per-file digests are cut from.
36
+ const plain = ["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-color"];
32
37
  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 })
38
+ exec("git", [...plain, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
39
+ exec("git", [...plain, "--stat", range], { cwd, timeout: 15_000 }),
40
+ // Any range that ends in the work tree (HEAD, HEAD~1, a branch) leaves untracked files out of git diff.
41
+ !range.includes("..")
42
+ ? exec(
43
+ "git",
44
+ ["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"],
45
+ { cwd, timeout: 15_000 },
46
+ )
37
47
  : Promise.resolve({ code: 0, stdout: "", stderr: "" }),
38
48
  ]);
39
49
  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 !== "") : [];
50
+ if (others.code !== 0) return err(others.stderr.trim() || "git ls-files --others failed");
51
+ const listed = others.stdout.split("\0").filter((p) => p !== "");
52
+ // A nested repository is listed as `dir/` and has no blob to hash.
53
+ const nested = listed.filter((p) => p.endsWith("/"));
54
+ const untracked = listed.filter((p) => !p.endsWith("/"));
55
+ if (untracked.length > MAX_UNTRACKED) {
56
+ return err(
57
+ `${untracked.length} untracked files (more than ${MAX_UNTRACKED}); commit or ignore some so they can be reviewed`,
58
+ );
59
+ }
41
60
  const files: Record<string, string> = {};
42
61
  for (const [path, text] of Object.entries(splitDiffByFile(diff.stdout))) {
43
- files[path] = digestOf(text);
62
+ files[path] = fileDigest(text);
63
+ }
64
+ if (diff.stdout.trim() !== "" && Object.keys(files).length === 0) {
65
+ return err("could not read the diff: no per-file sections were found");
44
66
  }
45
67
  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"}`;
68
+ const links = new Set<string>();
69
+ for (const path of untracked) {
70
+ const target = await linkTarget(cwd, path);
71
+ if (target !== undefined) {
72
+ files[path] = `new:${blobOf(target)}`;
73
+ links.add(path);
74
+ }
75
+ }
76
+ // `git hash-object <path>` follows symlinks (and fails on dangling ones), so links are hashed above.
77
+ const regular = untracked.filter((p) => !links.has(p));
78
+ if (regular.length > 0) {
79
+ const hashes = await exec("git", ["hash-object", "--", ...regular], {
80
+ cwd,
81
+ timeout: 15_000,
82
+ });
83
+ const lines = hashes.stdout.split("\n").filter((l) => l !== "");
84
+ if (hashes.code !== 0 || lines.length !== regular.length) {
85
+ return err(hashes.stderr.trim() || "git hash-object could not hash every untracked file");
86
+ }
87
+ for (const [i, path] of regular.entries()) files[path] = `new:${lines[i]}`;
88
+ }
50
89
  }
51
90
  const names = Object.keys(files).sort();
52
- const untrackedStat = untracked.map((p) => ` ${p} (untracked)`).join("\n");
91
+ const untrackedStat = [
92
+ ...untracked.map((p) => ` ${p} (untracked)`),
93
+ ...nested.map((p) => ` ${p} (nested repository, not reviewable)`),
94
+ ].join("\n");
53
95
  return ok({
54
96
  digest: digestOf(names.map((p) => `${p}\0${files[p]}`).join("\n")),
55
97
  files,
@@ -60,3 +102,28 @@ export async function snapshotDiff(
60
102
  return err(cause instanceof Error ? cause.message : String(cause));
61
103
  }
62
104
  }
105
+
106
+ const NEW_FILE = /^new file mode .*\nindex 0+\.\.([0-9a-f]+)/m;
107
+
108
+ /** A new file digests as its blob, the same form an untracked file gets, so `git add` changes nothing. */
109
+ const fileDigest = (section: string): string => {
110
+ const blob = NEW_FILE.exec(section)?.[1];
111
+ return blob === undefined ? digestOf(section) : `new:${blob}`;
112
+ };
113
+
114
+ /** The text of a symlink, or undefined when `path` is not one (or cannot be inspected). */
115
+ async function linkTarget(cwd: string, path: string): Promise<string | undefined> {
116
+ try {
117
+ const full = join(cwd, path);
118
+ return (await lstat(full)).isSymbolicLink() ? await readlink(full) : undefined;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /** Git's blob id for some content: what `git add` stores for a symlink is its target text. */
125
+ const blobOf = (content: string): string =>
126
+ createHash("sha1")
127
+ .update(`blob ${Buffer.byteLength(content)}\0`)
128
+ .update(content)
129
+ .digest("hex");
@@ -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, review.rounds.length, findings);
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, snap.value.digest)}`,
289
+ `next: ${nextAction(review, reviewed.digest)}`,
284
290
  ].join("\n"),
285
291
  );
286
292
  },