pi-jev-guard 0.3.3 → 0.5.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/README.md +68 -14
- package/config/jev-config.example.json +9 -0
- package/config/jev-verification.example.json +22 -0
- package/extensions/index.ts +489 -124
- package/package.json +1 -1
- package/src/automatic/guardian.ts +26 -2
- package/src/automatic/overlay.ts +160 -68
- package/src/commands.ts +106 -1
- package/src/config.ts +13 -0
- package/src/verification-runner.ts +177 -0
- package/src/verification-tool.ts +65 -0
- package/src/verification.ts +589 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** Esecuzione bounded dei soli comandi dichiarati nel manifest di verifica. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import type { VerificationCheck, VerificationSettings } from "./verification.ts";
|
|
4
|
+
|
|
5
|
+
export interface VerificationExecution {
|
|
6
|
+
id: string;
|
|
7
|
+
command: string;
|
|
8
|
+
ok: boolean;
|
|
9
|
+
exitCode?: number;
|
|
10
|
+
timedOut?: boolean;
|
|
11
|
+
aborted?: boolean;
|
|
12
|
+
/** Solo output fallito, bounded; il successo non riversa log inutili nel contesto. */
|
|
13
|
+
output?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function boundedTimeout(value: unknown): number {
|
|
17
|
+
// Config manuale può essere malformata: mai trasformare NaN in timeout 0.
|
|
18
|
+
const numeric = typeof value === "number" && Number.isFinite(value) ? value : 120_000;
|
|
19
|
+
// Non permettere manifest accidentali da ore: per check oltre 10 min l'utente
|
|
20
|
+
// può aumentare il setting ma resta una scelta esplicita e limitata.
|
|
21
|
+
return Math.max(1000, Math.min(600_000, Math.floor(numeric)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function boundedOutputChars(value: unknown): number {
|
|
25
|
+
const numeric = typeof value === "number" && Number.isFinite(value) ? value : 6_000;
|
|
26
|
+
return Math.max(0, Math.min(100_000, Math.floor(numeric)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function appendBounded(state: { text: string; elided: number }, chunk: Buffer, maxChars: number): void {
|
|
30
|
+
const value = chunk.toString("utf8");
|
|
31
|
+
const remaining = Math.max(0, maxChars - state.text.length);
|
|
32
|
+
state.text += value.slice(0, remaining);
|
|
33
|
+
state.elided += Math.max(0, value.length - remaining);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Esegue un comando scelto dal manifest. `command` non arriva mai dal modello;
|
|
38
|
+
* il chiamante deve aver già selezionato un VerificationCheck validato.
|
|
39
|
+
*/
|
|
40
|
+
export async function runVerificationCheck(args: {
|
|
41
|
+
check: VerificationCheck;
|
|
42
|
+
cwd: string;
|
|
43
|
+
settings: VerificationSettings;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}): Promise<VerificationExecution> {
|
|
46
|
+
const { check, cwd, settings, signal } = args;
|
|
47
|
+
const configuredTimeout = boundedTimeout(settings.commandTimeoutMs);
|
|
48
|
+
const timeoutMs = Math.min(
|
|
49
|
+
check.timeoutMs === undefined ? configuredTimeout : boundedTimeout(check.timeoutMs),
|
|
50
|
+
configuredTimeout,
|
|
51
|
+
);
|
|
52
|
+
const outputChars = boundedOutputChars(settings.outputChars);
|
|
53
|
+
const captured = { text: "", elided: 0 };
|
|
54
|
+
let timedOut = false;
|
|
55
|
+
let aborted = false;
|
|
56
|
+
|
|
57
|
+
return new Promise<VerificationExecution>((resolveResult) => {
|
|
58
|
+
let settled = false;
|
|
59
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
60
|
+
let forceKill: ReturnType<typeof setTimeout> | undefined;
|
|
61
|
+
let child: ReturnType<typeof spawn> | undefined;
|
|
62
|
+
const killTree = (signalName: NodeJS.Signals) => {
|
|
63
|
+
if (!child?.pid) return;
|
|
64
|
+
try {
|
|
65
|
+
// Su POSIX il comando shell e i suoi figli sono un process group
|
|
66
|
+
// dedicato (detached:true): evitare che un npm/test appeso sopravviva
|
|
67
|
+
// al timeout del guard. Su Windows il fallback uccide almeno shell.
|
|
68
|
+
if (process.platform !== "win32") process.kill(-child.pid, signalName);
|
|
69
|
+
else child.kill(signalName);
|
|
70
|
+
} catch {
|
|
71
|
+
try {
|
|
72
|
+
child.kill(signalName);
|
|
73
|
+
} catch {
|
|
74
|
+
// Il processo può essere già terminato.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const settle = (result: VerificationExecution) => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
settled = true;
|
|
81
|
+
if (timeout) clearTimeout(timeout);
|
|
82
|
+
if (forceKill) clearTimeout(forceKill);
|
|
83
|
+
signal?.removeEventListener("abort", onAbort);
|
|
84
|
+
resolveResult(result);
|
|
85
|
+
};
|
|
86
|
+
const terminate = () => {
|
|
87
|
+
killTree("SIGTERM");
|
|
88
|
+
forceKill ??= setTimeout(() => killTree("SIGKILL"), 1_000);
|
|
89
|
+
};
|
|
90
|
+
const output = () => {
|
|
91
|
+
const base = captured.text.trim();
|
|
92
|
+
const suffix = captured.elided > 0 ? `\n…[${captured.elided} chars elided]` : "";
|
|
93
|
+
return `${base}${suffix}`.trim();
|
|
94
|
+
};
|
|
95
|
+
const onAbort = () => {
|
|
96
|
+
aborted = true;
|
|
97
|
+
terminate();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// shell è necessario perché il manifest contiene comandi normali come
|
|
102
|
+
// `npm run typecheck`; la fiducia arriva dal file locale + project trust.
|
|
103
|
+
child = spawn(check.command, {
|
|
104
|
+
cwd,
|
|
105
|
+
shell: true,
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
detached: process.platform !== "win32",
|
|
108
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
settle({
|
|
112
|
+
id: check.id,
|
|
113
|
+
command: check.command,
|
|
114
|
+
ok: false,
|
|
115
|
+
output: error instanceof Error ? error.message : String(error),
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
child.stdout?.on("data", (chunk: Buffer) => appendBounded(captured, chunk, outputChars));
|
|
121
|
+
child.stderr?.on("data", (chunk: Buffer) => appendBounded(captured, chunk, outputChars));
|
|
122
|
+
child.on("error", (error) => {
|
|
123
|
+
settle({
|
|
124
|
+
id: check.id,
|
|
125
|
+
command: check.command,
|
|
126
|
+
ok: false,
|
|
127
|
+
output: error.message,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
child.on("close", (exitCode) => {
|
|
131
|
+
const ok = exitCode === 0 && !timedOut && !aborted;
|
|
132
|
+
settle({
|
|
133
|
+
id: check.id,
|
|
134
|
+
command: check.command,
|
|
135
|
+
ok,
|
|
136
|
+
...(typeof exitCode === "number" ? { exitCode } : {}),
|
|
137
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
138
|
+
...(aborted ? { aborted: true } : {}),
|
|
139
|
+
...(!ok ? { output: output() || (timedOut ? `Timeout after ${timeoutMs}ms` : aborted ? "Aborted" : "Command failed without output") } : {}),
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
timeout = setTimeout(() => {
|
|
144
|
+
timedOut = true;
|
|
145
|
+
terminate();
|
|
146
|
+
}, timeoutMs);
|
|
147
|
+
// Child può chiudersi molto rapidamente prima dell'assegnazione del timer.
|
|
148
|
+
if (settled && timeout) clearTimeout(timeout);
|
|
149
|
+
if (signal?.aborted) onAbort();
|
|
150
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function runVerificationChecks(args: {
|
|
155
|
+
checks: VerificationCheck[];
|
|
156
|
+
cwd: string;
|
|
157
|
+
settings: VerificationSettings;
|
|
158
|
+
signal?: AbortSignal;
|
|
159
|
+
}): Promise<VerificationExecution[]> {
|
|
160
|
+
const results: VerificationExecution[] = [];
|
|
161
|
+
for (const check of args.checks) {
|
|
162
|
+
if (args.signal?.aborted) break;
|
|
163
|
+
results.push(await runVerificationCheck({ ...args, check }));
|
|
164
|
+
}
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Testo destinato al modello: esiti concreti, output diagnostico solo su errore. */
|
|
169
|
+
export function formatVerificationExecutions(results: readonly VerificationExecution[]): string {
|
|
170
|
+
if (results.length === 0) return "[jev_verify] Nessun check eseguito.";
|
|
171
|
+
return results.map((result) => {
|
|
172
|
+
const header = result.ok
|
|
173
|
+
? `[jev_verify] ${result.id}: superato${result.exitCode !== undefined ? ` (exit ${result.exitCode})` : ""}.`
|
|
174
|
+
: `[jev_verify] ${result.id}: fallito${result.exitCode !== undefined ? ` (exit ${result.exitCode})` : ""}${result.timedOut ? " (timeout)" : ""}${result.aborted ? " (annullato)" : ""}.`;
|
|
175
|
+
return result.output ? `${header}\n${result.output}` : header;
|
|
176
|
+
}).join("\n\n");
|
|
177
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { VerificationExecution } from "./verification-runner.ts";
|
|
4
|
+
|
|
5
|
+
export interface VerificationToolRunResult {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
message: string;
|
|
8
|
+
results: VerificationExecution[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tool ristretto: il modello seleziona solo un id del manifest (o
|
|
13
|
+
* recommended/all), mai una stringa shell arbitraria.
|
|
14
|
+
*/
|
|
15
|
+
export function registerVerificationTool(
|
|
16
|
+
pi: ExtensionAPI,
|
|
17
|
+
deps: {
|
|
18
|
+
run: (args: {
|
|
19
|
+
cwd: string;
|
|
20
|
+
selection: string | undefined;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
trusted: boolean;
|
|
23
|
+
}) => Promise<VerificationToolRunResult>;
|
|
24
|
+
},
|
|
25
|
+
): void {
|
|
26
|
+
pi.registerTool({
|
|
27
|
+
name: "jev_verify",
|
|
28
|
+
label: "Jev project verification",
|
|
29
|
+
description:
|
|
30
|
+
"Run a project-approved typecheck, lint, or test declared in .pi/jev-verification.json. " +
|
|
31
|
+
"It never accepts arbitrary shell commands.",
|
|
32
|
+
promptSnippet: "Run approved project verification checks after code edits.",
|
|
33
|
+
promptGuidelines: [
|
|
34
|
+
"After a successful edit/write, call jev_verify with check='recommended' when verification is enabled.",
|
|
35
|
+
"Use only this tool's declared check ids; never claim code compiles or tests pass without its observed result.",
|
|
36
|
+
"If a check fails, inspect its actual output, repair the code, then run the check again.",
|
|
37
|
+
],
|
|
38
|
+
parameters: Type.Object({
|
|
39
|
+
check: Type.Optional(
|
|
40
|
+
Type.String({
|
|
41
|
+
minLength: 1,
|
|
42
|
+
maxLength: 64,
|
|
43
|
+
description: "Check id, or 'recommended' (default) / 'all'",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
}),
|
|
47
|
+
executionMode: "sequential",
|
|
48
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
49
|
+
const result = await deps.run({
|
|
50
|
+
cwd: ctx.cwd,
|
|
51
|
+
selection: params.check,
|
|
52
|
+
signal: signal ?? undefined,
|
|
53
|
+
trusted: ctx.isProjectTrusted(),
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: result.message }],
|
|
57
|
+
details: {
|
|
58
|
+
kind: "jev-verification",
|
|
59
|
+
ok: result.ok,
|
|
60
|
+
results: result.results,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|