pi-jev-guard 0.4.0 → 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 +55 -0
- package/config/jev-config.example.json +9 -0
- package/config/jev-verification.example.json +22 -0
- package/extensions/index.ts +387 -2
- package/package.json +1 -1
- package/src/automatic/guardian.ts +26 -2
- 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
|
@@ -62,6 +62,8 @@ export interface PostPublishCheckResult {
|
|
|
62
62
|
holdText?: string;
|
|
63
63
|
/** Dettaglio per il report (publish con nota, hold). */
|
|
64
64
|
grounding?: GateVerdictReport["grounding"];
|
|
65
|
+
/** Stato deterministico delle verifiche progetto osservate. */
|
|
66
|
+
verification?: GateVerdictReport["verification"];
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/** Esito finale del gate con verdetto, per `/jev last` e diagnostica. */
|
|
@@ -76,6 +78,12 @@ export interface GateVerdictReport {
|
|
|
76
78
|
action: "warned" | "repaired" | "held";
|
|
77
79
|
reasons: string[];
|
|
78
80
|
};
|
|
81
|
+
/** Mai generato dal modello: file edit/write + check configurati osservati. */
|
|
82
|
+
verification?: {
|
|
83
|
+
action: "passed" | "warned" | "repaired" | "held";
|
|
84
|
+
changedFiles: string[];
|
|
85
|
+
checks: Array<{ id: string; state: "passed" | "failed" | "pending" }>;
|
|
86
|
+
};
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
export class GuardianLimitError extends Error {
|
|
@@ -183,6 +191,10 @@ function withNotice(message: AssistantMessage, notice: string): AssistantMessage
|
|
|
183
191
|
return { ...message, content };
|
|
184
192
|
}
|
|
185
193
|
|
|
194
|
+
function hasToolCalls(message: AssistantMessage): boolean {
|
|
195
|
+
return message.content.some((block) => block.type === "toolCall");
|
|
196
|
+
}
|
|
197
|
+
|
|
186
198
|
function assertCompleteCandidate(message: AssistantMessage): void {
|
|
187
199
|
// Rifiuta terminali non supportati per pubblicazione.
|
|
188
200
|
if (
|
|
@@ -284,12 +296,14 @@ export function runGuardedStream(
|
|
|
284
296
|
const publishCandidate = async (
|
|
285
297
|
notice?: string,
|
|
286
298
|
grounding?: GateVerdictReport["grounding"],
|
|
299
|
+
verification?: GateVerdictReport["verification"],
|
|
287
300
|
): Promise<void> => {
|
|
288
301
|
deps.onVerdict?.({
|
|
289
302
|
verdict,
|
|
290
303
|
attempts: attempt + 1,
|
|
291
304
|
outcome: "published",
|
|
292
305
|
...(grounding ? { grounding } : {}),
|
|
306
|
+
...(verification ? { verification } : {}),
|
|
293
307
|
});
|
|
294
308
|
const approved = notice ? withNotice(candidate, notice) : candidate;
|
|
295
309
|
const aggregated = aggregateUsage(usages);
|
|
@@ -339,6 +353,15 @@ export function runGuardedStream(
|
|
|
339
353
|
const runPostCheck = async (
|
|
340
354
|
baseNotice?: string,
|
|
341
355
|
): Promise<"published" | "held" | undefined> => {
|
|
356
|
+
// I toolcall devono raggiungere il runtime: un post-check su una
|
|
357
|
+
// risposta intermedia potrebbe rigenerarla/holdarla e impedire il
|
|
358
|
+
// check stesso (es. `jev_verify`) prima che venga eseguito. Safety
|
|
359
|
+
// e tool policy restano già applicate; il post-check corre sul
|
|
360
|
+
// successivo messaggio finale dopo i tool result.
|
|
361
|
+
if (hasToolCalls(candidate)) {
|
|
362
|
+
await publishCandidate(baseNotice);
|
|
363
|
+
return "published";
|
|
364
|
+
}
|
|
342
365
|
if (!deps.postPublishCheck) {
|
|
343
366
|
await publishCandidate(baseNotice);
|
|
344
367
|
return "published";
|
|
@@ -357,7 +380,7 @@ export function runGuardedStream(
|
|
|
357
380
|
signal?.throwIfAborted();
|
|
358
381
|
if (check.action === "publish") {
|
|
359
382
|
const notice = [baseNotice, check.notice].filter(Boolean).join("") || undefined;
|
|
360
|
-
await publishCandidate(notice, check.grounding);
|
|
383
|
+
await publishCandidate(notice, check.grounding, check.verification);
|
|
361
384
|
return "published";
|
|
362
385
|
}
|
|
363
386
|
if (check.action === "hold") {
|
|
@@ -367,6 +390,7 @@ export function runGuardedStream(
|
|
|
367
390
|
attempts,
|
|
368
391
|
outcome: "held",
|
|
369
392
|
...(check.grounding ? { grounding: check.grounding } : {}),
|
|
393
|
+
...(check.verification ? { verification: check.verification } : {}),
|
|
370
394
|
});
|
|
371
395
|
await publishHoldText(check.holdText ?? "[JEV — OUTPUT NON PUBBLICATO]");
|
|
372
396
|
return "held";
|
|
@@ -376,7 +400,7 @@ export function runGuardedStream(
|
|
|
376
400
|
rejectedText: rawCandidate,
|
|
377
401
|
feedback:
|
|
378
402
|
check.feedback ??
|
|
379
|
-
"
|
|
403
|
+
"Post-publication check failed; correct the draft using the supplied evidence.",
|
|
380
404
|
});
|
|
381
405
|
return undefined;
|
|
382
406
|
};
|
package/src/commands.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { reviewWithCoverage } from "./reviewer.ts";
|
|
|
19
19
|
import type { Metrics } from "./metrics.ts";
|
|
20
20
|
import type { OutputVerdict } from "./output-judge.ts";
|
|
21
21
|
import type { GateVerdictReport } from "./automatic/guardian.ts";
|
|
22
|
+
import type { VerificationMode } from "./verification.ts";
|
|
22
23
|
|
|
23
24
|
export interface GateStatusView {
|
|
24
25
|
active: boolean;
|
|
@@ -79,6 +80,27 @@ export interface JevCommandDeps {
|
|
|
79
80
|
hasStaleTests: boolean;
|
|
80
81
|
};
|
|
81
82
|
setGrounding: (mode: "off" | "warn" | "repair" | "hold") => JevConfig;
|
|
83
|
+
verificationStatus?: (cwd: string) => {
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
mode: VerificationMode;
|
|
86
|
+
manifestPath: string;
|
|
87
|
+
manifestLoaded: boolean;
|
|
88
|
+
manifestError?: string;
|
|
89
|
+
changedFiles: string[];
|
|
90
|
+
checks: Array<{ id: string; state: "passed" | "failed" | "pending" }>;
|
|
91
|
+
};
|
|
92
|
+
setVerification?: (mode: "off" | VerificationMode) => JevConfig;
|
|
93
|
+
reloadVerificationManifest?: (cwd: string) => {
|
|
94
|
+
ok: boolean;
|
|
95
|
+
error?: string;
|
|
96
|
+
path?: string;
|
|
97
|
+
};
|
|
98
|
+
runVerification?: (args: {
|
|
99
|
+
cwd: string;
|
|
100
|
+
selection: string | undefined;
|
|
101
|
+
signal?: AbortSignal;
|
|
102
|
+
trusted: boolean;
|
|
103
|
+
}) => Promise<{ ok: boolean; message: string; results: unknown[] }>;
|
|
82
104
|
}
|
|
83
105
|
|
|
84
106
|
export const JEV_SUBCOMMANDS = [
|
|
@@ -96,6 +118,8 @@ export const JEV_SUBCOMMANDS = [
|
|
|
96
118
|
"output",
|
|
97
119
|
"last",
|
|
98
120
|
"grounding",
|
|
121
|
+
"verification",
|
|
122
|
+
"verify",
|
|
99
123
|
"backend",
|
|
100
124
|
"save-key",
|
|
101
125
|
"help",
|
|
@@ -121,6 +145,10 @@ export const JEV_HELP_TEXT = [
|
|
|
121
145
|
" /jev last Ultimo verdetto del gate (twin): esito e controlli",
|
|
122
146
|
" /jev grounding off|warn|repair|hold",
|
|
123
147
|
" Grounding affermazioni sessione (solo automatic, opt-in)",
|
|
148
|
+
" /jev verification off|warn|repair|hold|reload",
|
|
149
|
+
" Footer deterministico + repair dai check progetto (opt-in; reload acquisisce manifest)",
|
|
150
|
+
" /jev verify [recommended|all|id]",
|
|
151
|
+
" Esegue solo check dichiarati in .pi/jev-verification.json",
|
|
124
152
|
" /jev backend Mostra backend attivo e chiavi rilevate",
|
|
125
153
|
" /jev backend auto|openrouter|typesafe",
|
|
126
154
|
" Cambia backend (salvato in jev-config.json)",
|
|
@@ -140,7 +168,7 @@ export const JEV_HELP_TEXT = [
|
|
|
140
168
|
export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
141
169
|
pi.registerCommand("jev", {
|
|
142
170
|
description:
|
|
143
|
-
"Jev guard:
|
|
171
|
+
"Jev guard: gate, grounding e verifica progetto. Sottocomandi: status, mode, upstream, check, verification, verify, stats, help.",
|
|
144
172
|
getArgumentCompletions: (argumentPrefix) => {
|
|
145
173
|
const prefix = argumentPrefix.trim().toLowerCase();
|
|
146
174
|
return JEV_SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map(
|
|
@@ -188,6 +216,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
188
216
|
const info = deps.reviewerInfo();
|
|
189
217
|
const gate = deps.gateStatus();
|
|
190
218
|
const twinView = deps.twinStatus();
|
|
219
|
+
const verification = deps.verificationStatus?.(ctx.cwd);
|
|
191
220
|
const authProvider = twinView.primary?.provider ?? cfg.automatic.upstreamProvider;
|
|
192
221
|
let guardedAuth = "—";
|
|
193
222
|
if (gate.active) {
|
|
@@ -250,6 +279,11 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
250
279
|
`Exfil check: ${cfg.exfilCheck.enabled ? "attivo in automatic (confirm in TUI, fail-open)" : "disattivo (config)"}`,
|
|
251
280
|
`Output judge: ${cfg.outputJudge.enabled ? `attivo in automatic (tool: ${cfg.outputJudge.tools.join(",") || "—"}, fail-open)` : "disattivo (config)"}`,
|
|
252
281
|
`Grounding: ${cfg.grounding.enabled ? `attivo in automatic (mode=${cfg.grounding.mode}, evidenza=${deps.groundingStatus().entries}${deps.groundingStatus().hasStaleTests ? ", test stale" : ""})` : "disattivo (opt-in: /jev grounding warn)"}`,
|
|
282
|
+
verification
|
|
283
|
+
? `Verifica progetto: ${verification.enabled
|
|
284
|
+
? `attiva (mode=${verification.mode}, manifest=${verification.manifestLoaded ? "OK" : verification.manifestError ?? "assente"}, modifiche=${verification.changedFiles.length}, check=${verification.checks.map((check) => `${check.id}:${check.state}`).join(",") || "—"})`
|
|
285
|
+
: `disattiva (opt-in: /jev verification warn; manifest=${verification.manifestPath})`}`
|
|
286
|
+
: "Verifica progetto: non disponibile in questa estensione",
|
|
253
287
|
cfg.mode === "automatic"
|
|
254
288
|
? "ATTENZIONE: in automatic testo/codice vengono inviati al verificatore. Crea il twin con /jev upstream e seleziona il modello __jev per enforcement."
|
|
255
289
|
: "on-demand: nessuna verifica automatica.",
|
|
@@ -465,6 +499,9 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
465
499
|
last.report.grounding
|
|
466
500
|
? `grounding: ${last.report.grounding.action} (${last.report.grounding.reasons.join(", ") || "n/a"})`
|
|
467
501
|
: "grounding: non intervenuto",
|
|
502
|
+
last.report.verification
|
|
503
|
+
? `verifica progetto: ${last.report.verification.action} (file=${last.report.verification.changedFiles.join(",") || "—"}; check=${last.report.verification.checks.map((check) => `${check.id}:${check.state}`).join(",") || "—"})`
|
|
504
|
+
: "verifica progetto: non intervenuta",
|
|
468
505
|
v.errorCode ? `errore: ${v.errorCode}` : `backend: ${v.backend ?? "?"} · modello: ${v.model ?? "?"}`,
|
|
469
506
|
v.truncated ? `troncato: ${JSON.stringify(v.truncated)}` : "copertura intera",
|
|
470
507
|
`tempo verifica: ${Math.round(v.elapsedMs)}ms`,
|
|
@@ -521,6 +558,74 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
521
558
|
return;
|
|
522
559
|
}
|
|
523
560
|
|
|
561
|
+
if (sub === "verification") {
|
|
562
|
+
if (!deps.verificationStatus || !deps.setVerification) {
|
|
563
|
+
ctx.ui.notify("Verifica progetto non disponibile in questa versione dell'estensione.", "warning");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const value = parts[1];
|
|
567
|
+
if (value === undefined) {
|
|
568
|
+
const st = deps.verificationStatus(ctx.cwd);
|
|
569
|
+
ctx.ui.notify(
|
|
570
|
+
st.enabled
|
|
571
|
+
? [
|
|
572
|
+
`Verifica progetto attiva in automatic (mode=${st.mode}).`,
|
|
573
|
+
`Manifest: ${st.manifestPath} — ${st.manifestLoaded ? "OK" : st.manifestError ?? "non disponibile"}.`,
|
|
574
|
+
`Modifiche osservate: ${st.changedFiles.join(", ") || "nessuna"}.`,
|
|
575
|
+
`Check: ${st.checks.map((check) => `${check.id}:${check.state}`).join(", ") || "nessuno pertinente"}.`,
|
|
576
|
+
"Esegui quelli mancanti con /jev verify recommended. Dopo una modifica manuale del manifest: /jev verification reload.",
|
|
577
|
+
].join("\n")
|
|
578
|
+
: `Verifica progetto disattiva (opt-in). Manifest previsto: ${st.manifestPath}. Attiva con /jev verification warn|repair|hold.`,
|
|
579
|
+
"info",
|
|
580
|
+
);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (value === "reload") {
|
|
584
|
+
if (!deps.reloadVerificationManifest) {
|
|
585
|
+
ctx.ui.notify("Reload manifest non disponibile in questa versione dell'estensione.", "warning");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const reloaded = deps.reloadVerificationManifest(ctx.cwd);
|
|
589
|
+
ctx.ui.notify(
|
|
590
|
+
reloaded.ok
|
|
591
|
+
? `Manifest verifica acquisito: ${reloaded.path}. I comandi verranno eseguiti solo da questa snapshot.`
|
|
592
|
+
: `Manifest verifica non acquisito: ${reloaded.error}`,
|
|
593
|
+
reloaded.ok ? "info" : "warning",
|
|
594
|
+
);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (value !== "off" && value !== "warn" && value !== "repair" && value !== "hold") {
|
|
598
|
+
ctx.ui.notify("Uso: /jev verification off|warn|repair|hold|reload", "warning");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
deps.setVerification(value);
|
|
602
|
+
ctx.ui.notify(
|
|
603
|
+
value === "off"
|
|
604
|
+
? "Verifica progetto disattivata."
|
|
605
|
+
: `Verifica progetto attiva (mode=${value}). Crea ${deps.verificationStatus(ctx.cwd).manifestPath} con i soli comandi approvati, poi usa /jev verify recommended.`,
|
|
606
|
+
"info",
|
|
607
|
+
);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (sub === "verify") {
|
|
612
|
+
if (!deps.runVerification) {
|
|
613
|
+
ctx.ui.notify("Verifica progetto non disponibile in questa versione dell'estensione.", "warning");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const trusted = typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
|
|
617
|
+
? ctx.isProjectTrusted()
|
|
618
|
+
: false;
|
|
619
|
+
const result = await deps.runVerification({
|
|
620
|
+
cwd: ctx.cwd,
|
|
621
|
+
selection: parts[1],
|
|
622
|
+
signal: ctx.signal ?? undefined,
|
|
623
|
+
trusted,
|
|
624
|
+
});
|
|
625
|
+
ctx.ui.notify(result.message, result.ok ? "info" : "warning");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
524
629
|
if (sub === "reload") {
|
|
525
630
|
const cfg = deps.reload();
|
|
526
631
|
ctx.ui.notify(`Config ricaricata. mode=${cfg.mode} backend=${cfg.jev.backend}`, "info");
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import type { JevBackend } from "./reviewer.ts";
|
|
5
|
+
import type { VerificationSettings } from "./verification.ts";
|
|
5
6
|
|
|
6
7
|
export type JevMode = "on-demand" | "automatic";
|
|
7
8
|
export type JevBackendSetting = JevBackend | "auto";
|
|
@@ -97,6 +98,7 @@ export interface JevConfig {
|
|
|
97
98
|
/** Una modifica riuscita invalida i test precedenti (stale). */
|
|
98
99
|
invalidateTestsOnEdit: boolean;
|
|
99
100
|
};
|
|
101
|
+
verification: VerificationSettings;
|
|
100
102
|
exfilCheck: {
|
|
101
103
|
/** Check semantico sui comandi con tool di rete (solo automatic). */
|
|
102
104
|
enabled: boolean;
|
|
@@ -194,6 +196,17 @@ export const DEFAULT_CONFIG: JevConfig = {
|
|
|
194
196
|
cacheSeconds: 120,
|
|
195
197
|
invalidateTestsOnEdit: true,
|
|
196
198
|
},
|
|
199
|
+
verification: {
|
|
200
|
+
// Opt-in: il manifest può eseguire solo comandi esplicitamente approvati
|
|
201
|
+
// dal progetto; non abilitarlo silenziosamente in installazioni esistenti.
|
|
202
|
+
enabled: false,
|
|
203
|
+
mode: "warn",
|
|
204
|
+
manifestPath: ".pi/jev-verification.json",
|
|
205
|
+
maxRepairs: 1,
|
|
206
|
+
commandTimeoutMs: 120000,
|
|
207
|
+
outputChars: 6000,
|
|
208
|
+
maxCommands: 3,
|
|
209
|
+
},
|
|
197
210
|
exfilCheck: {
|
|
198
211
|
enabled: true,
|
|
199
212
|
tools: ["bash", "powershell"],
|
|
@@ -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
|
+
}
|