@jwilger/pi-development-system 0.21.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.
|
@@ -3,6 +3,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
3
3
|
import { appendContextTail, renderContextTail } from "../src/context/context-tail.ts";
|
|
4
4
|
import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
|
|
5
5
|
import { applyPromptSection } from "../src/context/system-prompt.ts";
|
|
6
|
+
import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
|
|
6
7
|
import { detectProfiles } from "../src/core/profile.ts";
|
|
7
8
|
import { createApprovalStore } from "../src/gates/approvals.ts";
|
|
8
9
|
import { registerCommitGuard } from "../src/gates/commit-guard.ts";
|
|
@@ -77,24 +78,26 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
77
78
|
return messages === undefined ? undefined : { messages };
|
|
78
79
|
});
|
|
79
80
|
|
|
81
|
+
const exec: Exec = async (command, args, options) =>
|
|
82
|
+
timeoutAsFailure(await pi.exec(command, args, options));
|
|
80
83
|
registerGitGuard({ pi, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
|
|
81
84
|
registerCommitGuard({
|
|
82
85
|
pi,
|
|
83
86
|
state,
|
|
84
87
|
jev: (ctx) => jevHolder.forContext(ctx),
|
|
85
|
-
exec
|
|
88
|
+
exec,
|
|
86
89
|
});
|
|
87
90
|
registerCiCommand({
|
|
88
91
|
pi,
|
|
89
92
|
state,
|
|
90
|
-
exec
|
|
93
|
+
exec,
|
|
91
94
|
});
|
|
92
95
|
registerPushGuard({
|
|
93
96
|
pi,
|
|
94
97
|
state,
|
|
95
98
|
approvals,
|
|
96
99
|
jev: (ctx) => jevHolder.forContext(ctx),
|
|
97
|
-
exec
|
|
100
|
+
exec,
|
|
98
101
|
});
|
|
99
102
|
registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
|
|
100
103
|
registerTestEvidence({ pi, state });
|
|
@@ -107,8 +110,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
107
110
|
const reviewDeps = {
|
|
108
111
|
state,
|
|
109
112
|
jev: (ctx: ExtensionContext) => jevHolder.forContext(ctx),
|
|
110
|
-
exec
|
|
111
|
-
pi.exec(command, args, options),
|
|
113
|
+
exec,
|
|
112
114
|
} satisfies Parameters<typeof createReviewStartTool>[0];
|
|
113
115
|
pi.registerTool(createReviewStartTool(reviewDeps));
|
|
114
116
|
pi.registerTool(createReviewRecordTool(reviewDeps));
|
package/package.json
CHANGED
package/src/core/exec.ts
CHANGED
|
@@ -4,3 +4,26 @@ export type Exec = (
|
|
|
4
4
|
args: string[],
|
|
5
5
|
options?: { cwd?: string; timeout?: number },
|
|
6
6
|
) => Promise<{ code: number; stdout: string; stderr: string }>;
|
|
7
|
+
|
|
8
|
+
/** Exit code reported for a command that was killed for running too long (as `timeout(1)` does). */
|
|
9
|
+
export const TIMEOUT_CODE = 124;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* pi reports a killed command as `code: 0, killed: true`, so a timed-out `git diff` would look like an
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
export const timeoutAsFailure = (result: {
|
|
18
|
+
code: number;
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
killed?: boolean;
|
|
22
|
+
}): { code: number; stdout: string; stderr: string } =>
|
|
23
|
+
result.killed === true
|
|
24
|
+
? {
|
|
25
|
+
code: TIMEOUT_CODE,
|
|
26
|
+
stdout: result.stdout,
|
|
27
|
+
stderr: `${result.stderr}\ntimed out`.trim(),
|
|
28
|
+
}
|
|
29
|
+
: { code: result.code, stdout: result.stdout, stderr: result.stderr };
|
package/src/core/review-flow.ts
CHANGED
|
@@ -56,6 +56,12 @@ export function reviewGap(state: DevsysState, slice: SliceRef, now: DiffNow): st
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
const ESCAPES: Record<string, string> = { t: "\t", n: "\n", '"': '"', "\\": "\\" };
|
|
60
|
+
|
|
61
|
+
/** Undo git's C-style quoting of a path (`\"`, `\\`, `\t`, `\n`; octal bytes were already turned off with quotePath=false). */
|
|
62
|
+
const unquote = (path: string): string =>
|
|
63
|
+
path.replace(/\\([tn"\\])/g, (_m, c: string) => ESCAPES[c] ?? c);
|
|
64
|
+
|
|
59
65
|
/** A unified diff cut into one text per file, keyed by the file's new path. */
|
|
60
66
|
export function splitDiffByFile(diff: string): Record<string, string> {
|
|
61
67
|
const out: Record<string, string> = {};
|
|
@@ -69,7 +75,7 @@ export function splitDiffByFile(diff: string): Record<string, string> {
|
|
|
69
75
|
const header = /^diff --git "?a\/.*?"? "?b\/(.*?)"?$/.exec(line);
|
|
70
76
|
if (header !== null) {
|
|
71
77
|
flush();
|
|
72
|
-
path = header[1] ?? line;
|
|
78
|
+
path = unquote(header[1] ?? line);
|
|
73
79
|
lines = [];
|
|
74
80
|
}
|
|
75
81
|
lines.push(line);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
parseConventionalCommit,
|
|
14
14
|
} from "../core/commit-message.ts";
|
|
15
15
|
import type { Exec } from "../core/exec.ts";
|
|
16
|
+
import { resolveGit } from "../core/git-invocations.ts";
|
|
16
17
|
import { reviewGap } from "../core/review-flow.ts";
|
|
17
18
|
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
18
19
|
import type { Jev } from "../jev/client.ts";
|
|
@@ -136,32 +137,84 @@ async function reviewNeeds(
|
|
|
136
137
|
if (gap !== undefined) return [{ gate: REVIEW, why: gap }];
|
|
137
138
|
// The review saw the work tree; a plain `git commit` records the index. A file staged and then edited
|
|
138
139
|
// again would commit the older, unreviewed content.
|
|
139
|
-
const
|
|
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);
|
|
140
150
|
return stale.length === 0
|
|
141
151
|
? []
|
|
142
152
|
: [
|
|
143
153
|
{
|
|
144
154
|
gate: REVIEW,
|
|
145
|
-
why: `the staged content of ${stale.join(", ")} differs from the work tree that was reviewed (edited after \`git add
|
|
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`,
|
|
146
156
|
},
|
|
147
157
|
];
|
|
148
158
|
}
|
|
149
159
|
|
|
150
160
|
/** Files staged and also changed since staging; empty when the commit stages everything itself. */
|
|
151
161
|
const stagedThenEdited = (
|
|
152
|
-
names: { staged: readonly string[]; unstaged: readonly string[] }
|
|
162
|
+
names: { staged: readonly string[]; unstaged: readonly string[] },
|
|
153
163
|
command: string,
|
|
154
|
-
): string[] =>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
};
|
|
174
|
+
|
|
175
|
+
const STAGE_ALL = new Set(["-A", "--all", "-u", "--update", "."]);
|
|
176
|
+
const BOOLEAN_SHORT = /[enqsvz]/;
|
|
177
|
+
|
|
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
|
+
});
|
|
202
|
+
}
|
|
158
203
|
|
|
159
204
|
async function changedNames(
|
|
160
205
|
exec: Exec,
|
|
161
206
|
cwd: string,
|
|
162
207
|
): Promise<{ staged: string[]; unstaged: string[] } | undefined> {
|
|
163
208
|
try {
|
|
164
|
-
const base = [
|
|
209
|
+
const base = [
|
|
210
|
+
"-c",
|
|
211
|
+
"core.quotePath=false",
|
|
212
|
+
"diff",
|
|
213
|
+
"--no-ext-diff",
|
|
214
|
+
"--name-only",
|
|
215
|
+
"-z",
|
|
216
|
+
"--ignore-submodules=dirty",
|
|
217
|
+
];
|
|
165
218
|
const [staged, unstaged] = await Promise.all([
|
|
166
219
|
exec("git", [...base, "--cached"], { cwd, timeout: 10_000 }),
|
|
167
220
|
exec("git", base, { cwd, timeout: 10_000 }),
|