@jwilger/pi-development-system 0.22.0 → 0.23.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.23.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,68 @@ 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);
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
 
151
160
  /** Files staged and also changed since staging; empty when the commit stages everything itself. */
152
161
  const stagedThenEdited = (
153
- names: { staged: readonly string[]; unstaged: readonly string[] } | undefined,
162
+ names: { staged: readonly string[]; unstaged: readonly string[] },
154
163
  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));
164
+ ): string[] => {
165
+ const invocations = resolveGit(command).invocations;
166
+ if (stagesAllTracked(invocations)) return [];
167
+ // A path that is an argument of the commit or add itself is committed (or re-staged) from the work tree,
168
+ // which was reviewed. Message text is one argument and never equals a path.
169
+ const named = new Set(
170
+ invocations.filter((i) => i.sub === "commit" || i.sub === "add").flatMap((i) => i.args),
171
+ );
172
+ return names.staged.filter((name) => names.unstaged.includes(name) && !named.has(name));
173
+ };
160
174
 
161
175
  const STAGE_ALL = new Set(["-A", "--all", "-u", "--update", "."]);
176
+ const BOOLEAN_SHORT = /[enqsvz]/;
162
177
 
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
- );
178
+ /** `-a` inside a short-flag cluster such as `-sa`; a value-taking letter ends the cluster (`-mtab`, `-uall`). */
179
+ function clusterHasA(arg: string): boolean {
180
+ if (!/^-[a-zA-Z]+$/.test(arg)) return false;
181
+ for (const ch of arg.slice(1)) {
182
+ if (ch === "a") return true;
183
+ if (!BOOLEAN_SHORT.test(ch)) return false;
184
+ }
185
+ return false;
186
+ }
187
+
188
+ /**
189
+ * The command re-stages every tracked change of this repository before it commits: `commit -a`, or
190
+ * `git add -A|-u|.` with no pathspec, in the same directory, ahead of the commit. Read from parsed
191
+ * invocations, not message text.
192
+ */
193
+ function stagesAllTracked(invocations: ReturnType<typeof resolveGit>["invocations"]): boolean {
194
+ const firstCommit = invocations.findIndex((i) => i.sub === "commit");
195
+ return invocations.some((i, index) => {
196
+ if (i.dir !== undefined) return false;
197
+ if (i.sub === "commit") return i.args.some((a) => a === "--all" || clusterHasA(a));
198
+ if (i.sub !== "add" || (firstCommit !== -1 && index > firstCommit)) return false;
199
+ const paths = i.args.filter((a) => !a.startsWith("-"));
200
+ return i.args.some((a) => STAGE_ALL.has(a)) && paths.every((a) => a === ".");
201
+ });
174
202
  }
175
203
 
176
204
  async function changedNames(