@jwilger/pi-development-system 0.22.0 → 0.24.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.22.0",
3
+ "version": "0.24.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"
package/src/core/exec.ts CHANGED
@@ -11,6 +11,8 @@ export const TIMEOUT_CODE = 124;
11
11
  /**
12
12
  * pi reports a killed command as `code: 0, killed: true`, so a timed-out `git diff` would look like an
13
13
  * empty diff. A guard must never read that as "nothing changed": make the kill a failure.
14
+ * Limit: a process killed from outside (e.g. the OOM killer) also reads as `code: 0` with `killed` unset;
15
+ * pi's API does not expose that, so it cannot be detected here.
14
16
  */
15
17
  export const timeoutAsFailure = (result: {
16
18
  code: number;
@@ -137,40 +137,80 @@ async function reviewNeeds(
137
137
  if (gap !== undefined) return [{ gate: REVIEW, why: gap }];
138
138
  // The review saw the work tree; a plain `git commit` records the index. A file staged and then edited
139
139
  // again would commit the older, unreviewed content.
140
- const stale = stagedThenEdited(await changedNames(deps.exec, ctx.cwd), command);
140
+ const names = await changedNames(deps.exec, ctx.cwd);
141
+ if (names === undefined) {
142
+ return [
143
+ {
144
+ gate: REVIEW,
145
+ why: "cannot read which files are staged or edited, so the staged content cannot be compared with the reviewed work tree",
146
+ },
147
+ ];
148
+ }
149
+ const stale = stagedThenEdited(names, command, ctx.cwd);
141
150
  return stale.length === 0
142
151
  ? []
143
152
  : [
144
153
  {
145
154
  gate: REVIEW,
146
- why: `the staged content of ${stale.join(", ")} differs from the work tree that was reviewed (edited after \`git add\`); stage it again`,
155
+ why: `the staged content of ${stale.join(", ")} differs from the work tree that was reviewed (edited after \`git add\`, or only partly staged); stage it again, or depart if committing in parts is deliberate`,
147
156
  },
148
157
  ];
149
158
  }
150
159
 
160
+ type Invocations = ReturnType<typeof resolveGit>["invocations"];
161
+
151
162
  /** Files staged and also changed since staging; empty when the commit stages everything itself. */
152
163
  const stagedThenEdited = (
153
- names: { staged: readonly string[]; unstaged: readonly string[] } | undefined,
164
+ names: { staged: readonly string[]; unstaged: readonly string[] },
154
165
  command: string,
155
- ): string[] =>
156
- names === undefined || stagesAllTracked(command)
157
- ? []
158
- : // A path named in the command is committed (or re-staged) from the work tree, which was reviewed.
159
- names.staged.filter((name) => names.unstaged.includes(name) && !command.includes(name));
166
+ cwd: string,
167
+ ): string[] => {
168
+ // Only what runs before or at the first commit can change what that commit records.
169
+ const all = resolveGit(command).invocations;
170
+ const firstCommit = all.findIndex((i) => i.sub === "commit");
171
+ const upto = firstCommit === -1 ? all : all.slice(0, firstCommit + 1);
172
+ if (stagesAllTracked(upto, cwd)) return [];
173
+ // A path (or a directory holding it) that is an argument of the commit or add itself is committed or
174
+ // re-staged from the work tree, which was reviewed. Message text is one argument and never equals a path.
175
+ const named = upto
176
+ .filter((i) => (i.sub === "commit" || i.sub === "add") && !isDryRun(i) && inThisDir(i, cwd))
177
+ .flatMap((i) => i.args.filter((a) => !a.startsWith("-")).map(cleanPath));
178
+ const covered = (name: string) => named.some((n) => name === n || name.startsWith(`${n}/`));
179
+ return names.staged.filter((name) => names.unstaged.includes(name) && !covered(name));
180
+ };
181
+
182
+ const cleanPath = (arg: string): string => arg.replace(/^(\.\/)+/, "").replace(/\/+$/, "");
183
+ const isDryRun = (i: Invocations[number]): boolean =>
184
+ i.sub === "add" && i.args.some((a) => a === "-n" || a === "--dry-run");
185
+ /** The invocation runs in the session's directory (no `cd`/`-C`, or one that leads back to it). */
186
+ const inThisDir = (i: Invocations[number], cwd: string): boolean =>
187
+ i.dir === undefined || resolve(cwd, i.dir) === resolve(cwd);
160
188
 
161
189
  const STAGE_ALL = new Set(["-A", "--all", "-u", "--update", "."]);
190
+ const BOOLEAN_SHORT = /[enqsvz]/;
162
191
 
163
- /** The command re-stages every tracked change itself: `commit -a`, or `git add -A|-u|.`. Read from parsed invocations, not message text. */
164
- function stagesAllTracked(command: string): boolean {
165
- return resolveGit(command).invocations.some(
166
- (i) =>
167
- (i.sub === "commit" &&
168
- i.args.some(
169
- (a) =>
170
- a === "--all" || a === "--include" || (/^-[a-zA-Z]{1,4}$/.test(a) && a.includes("a")),
171
- )) ||
172
- (i.sub === "add" && i.args.some((a) => STAGE_ALL.has(a))),
173
- );
192
+ /** `-a` inside a short-flag cluster such as `-sa`; a value-taking letter ends the cluster (`-mtab`, `-uall`). */
193
+ function clusterHasA(arg: string): boolean {
194
+ if (!/^-[a-zA-Z]+$/.test(arg)) return false;
195
+ for (const ch of arg.slice(1)) {
196
+ if (ch === "a") return true;
197
+ if (!BOOLEAN_SHORT.test(ch)) return false;
198
+ }
199
+ return false;
200
+ }
201
+
202
+ /**
203
+ * The command re-stages every tracked change of this repository before it commits: `commit -a`, or
204
+ * `git add -A|-u|.` with no pathspec, in this directory. Read from parsed invocations, not message text.
205
+ */
206
+ function stagesAllTracked(invocations: Invocations, cwd: string): boolean {
207
+ return invocations.some((i) => {
208
+ if (!inThisDir(i, cwd) || isDryRun(i)) return false;
209
+ if (i.sub === "commit") return i.args.some((a) => a === "--all" || clusterHasA(a));
210
+ if (i.sub !== "add") return false;
211
+ const paths = i.args.filter((a) => !a.startsWith("-"));
212
+ return i.args.some((a) => STAGE_ALL.has(a)) && paths.every((a) => a === ".");
213
+ });
174
214
  }
175
215
 
176
216
  async function changedNames(