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.
@@ -0,0 +1,589 @@
1
+ /**
2
+ * Verifica deterministica delle modifiche osservate nella sessione.
3
+ *
4
+ * Diversa da Jev: qui non si prova a "capire" se il codice è corretto.
5
+ * Si conserva soltanto un fatto osservabile: edit/write riusciti e l'esito
6
+ * di comandi dichiarati dal progetto. Il footer finale è costruito da questi
7
+ * dati, non dal testo del modello, quindi non può inventare test superati.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { isAbsolute, relative, resolve, sep } from "node:path";
12
+
13
+ export type VerificationMode = "warn" | "repair" | "hold";
14
+
15
+ /** Impostazioni condivise; i comandi eseguibili restano nel manifest locale. */
16
+ export interface VerificationSettings {
17
+ /** Opt-in: nessun footer, repair o comando automatico finché è false. */
18
+ enabled: boolean;
19
+ /** warn = footer; repair = un tentativo privato; hold = blocco rigoroso. */
20
+ mode: VerificationMode;
21
+ /** File JSON relativo alla root del progetto, mai un comando generato dal modello. */
22
+ manifestPath: string;
23
+ /** Rigenerazioni massime per risposta quando mancano/falliscono check. */
24
+ maxRepairs: number;
25
+ /** Timeout massimo predefinito dei check avviati da jev_verify o /jev verify. */
26
+ commandTimeoutMs: number;
27
+ /** Output massimo restituito al modello per un check fallito. */
28
+ outputChars: number;
29
+ /** Limite di check eseguibili in una richiesta `recommended`/`all`. */
30
+ maxCommands: number;
31
+ }
32
+
33
+ export interface VerificationCheck {
34
+ /** Id stabile, usato da /jev verify e dal tool jev_verify. */
35
+ id: string;
36
+ /** Comando esplicitamente approvato dal manifest del progetto. */
37
+ command: string;
38
+ /** Glob dei file a cui il check si applica; assente = tutte le modifiche. */
39
+ files?: string[];
40
+ description?: string;
41
+ /** Override bounded dal timeout globale del guard. */
42
+ timeoutMs?: number;
43
+ }
44
+
45
+ export interface VerificationManifest {
46
+ version: 1;
47
+ checks: VerificationCheck[];
48
+ }
49
+
50
+ export type LoadedVerificationManifest =
51
+ | { ok: true; path: string; manifest: VerificationManifest; fingerprint: string }
52
+ | { ok: false; path?: string; error: string };
53
+
54
+ /** Manifest acquisito a inizio sessione o su comando utente esplicito. */
55
+ export type VerificationManifestTrust =
56
+ | { ok: true; cwd: string; path: string; manifest: VerificationManifest; fingerprint: string }
57
+ | { ok: false; cwd: string; error: string };
58
+
59
+ /** Un edit/write osservato con successo; non pretende di vedere modifiche via shell. */
60
+ export interface ObservedModification {
61
+ path: string;
62
+ seq: number;
63
+ }
64
+
65
+ export interface VerificationRun {
66
+ id: string;
67
+ command: string;
68
+ ok: boolean;
69
+ /** Assente quando il tool non espone l'exit code. */
70
+ exitCode?: number;
71
+ /**
72
+ * Ordine di INIZIO del check. Deve essere successivo al completamento
73
+ * dell'edit pertinente: un test partito in parallelo prima dell'edit non
74
+ * diventa fresco solo perché il suo tool_result arriva dopo.
75
+ */
76
+ seq: number;
77
+ source: "jev_verify" | "bash" | "powershell" | "manual";
78
+ }
79
+
80
+ export interface VerificationState {
81
+ modifications: ObservedModification[];
82
+ runs: VerificationRun[];
83
+ }
84
+
85
+ export type VerificationCheckState = "passed" | "failed" | "pending";
86
+
87
+ export interface VerificationCheckStatus {
88
+ check: VerificationCheck;
89
+ state: VerificationCheckState;
90
+ /** Ultimo run dopo l'ultima modifica pertinente, se presente. */
91
+ run?: VerificationRun;
92
+ latestRelevantEditSeq: number;
93
+ }
94
+
95
+ export interface VerificationStatus {
96
+ changedFiles: string[];
97
+ /** Errore/mancanza manifest: non è una prova negativa sul codice. */
98
+ manifestError?: string;
99
+ checks: VerificationCheckStatus[];
100
+ /** Esistono check dichiarati che corrispondono alle modifiche osservate. */
101
+ actionable: boolean;
102
+ hasPending: boolean;
103
+ hasFailed: boolean;
104
+ allPassed: boolean;
105
+ }
106
+
107
+ export function createVerificationState(): VerificationState {
108
+ return { modifications: [], runs: [] };
109
+ }
110
+
111
+ /**
112
+ * Trasforma un path tool in un path relativo al progetto. I path fuori root
113
+ * non entrano nel manifest locale: possono essere riportati dal modello, ma
114
+ * non diventano falsamente coperti da un check del progetto corrente.
115
+ */
116
+ export function normalizeObservedPath(path: unknown, cwd: string): string | undefined {
117
+ if (typeof path !== "string" || !path.trim()) return undefined;
118
+ const root = resolve(cwd);
119
+ const absolute = resolve(root, path.trim());
120
+ const rel = relative(root, absolute);
121
+ if (!rel || rel === "." || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
122
+ return undefined;
123
+ }
124
+ return rel.split(sep).join("/");
125
+ }
126
+
127
+ /** Registra l'ultima modifica riuscita per path; cap conservativo della memoria. */
128
+ export function recordObservedModification(
129
+ state: VerificationState,
130
+ path: string,
131
+ seq: number,
132
+ ): void {
133
+ const existing = state.modifications.find((entry) => entry.path === path);
134
+ if (existing) {
135
+ existing.seq = seq;
136
+ } else {
137
+ state.modifications.push({ path, seq });
138
+ }
139
+ if (state.modifications.length > 100) {
140
+ state.modifications.sort((a, b) => a.seq - b.seq);
141
+ state.modifications.splice(0, state.modifications.length - 100);
142
+ }
143
+ }
144
+
145
+ /** Registra un esito reale; più run dello stesso id sono mantenuti per il freshness check. */
146
+ export function recordVerificationRun(
147
+ state: VerificationState,
148
+ run: VerificationRun,
149
+ ): void {
150
+ state.runs.push({ ...run });
151
+ if (state.runs.length > 200) state.runs.splice(0, state.runs.length - 200);
152
+ }
153
+
154
+ function isRecord(value: unknown): value is Record<string, unknown> {
155
+ return typeof value === "object" && value !== null && !Array.isArray(value);
156
+ }
157
+
158
+ const CHECK_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
159
+
160
+ function parseFiles(value: unknown, index: number): string[] | undefined {
161
+ if (value === undefined) return undefined;
162
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
163
+ throw new Error(`checks[${index}].files deve essere un array non vuoto (max 100)`);
164
+ }
165
+ const files = value.map((item, itemIndex) => {
166
+ if (typeof item !== "string" || !item.trim() || item.length > 240) {
167
+ throw new Error(`checks[${index}].files[${itemIndex}] non valido`);
168
+ }
169
+ const normalized = item.trim().replace(/\\/g, "/");
170
+ if (normalized.startsWith("/") || normalized.includes("\0")) {
171
+ throw new Error(`checks[${index}].files[${itemIndex}] deve essere un glob relativo`);
172
+ }
173
+ return normalized;
174
+ });
175
+ return [...new Set(files)];
176
+ }
177
+
178
+ /** Parser stretto: un manifest malformato non esegue mai comandi. */
179
+ export function parseVerificationManifest(value: unknown): VerificationManifest {
180
+ if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.checks)) {
181
+ throw new Error("manifest richiede { version: 1, checks: [...] }");
182
+ }
183
+ if (value.checks.length === 0 || value.checks.length > 50) {
184
+ throw new Error("manifest.checks deve contenere da 1 a 50 check");
185
+ }
186
+ const seen = new Set<string>();
187
+ const checks: VerificationCheck[] = value.checks.map((raw, index) => {
188
+ if (!isRecord(raw)) throw new Error(`checks[${index}] non è un oggetto`);
189
+ const id = raw.id;
190
+ const command = raw.command;
191
+ if (typeof id !== "string" || !CHECK_ID.test(id)) {
192
+ throw new Error(`checks[${index}].id non valido`);
193
+ }
194
+ if (seen.has(id)) throw new Error(`id check duplicato: ${id}`);
195
+ seen.add(id);
196
+ if (
197
+ typeof command !== "string" ||
198
+ !command.trim() ||
199
+ command.length > 2000 ||
200
+ command.includes("\0") ||
201
+ /[\r\n]/.test(command)
202
+ ) {
203
+ throw new Error(`checks[${index}].command non valido`);
204
+ }
205
+ const description = raw.description;
206
+ if (description !== undefined && (typeof description !== "string" || description.length > 300)) {
207
+ throw new Error(`checks[${index}].description non valida`);
208
+ }
209
+ const timeoutMs = raw.timeoutMs;
210
+ if (timeoutMs !== undefined && (
211
+ typeof timeoutMs !== "number" ||
212
+ !Number.isInteger(timeoutMs) ||
213
+ timeoutMs < 1000 ||
214
+ timeoutMs > 3_600_000
215
+ )) {
216
+ throw new Error(`checks[${index}].timeoutMs non valido`);
217
+ }
218
+ const files = parseFiles(raw.files, index);
219
+ return {
220
+ id,
221
+ command: command.trim(),
222
+ ...(files ? { files } : {}),
223
+ ...(typeof description === "string" && description ? { description } : {}),
224
+ ...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
225
+ };
226
+ });
227
+ return { version: 1, checks };
228
+ }
229
+
230
+ /** Risolve un manifest esclusivamente dentro la root progetto. */
231
+ export function resolveManifestPath(cwd: string, manifestPath: string): string | undefined {
232
+ if (!manifestPath.trim()) return undefined;
233
+ const root = resolve(cwd);
234
+ const target = resolve(root, manifestPath.trim());
235
+ const rel = relative(root, target);
236
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
237
+ return target;
238
+ }
239
+
240
+ /** Il modello non può alterare la policy che autorizza i suoi comandi. */
241
+ export function isVerificationManifestPath(
242
+ path: unknown,
243
+ cwd: string,
244
+ manifestPath: string,
245
+ ): boolean {
246
+ if (typeof path !== "string" || !path.trim()) return false;
247
+ const manifest = resolveManifestPath(cwd, manifestPath);
248
+ if (!manifest) return false;
249
+ return resolve(cwd, path.trim()) === manifest;
250
+ }
251
+
252
+ function fingerprintText(text: string): string {
253
+ // Non è una firma/autorizzazione remota: serve a rilevare con robustezza
254
+ // una mutazione del file dopo la snapshot locale trusted.
255
+ return createHash("sha256").update(text).digest("hex");
256
+ }
257
+
258
+ export function loadVerificationManifest(
259
+ cwd: string,
260
+ manifestPath: string,
261
+ ): LoadedVerificationManifest {
262
+ const path = resolveManifestPath(cwd, manifestPath);
263
+ if (!path) {
264
+ return { ok: false, error: "percorso manifest non valido: deve restare dentro il progetto" };
265
+ }
266
+ if (!existsSync(path)) {
267
+ return { ok: false, path, error: `manifest non trovato: ${manifestPath}` };
268
+ }
269
+ try {
270
+ const text = readFileSync(path, "utf8");
271
+ const parsed = JSON.parse(text);
272
+ return {
273
+ ok: true,
274
+ path,
275
+ manifest: parseVerificationManifest(parsed),
276
+ fingerprint: fingerprintText(text),
277
+ };
278
+ } catch (error) {
279
+ return {
280
+ ok: false,
281
+ path,
282
+ error: `manifest non valido: ${error instanceof Error ? error.message : String(error)}`,
283
+ };
284
+ }
285
+ }
286
+
287
+ /** Acquisisce la policy di esecuzione dal disco in un punto esplicitamente utente. */
288
+ export function trustVerificationManifest(
289
+ cwd: string,
290
+ manifestPath: string,
291
+ ): VerificationManifestTrust {
292
+ const loaded = loadVerificationManifest(cwd, manifestPath);
293
+ return loaded.ok
294
+ ? {
295
+ ok: true,
296
+ cwd: resolve(cwd),
297
+ path: loaded.path,
298
+ manifest: loaded.manifest,
299
+ fingerprint: loaded.fingerprint,
300
+ }
301
+ : { ok: false, cwd: resolve(cwd), error: loaded.error };
302
+ }
303
+
304
+ /**
305
+ * Ritorna il manifest acquisito, ma rifiuta mutazioni del file dopo il trust.
306
+ * I comandi vengono poi selezionati dall'oggetto in memoria acquisito, non
307
+ * dal file corrente: un agente non può scrivere un comando e farlo eseguire
308
+ * nella stessa sessione.
309
+ */
310
+ export function trustedVerificationManifest(
311
+ trust: VerificationManifestTrust | undefined,
312
+ cwd: string,
313
+ manifestPath: string,
314
+ ): LoadedVerificationManifest {
315
+ if (!trust) return { ok: false, error: "manifest non ancora acquisito; usa /jev verification reload" };
316
+ if (!trust.ok) return { ok: false, error: trust.error };
317
+ if (trust.cwd !== resolve(cwd)) {
318
+ return { ok: false, error: "manifest acquisito per un'altra directory; usa /jev verification reload" };
319
+ }
320
+ const current = loadVerificationManifest(cwd, manifestPath);
321
+ if (!current.ok) return current;
322
+ if (current.path !== trust.path || current.fingerprint !== trust.fingerprint) {
323
+ return {
324
+ ok: false,
325
+ path: current.path,
326
+ error: "manifest modificato dopo l'acquisizione; controllalo e usa /jev verification reload",
327
+ };
328
+ }
329
+ return {
330
+ ok: true,
331
+ path: trust.path,
332
+ manifest: trust.manifest,
333
+ fingerprint: trust.fingerprint,
334
+ };
335
+ }
336
+
337
+ function globToRegExp(pattern: string): RegExp {
338
+ let source = "^";
339
+ for (let i = 0; i < pattern.length; i++) {
340
+ const char = pattern[i];
341
+ if (char === "*") {
342
+ if (pattern[i + 1] === "*") {
343
+ if (pattern[i + 2] === "/") {
344
+ source += "(?:.*/)?";
345
+ i += 2;
346
+ } else {
347
+ source += ".*";
348
+ i++;
349
+ }
350
+ } else {
351
+ source += "[^/]*";
352
+ }
353
+ } else if (char === "?") {
354
+ source += "[^/]";
355
+ } else {
356
+ source += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
357
+ }
358
+ }
359
+ return new RegExp(`${source}$`);
360
+ }
361
+
362
+ /** True se il check è pertinente a un file; `files` assente significa globale. */
363
+ export function checkMatchesPath(check: VerificationCheck, path: string): boolean {
364
+ if (!check.files || check.files.length === 0) return true;
365
+ return check.files.some((pattern) => globToRegExp(pattern).test(path));
366
+ }
367
+
368
+ function latestRunAfter(
369
+ state: VerificationState,
370
+ id: string,
371
+ afterSeq: number,
372
+ ): VerificationRun | undefined {
373
+ return state.runs
374
+ .filter((run) => run.id === id && run.seq > afterSeq)
375
+ .sort((a, b) => b.seq - a.seq)[0];
376
+ }
377
+
378
+ /** Stato derivato esclusivamente da modifiche e run osservati. */
379
+ export function buildVerificationStatus(
380
+ state: VerificationState,
381
+ loaded: LoadedVerificationManifest,
382
+ ): VerificationStatus {
383
+ const modifications = [...state.modifications].sort((a, b) => a.path.localeCompare(b.path));
384
+ const changedFiles = modifications.map((entry) => entry.path);
385
+ if (changedFiles.length === 0) {
386
+ return {
387
+ changedFiles,
388
+ ...(loaded.ok ? {} : { manifestError: loaded.error }),
389
+ checks: [],
390
+ actionable: false,
391
+ hasPending: false,
392
+ hasFailed: false,
393
+ allPassed: false,
394
+ };
395
+ }
396
+ if (!loaded.ok) {
397
+ return {
398
+ changedFiles,
399
+ manifestError: loaded.error,
400
+ checks: [],
401
+ actionable: false,
402
+ hasPending: false,
403
+ hasFailed: false,
404
+ allPassed: false,
405
+ };
406
+ }
407
+ const checks: VerificationCheckStatus[] = [];
408
+ for (const check of loaded.manifest.checks) {
409
+ const relevant = modifications.filter((edit) => checkMatchesPath(check, edit.path));
410
+ if (relevant.length === 0) continue;
411
+ const latestRelevantEditSeq = Math.max(...relevant.map((edit) => edit.seq));
412
+ const run = latestRunAfter(state, check.id, latestRelevantEditSeq);
413
+ checks.push({
414
+ check,
415
+ latestRelevantEditSeq,
416
+ ...(run ? { run } : {}),
417
+ state: run ? (run.ok ? "passed" : "failed") : "pending",
418
+ });
419
+ }
420
+ const hasPending = checks.some((check) => check.state === "pending");
421
+ const hasFailed = checks.some((check) => check.state === "failed");
422
+ return {
423
+ changedFiles,
424
+ checks,
425
+ actionable: checks.length > 0,
426
+ hasPending,
427
+ hasFailed,
428
+ allPassed: checks.length > 0 && !hasPending && !hasFailed,
429
+ };
430
+ }
431
+
432
+ function code(value: string): string {
433
+ return `\`${value.replace(/`/g, "'")}\``;
434
+ }
435
+
436
+ function shortList(values: string[], max = 8): string {
437
+ const shown = values.slice(0, max).map(code).join(", ");
438
+ return values.length > max ? `${shown}, +${values.length - max}` : shown;
439
+ }
440
+
441
+ /** Footer trasparente, costruito dal guard e non dal modello. */
442
+ export function verificationNotice(status: VerificationStatus): string | undefined {
443
+ if (status.changedFiles.length === 0) return undefined;
444
+ const lines = [
445
+ "",
446
+ "> [jev] verifica osservata (generata dal guard, non dal modello)",
447
+ `> - Modifiche osservate via edit/write: ${shortList(status.changedFiles)}`,
448
+ ];
449
+ if (status.manifestError) {
450
+ lines.push(`> - Stato: ${status.manifestError}; nessuna verifica configurata è stata attribuita.`);
451
+ return lines.join("\n");
452
+ }
453
+ if (!status.actionable) {
454
+ lines.push(
455
+ "> - Stato: nessun check del manifest corrisponde ai file modificati; correttezza non verificata.",
456
+ );
457
+ return lines.join("\n");
458
+ }
459
+ for (const item of status.checks) {
460
+ const label = code(item.check.id);
461
+ if (item.state === "passed") lines.push(`> - ${label}: superato dopo l'ultima modifica pertinente.`);
462
+ else if (item.state === "failed") {
463
+ const detail = item.run?.exitCode !== undefined ? ` (exit ${item.run.exitCode})` : "";
464
+ lines.push(`> - ${label}: fallito${detail}.`);
465
+ } else {
466
+ lines.push(`> - ${label}: non eseguito dopo l'ultima modifica pertinente.`);
467
+ }
468
+ }
469
+ return lines.join("\n");
470
+ }
471
+
472
+ export interface VerificationDecision {
473
+ action: "publish" | "regenerate" | "hold";
474
+ notice?: string;
475
+ feedback?: string;
476
+ holdText?: string;
477
+ }
478
+
479
+ export function buildVerificationRepairFeedback(status: VerificationStatus): string {
480
+ const pending = status.checks.filter((item) => item.state === "pending").map((item) => item.check.id);
481
+ const failed = status.checks.filter((item) => item.state === "failed").map((item) => item.check.id);
482
+ return [
483
+ "Deterministic verification is incomplete or failing after observed edits.",
484
+ pending.length > 0 ? `Checks not run after relevant edits: ${pending.join(", ")}.` : "",
485
+ failed.length > 0 ? `Checks failed: ${failed.join(", ")}.` : "",
486
+ "Use jev_verify with check='recommended' to run only the project-approved pending checks.",
487
+ "If a check failed, inspect its real tool output, fix the code, then run it again.",
488
+ "Do not state that code compiles or tests pass unless the observed verification status says so.",
489
+ ].filter(Boolean).join("\n");
490
+ }
491
+
492
+ export function buildVerificationHoldText(status: VerificationStatus): string {
493
+ const failed = status.checks.filter((item) => item.state === "failed").map((item) => item.check.id);
494
+ const pending = status.checks.filter((item) => item.state === "pending").map((item) => item.check.id);
495
+ return [
496
+ "[JEV — OUTPUT NON PUBBLICATO]",
497
+ "",
498
+ "Risultato: verification-hold",
499
+ `Modifiche osservate: ${status.changedFiles.join(", ")}`,
500
+ failed.length > 0 ? `Check falliti: ${failed.join(", ")}` : "",
501
+ pending.length > 0 ? `Check mancanti: ${pending.join(", ")}` : "",
502
+ "",
503
+ "La risposta non viene pubblicata finché i check configurati non sono aggiornati.",
504
+ ].filter((line) => line !== "").join("\n");
505
+ }
506
+
507
+ /**
508
+ * `hold` è deliberatamente stretto: senza manifest/non pertinente non blocca.
509
+ * Su check mancanti permette prima una riparazione; su failure concreta trattiene.
510
+ */
511
+ export function decideVerificationAction(args: {
512
+ status: VerificationStatus;
513
+ mode: VerificationMode;
514
+ repairsUsed: number;
515
+ maxRepairs: number;
516
+ }): VerificationDecision {
517
+ const { status, mode, repairsUsed, maxRepairs } = args;
518
+ const notice = verificationNotice(status);
519
+ if (status.changedFiles.length === 0 || !status.actionable) {
520
+ return { action: "publish", ...(notice ? { notice } : {}) };
521
+ }
522
+ const needsAction = status.hasPending || status.hasFailed;
523
+ if (!needsAction) return { action: "publish", ...(notice ? { notice } : {}) };
524
+ if (mode === "warn") return { action: "publish", ...(notice ? { notice } : {}) };
525
+ if (mode === "hold" && status.hasFailed) {
526
+ return { action: "hold", holdText: buildVerificationHoldText(status) };
527
+ }
528
+ if (repairsUsed < Math.max(0, maxRepairs)) {
529
+ return { action: "regenerate", feedback: buildVerificationRepairFeedback(status) };
530
+ }
531
+ if (mode === "hold") {
532
+ return { action: "hold", holdText: buildVerificationHoldText(status) };
533
+ }
534
+ return { action: "publish", ...(notice ? { notice } : {}) };
535
+ }
536
+
537
+ export type VerificationSelection = "recommended" | "all" | string;
538
+
539
+ export type SelectedChecks =
540
+ | { ok: true; checks: VerificationCheck[] }
541
+ | { ok: false; error: string };
542
+
543
+ /** Seleziona soltanto id dichiarati; il modello non può passare un comando. */
544
+ export function selectVerificationChecks(
545
+ loaded: LoadedVerificationManifest,
546
+ status: VerificationStatus,
547
+ selection: VerificationSelection | undefined,
548
+ maxCommands: number,
549
+ ): SelectedChecks {
550
+ if (!loaded.ok) return { ok: false, error: loaded.error };
551
+ const requested = (selection ?? "recommended").trim();
552
+ let checks: VerificationCheck[];
553
+ if (requested === "recommended") {
554
+ if (status.changedFiles.length === 0) {
555
+ return { ok: false, error: "nessuna modifica edit/write osservata; usa all o un id esplicito" };
556
+ }
557
+ checks = status.checks
558
+ .filter((item) => item.state !== "passed")
559
+ .map((item) => item.check);
560
+ if (checks.length === 0) return { ok: false, error: "tutti i check pertinenti risultano già superati" };
561
+ } else if (requested === "all") {
562
+ checks = [...loaded.manifest.checks];
563
+ } else {
564
+ const found = loaded.manifest.checks.find((check) => check.id === requested);
565
+ if (!found) return { ok: false, error: `check sconosciuto: ${requested}` };
566
+ checks = [found];
567
+ }
568
+ const numericMax = typeof maxCommands === "number" && Number.isFinite(maxCommands)
569
+ ? maxCommands
570
+ : 3;
571
+ const max = Math.max(1, Math.min(20, Math.floor(numericMax)));
572
+ if (checks.length > max) {
573
+ return { ok: false, error: `selezione contiene ${checks.length} check, oltre maxCommands=${max}` };
574
+ }
575
+ return { ok: true, checks };
576
+ }
577
+
578
+ /** Match conservativo per i bash nativi: solo comando identico, whitespace normalizzato. */
579
+ export function manifestCheckForCommand(
580
+ manifest: VerificationManifest,
581
+ command: unknown,
582
+ ): VerificationCheck | undefined {
583
+ if (typeof command !== "string") return undefined;
584
+ const normalized = command.trim().replace(/\s+/g, " ");
585
+ if (!normalized) return undefined;
586
+ return manifest.checks.find(
587
+ (check) => check.command.replace(/\s+/g, " ") === normalized,
588
+ );
589
+ }