agentic-kdd 3.0.4 → 3.2.1

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,851 @@
1
+ /**
2
+ * Agentic KDD — Contract Guard v1.0
3
+ * Preservation Intelligence Layer (PIL)
4
+ *
5
+ * El sistema no solo recuerda errores — protege activamente lo que funciona.
6
+ *
7
+ * ┌─────────────────────────────────────────────────────────────────────────┐
8
+ * │ PROBLEMA QUE RESUELVE: │
9
+ * │ El agente aprende "qué no hacer" pero no mantiene una lista viva de │
10
+ * │ "qué debe seguir funcionando". Contract Guard cierra ese gap. │
11
+ * │ │
12
+ * │ Bug A → Fix A → OK │
13
+ * │ Bug B → Fix B → Login roto (daño colateral no detectado) │
14
+ * │ │
15
+ * │ Con Contract Guard: │
16
+ * │ Bug B → Fix B → Preservation Gate detecta AUTH-001 roto → STOP │
17
+ * └─────────────────────────────────────────────────────────────────────────┘
18
+ *
19
+ * FLUJO:
20
+ * 1. Auto-genera contratos desde tests que pasan (sin intervención del dev)
21
+ * 2. Promueve: candidate → verified → protected (basado en evidencia)
22
+ * 3. Antes de aceptar cambios: verifica que contratos protegidos siguen verdes
23
+ * 4. Si algo falla: STOP con reporte exacto de qué contrato se rompió
24
+ * 5. Registra causal edges: verifies, protects, invalidated_contract
25
+ *
26
+ * INTEGRACIÓN:
27
+ * - Se hookea en tdd-gate.cjs: después de cada run exitoso
28
+ * - Se hookea en harness.cjs: paso ⑤ Preservation Gate
29
+ * - Se hookea en impact-analyzer.cjs: blast radius pre-cambio
30
+ *
31
+ * Uso:
32
+ * node contract-guard.cjs status — estado de contratos
33
+ * node contract-guard.cjs list [module] — listar contratos
34
+ * node contract-guard.cjs verify [module] — revalidar contratos
35
+ * node contract-guard.cjs blast <file> — blast radius de un archivo
36
+ * node contract-guard.cjs promote — promover candidatos
37
+ * node contract-guard.cjs snapshot — tomar snapshot actual
38
+ * node contract-guard.cjs diff <ciclo_id> — diferencia antes/después
39
+ * node contract-guard.cjs gate — correr Preservation Gate
40
+ */
41
+
42
+ 'use strict';
43
+
44
+ const path = require('path');
45
+ const fs = require('fs');
46
+ const { execSync, spawnSync } = require('child_process');
47
+
48
+ // ─── CONSTANTES ───────────────────────────────────────────────────────────────
49
+
50
+ const PROMOTION_RULES = {
51
+ CANDIDATE_TO_VERIFIED: { min_passes: 3, max_failure_rate: 0.05 },
52
+ VERIFIED_TO_PROTECTED: { min_passes: 7, max_failure_rate: 0.02 },
53
+ };
54
+
55
+ const BLAST_THRESHOLDS = {
56
+ LOW: 3, // ≤ 3 contratos afectados → safe para creative mode
57
+ MEDIUM: 10, // ≤ 10 → warning
58
+ HIGH: 20, // ≤ 20 → require extra validation
59
+ CRITICAL: Infinity, // > 20 → block creative, force manual review
60
+ };
61
+
62
+ const STATUS = {
63
+ CANDIDATE: 'candidate', // < 3 passes consecutivos
64
+ VERIFIED: 'verified', // ≥ 3 passes, failure_rate ≤ 5%
65
+ PROTECTED: 'protected', // ≥ 7 passes, failure_rate ≤ 2% — intocable
66
+ INVALIDATED: 'invalidated', // fue roto en un ciclo reciente
67
+ DEPRECATED: 'deprecated', // el test que lo verificaba fue eliminado
68
+ };
69
+
70
+ // ─── DB ───────────────────────────────────────────────────────────────────────
71
+
72
+ function openDB(projectRoot) {
73
+ const dbPath = path.join(projectRoot, '.agentic/memoria.db');
74
+ try { return new (require('better-sqlite3'))(dbPath); } catch {}
75
+ try { const { DatabaseSync } = require('node:sqlite'); return new DatabaseSync(dbPath); } catch {}
76
+ throw new Error('No SQLite driver disponible');
77
+ }
78
+
79
+ // ─── SCHEMA MIGRATION ────────────────────────────────────────────────────────
80
+
81
+ function migrateSchema(db) {
82
+ // verified_contracts: contratos de comportamiento verificado
83
+ db.exec(`
84
+ CREATE TABLE IF NOT EXISTS verified_contracts (
85
+ id TEXT PRIMARY KEY,
86
+ module TEXT NOT NULL,
87
+ name TEXT NOT NULL,
88
+ description TEXT,
89
+ test_file TEXT,
90
+ test_name TEXT,
91
+ source_files TEXT DEFAULT '[]',
92
+ inputs_signature TEXT,
93
+ outputs_signature TEXT,
94
+ verification_count INTEGER DEFAULT 0,
95
+ consecutive_passes INTEGER DEFAULT 0,
96
+ failure_count INTEGER DEFAULT 0,
97
+ last_verified TEXT,
98
+ last_failed TEXT,
99
+ created_at TEXT DEFAULT (datetime('now')),
100
+ updated_at TEXT DEFAULT (datetime('now')),
101
+ status TEXT DEFAULT 'candidate',
102
+ risk_level TEXT DEFAULT 'MEDIUM',
103
+ auto_generated INTEGER DEFAULT 1,
104
+ ciclo_created TEXT,
105
+ ciclo_last_verified TEXT,
106
+ notes TEXT
107
+ )
108
+ `);
109
+
110
+ // regression_snapshots: foto de tests antes de cada ciclo
111
+ db.exec(`
112
+ CREATE TABLE IF NOT EXISTS regression_snapshots (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114
+ ciclo_id TEXT NOT NULL,
115
+ snapshot_type TEXT NOT NULL, -- before | after
116
+ passing_tests TEXT DEFAULT '[]',
117
+ failing_tests TEXT DEFAULT '[]',
118
+ contract_ids TEXT DEFAULT '[]',
119
+ created_at TEXT DEFAULT (datetime('now'))
120
+ )
121
+ `);
122
+
123
+ // contract_violations: historial de violaciones
124
+ db.exec(`
125
+ CREATE TABLE IF NOT EXISTS contract_violations (
126
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
127
+ contract_id TEXT NOT NULL,
128
+ ciclo_id TEXT,
129
+ violation_type TEXT NOT NULL, -- regression | invalidation | modification
130
+ description TEXT,
131
+ recovered INTEGER DEFAULT 0,
132
+ recovery_ciclo TEXT,
133
+ created_at TEXT DEFAULT (datetime('now'))
134
+ )
135
+ `);
136
+
137
+ // Índices
138
+ try {
139
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_contracts_module ON verified_contracts(module)`);
140
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_contracts_status ON verified_contracts(status)`);
141
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_snapshots_ciclo ON regression_snapshots(ciclo_id)`);
142
+ } catch {}
143
+ }
144
+
145
+ // ─── GENERAR ID DE CONTRATO ───────────────────────────────────────────────────
146
+
147
+ function generateContractId(module, testName) {
148
+ const prefix = module.toUpperCase().replace(/[^A-Z0-9]/g, '').substring(0, 6);
149
+ const hash = require('crypto')
150
+ .createHash('md5')
151
+ .update(`${module}:${testName}`)
152
+ .digest('hex')
153
+ .substring(0, 4)
154
+ .toUpperCase();
155
+ return `${prefix}-${hash}`;
156
+ }
157
+
158
+ // ─── AUTO-GENERACIÓN DESDE TEST OUTPUT ────────────────────────────────────────
159
+
160
+ /**
161
+ * Parsea el output de tests y extrae contratos automáticamente.
162
+ * Soporta: Jest, Vitest, Mocha, pytest.
163
+ * @param {string} testOutput - output crudo del test runner
164
+ * @param {string} projectRoot
165
+ * @returns {Array} contratos detectados
166
+ */
167
+ function extractContractsFromTestOutput(testOutput, projectRoot, cicloId) {
168
+ const contracts = [];
169
+
170
+ // ── Jest / Vitest parser ──────────────────────────────────────────────────
171
+ const jestPassing = /✓|✔|PASS|√|\s+✓\s+(.+)/g;
172
+ const jestTest = /^\s*(?:✓|✔|√)\s+(.+?)(?:\s+\(\d+\s*m?s\))?$/gm;
173
+
174
+ let match;
175
+ while ((match = jestTest.exec(testOutput)) !== null) {
176
+ const testName = match[1].trim();
177
+ if (!testName || testName.length < 3) continue;
178
+
179
+ // Inferir módulo desde el test name
180
+ const module = inferModuleFromTest(testName, testOutput, projectRoot);
181
+
182
+ contracts.push({
183
+ module,
184
+ name: testName,
185
+ description: `Auto-generated from passing test: ${testName}`,
186
+ test_name: testName,
187
+ ciclo_created: cicloId,
188
+ status: STATUS.CANDIDATE,
189
+ });
190
+ }
191
+
192
+ // ── pytest parser ─────────────────────────────────────────────────────────
193
+ const pytestPassing = /PASSED\s+(.+?)(?:\s+-\s+(.+?))?$/gm;
194
+ while ((match = pytestPassing.exec(testOutput)) !== null) {
195
+ const testFile = match[1]?.trim();
196
+ const testName = match[2]?.trim() || testFile;
197
+ if (!testName) continue;
198
+
199
+ const module = inferModuleFromTest(testName, testOutput, projectRoot);
200
+ contracts.push({
201
+ module,
202
+ name: testName,
203
+ description: `Auto-generated from pytest: ${testName}`,
204
+ test_name: testName,
205
+ test_file: testFile,
206
+ ciclo_created: cicloId,
207
+ status: STATUS.CANDIDATE,
208
+ });
209
+ }
210
+
211
+ // ── Test suites (Jest suite names) ───────────────────────────────────────
212
+ const suitePassing = /PASS\s+(.+\.(?:test|spec)\.[jt]sx?)/g;
213
+ while ((match = suitePassing.exec(testOutput)) !== null) {
214
+ const testFile = match[1].trim();
215
+ const module = inferModuleFromFilePath(testFile);
216
+
217
+ contracts.push({
218
+ module,
219
+ name: `Suite: ${path.basename(testFile, path.extname(testFile))}`,
220
+ description: `Auto-generated from passing suite: ${testFile}`,
221
+ test_file: testFile,
222
+ test_name: path.basename(testFile),
223
+ ciclo_created: cicloId,
224
+ status: STATUS.CANDIDATE,
225
+ });
226
+ }
227
+
228
+ return contracts;
229
+ }
230
+
231
+ function inferModuleFromTest(testName, fullOutput, projectRoot) {
232
+ // Intentar inferir desde el nombre del test
233
+ const lowerName = testName.toLowerCase();
234
+
235
+ const modulePatterns = [
236
+ { pattern: /auth|login|session|jwt|token|refresh/i, module: 'auth' },
237
+ { pattern: /payment|checkout|billing|invoice|stripe/i, module: 'payments' },
238
+ { pattern: /user|profile|account|register/i, module: 'users' },
239
+ { pattern: /api|route|endpoint|controller/i, module: 'api' },
240
+ { pattern: /database|db|query|migration|model/i, module: 'database' },
241
+ { pattern: /email|notification|smtp|send/i, module: 'notifications' },
242
+ { pattern: /file|upload|storage|image/i, module: 'storage' },
243
+ { pattern: /dashboard|analytics|report|metric/i, module: 'analytics' },
244
+ { pattern: /order|cart|product|inventory/i, module: 'commerce' },
245
+ ];
246
+
247
+ for (const { pattern, module } of modulePatterns) {
248
+ if (pattern.test(testName)) return module;
249
+ }
250
+
251
+ // Extraer primera palabra como módulo
252
+ const firstWord = testName.split(/[\s>\/\\]+/)[0].toLowerCase();
253
+ return firstWord || 'global';
254
+ }
255
+
256
+ function inferModuleFromFilePath(filePath) {
257
+ const parts = filePath.replace(/\\/g, '/').split('/');
258
+ // Buscar carpeta significativa (no src, test, __tests__, spec)
259
+ const skip = new Set(['src', 'test', 'tests', '__tests__', 'spec', 'specs', '.', '..']);
260
+ for (const part of parts) {
261
+ if (!skip.has(part.toLowerCase()) && !part.includes('.')) return part;
262
+ }
263
+ return path.basename(filePath).split('.')[0] || 'global';
264
+ }
265
+
266
+ // ─── GUARDAR / ACTUALIZAR CONTRATOS ──────────────────────────────────────────
267
+
268
+ function upsertContract(db, contract, cicloId) {
269
+ const id = contract.id || generateContractId(contract.module, contract.name);
270
+
271
+ const existing = db.prepare('SELECT * FROM verified_contracts WHERE id = ?').get(id);
272
+
273
+ if (existing) {
274
+ // Actualizar contrato existente
275
+ const newPasses = (existing.consecutive_passes || 0) + 1;
276
+ const newTotal = (existing.verification_count || 0) + 1;
277
+
278
+ db.prepare(`
279
+ UPDATE verified_contracts SET
280
+ verification_count = ?,
281
+ consecutive_passes = ?,
282
+ last_verified = datetime('now'),
283
+ ciclo_last_verified = ?,
284
+ updated_at = datetime('now')
285
+ WHERE id = ?
286
+ `).run(newTotal, newPasses, cicloId, id);
287
+
288
+ // Auto-promover si cumple criterios
289
+ autoPromote(db, id, newPasses, newTotal, existing.failure_count || 0);
290
+ } else {
291
+ // Crear nuevo contrato
292
+ db.prepare(`
293
+ INSERT OR IGNORE INTO verified_contracts
294
+ (id, module, name, description, test_file, test_name, verification_count,
295
+ consecutive_passes, status, ciclo_created, ciclo_last_verified, auto_generated)
296
+ VALUES (?, ?, ?, ?, ?, ?, 1, 1, 'candidate', ?, ?, 1)
297
+ `).run(
298
+ id, contract.module, contract.name,
299
+ contract.description || contract.name,
300
+ contract.test_file || null,
301
+ contract.test_name || contract.name,
302
+ cicloId, cicloId
303
+ );
304
+ }
305
+
306
+ return id;
307
+ }
308
+
309
+ // ─── AUTO-PROMOCIÓN ───────────────────────────────────────────────────────────
310
+
311
+ function autoPromote(db, contractId, consecutivePasses, totalPasses, failureCount) {
312
+ const failureRate = totalPasses > 0 ? failureCount / totalPasses : 0;
313
+ const contract = db.prepare('SELECT status FROM verified_contracts WHERE id = ?').get(contractId);
314
+ if (!contract) return;
315
+
316
+ let newStatus = contract.status;
317
+
318
+ if (contract.status === STATUS.CANDIDATE) {
319
+ const rule = PROMOTION_RULES.CANDIDATE_TO_VERIFIED;
320
+ if (consecutivePasses >= rule.min_passes && failureRate <= rule.max_failure_rate) {
321
+ newStatus = STATUS.VERIFIED;
322
+ }
323
+ }
324
+
325
+ if (contract.status === STATUS.VERIFIED || newStatus === STATUS.VERIFIED) {
326
+ const rule = PROMOTION_RULES.VERIFIED_TO_PROTECTED;
327
+ if (consecutivePasses >= rule.min_passes && failureRate <= rule.max_failure_rate) {
328
+ newStatus = STATUS.PROTECTED;
329
+ }
330
+ }
331
+
332
+ if (newStatus !== contract.status) {
333
+ db.prepare(`
334
+ UPDATE verified_contracts SET status = ?, updated_at = datetime('now')
335
+ WHERE id = ?
336
+ `).run(newStatus, contractId);
337
+ console.log(`[CONTRACT] Promoted ${contractId}: ${contract.status} → ${newStatus}`);
338
+ }
339
+ }
340
+
341
+ // ─── REGISTRAR FALLO DE CONTRATO ─────────────────────────────────────────────
342
+
343
+ function recordContractFailure(db, contractId, cicloId, description) {
344
+ // Actualizar contrato
345
+ db.prepare(`
346
+ UPDATE verified_contracts SET
347
+ failure_count = failure_count + 1,
348
+ consecutive_passes = 0,
349
+ last_failed = datetime('now'),
350
+ status = CASE WHEN status = 'protected' THEN 'invalidated' ELSE status END,
351
+ updated_at = datetime('now')
352
+ WHERE id = ?
353
+ `).run(contractId);
354
+
355
+ // Registrar violación
356
+ db.prepare(`
357
+ INSERT INTO contract_violations (contract_id, ciclo_id, violation_type, description)
358
+ VALUES (?, ?, 'regression', ?)
359
+ `).run(contractId, cicloId, description || 'Contract failed during cycle');
360
+
361
+ // Registrar causal edge
362
+ try {
363
+ db.prepare(`
364
+ INSERT OR IGNORE INTO relaciones_semanticas
365
+ (desde_entidad, tipo, hacia_entidad, descripcion, confidence, valid_at)
366
+ VALUES (?, 'invalidated_contract', ?, ?, 'HIGH', datetime('now'))
367
+ `).run(cicloId || 'unknown_cycle', contractId, description || 'regression detected');
368
+ } catch {}
369
+ }
370
+
371
+ // ─── SNAPSHOT ANTES/DESPUÉS ───────────────────────────────────────────────────
372
+
373
+ /**
374
+ * Toma un snapshot del estado de tests antes de ejecutar un ciclo.
375
+ * Se llama desde el harness antes de la fase de build.
376
+ */
377
+ function takeSnapshot(db, projectRoot, cicloId, snapshotType) {
378
+ const testOutput = runTests(projectRoot);
379
+ const passing = extractPassingTests(testOutput);
380
+ const failing = extractFailingTests(testOutput);
381
+
382
+ // Mapear tests a contratos
383
+ const contractIds = [];
384
+ passing.forEach(test => {
385
+ const module = inferModuleFromTest(test, testOutput, projectRoot);
386
+ const id = generateContractId(module, test);
387
+ contractIds.push(id);
388
+ });
389
+
390
+ db.prepare(`
391
+ INSERT INTO regression_snapshots
392
+ (ciclo_id, snapshot_type, passing_tests, failing_tests, contract_ids)
393
+ VALUES (?, ?, ?, ?, ?)
394
+ `).run(
395
+ cicloId, snapshotType,
396
+ JSON.stringify(passing),
397
+ JSON.stringify(failing),
398
+ JSON.stringify(contractIds)
399
+ );
400
+
401
+ return { passing, failing, contractIds, total: passing.length + failing.length };
402
+ }
403
+
404
+ // ─── PRESERVATION GATE ───────────────────────────────────────────────────────
405
+
406
+ /**
407
+ * El paso ⑤ del pipeline. Verifica que los contratos PROTECTED y VERIFIED
408
+ * sigan pasando después de un ciclo.
409
+ *
410
+ * Solo corre los tests relacionados con archivos modificados (no todos).
411
+ * Usa el AST graph para identificar qué contratos están en riesgo.
412
+ *
413
+ * @returns { passed: bool, violations: [], blast_radius: int }
414
+ */
415
+ function runPreservationGate(db, projectRoot, cicloId, modifiedFiles = []) {
416
+ const result = {
417
+ passed: true,
418
+ violations: [],
419
+ blast_radius: 0,
420
+ contracts_checked: 0,
421
+ contracts_protected: 0,
422
+ contracts_verified: 0,
423
+ skipped_reason: null,
424
+ };
425
+
426
+ // Obtener contratos protegidos y verificados
427
+ let contracts = [];
428
+ try {
429
+ contracts = db.prepare(`
430
+ SELECT * FROM verified_contracts
431
+ WHERE status IN ('protected', 'verified')
432
+ ORDER BY status DESC, verification_count DESC
433
+ `).all();
434
+ } catch { return result; }
435
+
436
+ if (contracts.length === 0) {
437
+ result.skipped_reason = 'No verified contracts yet — run more cycles to build contract base';
438
+ return result;
439
+ }
440
+
441
+ result.contracts_protected = contracts.filter(c => c.status === STATUS.PROTECTED).length;
442
+ result.contracts_verified = contracts.filter(c => c.status === STATUS.VERIFIED).length;
443
+
444
+ // Si hay archivos modificados, filtrar contratos relacionados
445
+ let contractsToCheck = contracts;
446
+ if (modifiedFiles.length > 0) {
447
+ // Calcular blast radius
448
+ const blastContracts = getContractsInBlastRadius(db, modifiedFiles, contracts);
449
+ result.blast_radius = blastContracts.length;
450
+
451
+ // Solo verificar contratos en el blast radius
452
+ if (blastContracts.length > 0) {
453
+ contractsToCheck = blastContracts;
454
+ } else {
455
+ // No hay contratos en riesgo → gate pasa automáticamente
456
+ result.passed = true;
457
+ result.skipped_reason = `No contracts in blast radius of modified files (${modifiedFiles.length} files)`;
458
+ return result;
459
+ }
460
+ }
461
+
462
+ result.contracts_checked = contractsToCheck.length;
463
+
464
+ // Correr solo los test files relacionados
465
+ const testFilesToRun = [...new Set(
466
+ contractsToCheck
467
+ .map(c => c.test_file)
468
+ .filter(Boolean)
469
+ )];
470
+
471
+ let testOutput = '';
472
+ if (testFilesToRun.length > 0) {
473
+ testOutput = runSpecificTests(projectRoot, testFilesToRun);
474
+ } else {
475
+ // Sin test files mapeados → correr suite completa
476
+ testOutput = runTests(projectRoot);
477
+ }
478
+
479
+ const passingTests = new Set(extractPassingTests(testOutput));
480
+ const failingTests = new Set(extractFailingTests(testOutput));
481
+
482
+ // Verificar cada contrato
483
+ for (const contract of contractsToCheck) {
484
+ const testName = contract.test_name || contract.name;
485
+ const isFailing = failingTests.has(testName) ||
486
+ [...failingTests].some(t => t.includes(testName) || testName.includes(t));
487
+
488
+ if (isFailing) {
489
+ result.passed = false;
490
+ result.violations.push({
491
+ contract_id: contract.id,
492
+ contract_name: contract.name,
493
+ module: contract.module,
494
+ status: contract.status,
495
+ test: testName,
496
+ severity: contract.status === STATUS.PROTECTED ? 'CRITICAL' : 'HIGH',
497
+ message: `${contract.status.toUpperCase()} contract broken: ${contract.name} (${contract.module})`,
498
+ });
499
+
500
+ // Registrar la violación en DB
501
+ recordContractFailure(db, contract.id, cicloId,
502
+ `Preservation Gate violation in cycle ${cicloId}`);
503
+ } else if (passingTests.has(testName) ||
504
+ [...passingTests].some(t => t.includes(testName))) {
505
+ // Contrato pasó → actualizar
506
+ upsertContract(db, { id: contract.id, module: contract.module, name: contract.name }, cicloId);
507
+ }
508
+ }
509
+
510
+ return result;
511
+ }
512
+
513
+ // ─── BLAST RADIUS ────────────────────────────────────────────────────────────
514
+
515
+ /**
516
+ * Calcula cuántos contratos verificados están en riesgo dado un set de archivos.
517
+ * Usa el AST graph para propagar dependencias.
518
+ */
519
+ function getContractsInBlastRadius(db, modifiedFiles, allContracts) {
520
+ const atRisk = [];
521
+
522
+ // Obtener todos los archivos que dependen de los modificados (via AST)
523
+ const affectedFiles = new Set(modifiedFiles);
524
+ try {
525
+ for (const file of modifiedFiles) {
526
+ const dependents = db.prepare(`
527
+ SELECT DISTINCT desde_entidad FROM relaciones_semanticas
528
+ WHERE (hacia_entidad LIKE ? OR hacia_entidad = ?)
529
+ AND tipo IN ('depende_de', 'importa', 'usa', 'llama')
530
+ AND (invalid_at IS NULL OR invalid_at = '')
531
+ `).all(`%${path.basename(file)}%`, file);
532
+
533
+ dependents.forEach(d => affectedFiles.add(d.desde_entidad));
534
+ }
535
+ } catch {}
536
+
537
+ // Mapear a contratos
538
+ for (const contract of allContracts) {
539
+ const sourceFiles = (() => {
540
+ try { return JSON.parse(contract.source_files || '[]'); } catch { return []; }
541
+ })();
542
+
543
+ const testFile = contract.test_file || '';
544
+
545
+ // Contrato está en riesgo si:
546
+ // 1. Su test file fue modificado
547
+ // 2. Alguno de sus source files fue modificado
548
+ // 3. Algún archivo del blast radius toca su módulo
549
+ const isAtRisk =
550
+ modifiedFiles.some(f => testFile.includes(path.basename(f)) || f.includes(testFile)) ||
551
+ sourceFiles.some(sf => affectedFiles.has(sf) || modifiedFiles.some(m => sf.includes(path.basename(m)))) ||
552
+ [...affectedFiles].some(af => af.toLowerCase().includes(contract.module.toLowerCase()));
553
+
554
+ if (isAtRisk) atRisk.push(contract);
555
+ }
556
+
557
+ return atRisk;
558
+ }
559
+
560
+ /**
561
+ * Reporte de blast radius para un archivo.
562
+ */
563
+ function getBlastRadiusReport(db, projectRoot, targetFile) {
564
+ let contracts = [];
565
+ try {
566
+ contracts = db.prepare(`
567
+ SELECT * FROM verified_contracts WHERE status IN ('protected', 'verified')
568
+ `).all();
569
+ } catch { return { file: targetFile, contracts_at_risk: 0, severity: 'LOW', contracts: [] }; }
570
+
571
+ const atRisk = getContractsInBlastRadius(db, [targetFile], contracts);
572
+
573
+ const severity = atRisk.length <= BLAST_THRESHOLDS.LOW ? 'LOW'
574
+ : atRisk.length <= BLAST_THRESHOLDS.MEDIUM ? 'MEDIUM'
575
+ : atRisk.length <= BLAST_THRESHOLDS.HIGH ? 'HIGH'
576
+ : 'CRITICAL';
577
+
578
+ return {
579
+ file: targetFile,
580
+ contracts_at_risk: atRisk.length,
581
+ severity,
582
+ protected_contracts: atRisk.filter(c => c.status === STATUS.PROTECTED).length,
583
+ verified_contracts: atRisk.filter(c => c.status === STATUS.VERIFIED).length,
584
+ contracts: atRisk.map(c => ({
585
+ id: c.id,
586
+ name: c.name,
587
+ module: c.module,
588
+ status: c.status,
589
+ })),
590
+ recommendation: severity === 'LOW'
591
+ ? 'Safe to modify — minimal contract risk'
592
+ : severity === 'MEDIUM'
593
+ ? 'Proceed with caution — run preservation gate after changes'
594
+ : severity === 'HIGH'
595
+ ? 'High risk — verify all contracts before accepting changes'
596
+ : 'CRITICAL — multiple protected contracts at risk — manual review required',
597
+ };
598
+ }
599
+
600
+ // ─── INGERIR DESDE CICLO COMPLETADO ──────────────────────────────────────────
601
+
602
+ /**
603
+ * Punto de entrada principal. Llamar después de cada ciclo exitoso.
604
+ * Extrae contratos del output de tests y los almacena/actualiza.
605
+ */
606
+ function ingestFromCycle(db, projectRoot, cicloId, testOutput) {
607
+ if (!testOutput) return { contracts_created: 0, contracts_updated: 0 };
608
+
609
+ const contracts = extractContractsFromTestOutput(testOutput, projectRoot, cicloId);
610
+ let created = 0, updated = 0;
611
+
612
+ for (const contract of contracts) {
613
+ const id = generateContractId(contract.module, contract.name);
614
+ const existing = db.prepare('SELECT id FROM verified_contracts WHERE id = ?').get(id);
615
+
616
+ upsertContract(db, contract, cicloId);
617
+ if (existing) updated++; else created++;
618
+ }
619
+
620
+ // Agregar causal edge del ciclo a los contratos
621
+ try {
622
+ const contractIds = contracts
623
+ .map(c => generateContractId(c.module, c.name))
624
+ .slice(0, 10); // Máx 10 edges por ciclo
625
+
626
+ contractIds.forEach(cid => {
627
+ try {
628
+ db.prepare(`
629
+ INSERT OR IGNORE INTO relaciones_semanticas
630
+ (desde_entidad, tipo, hacia_entidad, descripcion, valid_at)
631
+ VALUES (?, 'verifies', ?, 'cycle verified this contract', datetime('now'))
632
+ `).run(cicloId, cid);
633
+ } catch {}
634
+ });
635
+ } catch {}
636
+
637
+ return { contracts_created: created, contracts_updated: updated };
638
+ }
639
+
640
+ // ─── TEST RUNNERS ─────────────────────────────────────────────────────────────
641
+
642
+ function runTests(projectRoot) {
643
+ const commands = ['npm test -- --passWithNoTests', 'npx jest --passWithNoTests', 'npx vitest run', 'npm run test'];
644
+ for (const cmd of commands) {
645
+ try {
646
+ const out = execSync(cmd, {
647
+ cwd: projectRoot, stdio: 'pipe', timeout: 120000
648
+ }).toString();
649
+ return out;
650
+ } catch (e) {
651
+ const out = (e.stdout?.toString() || '') + (e.stderr?.toString() || '');
652
+ if (out.length > 100) return out; // test output even if exit code != 0
653
+ }
654
+ }
655
+ return '';
656
+ }
657
+
658
+ function runSpecificTests(projectRoot, testFiles) {
659
+ if (!testFiles || testFiles.length === 0) return runTests(projectRoot);
660
+
661
+ const fileList = testFiles.map(f => `"${f}"`).join(' ');
662
+ try {
663
+ const out = execSync(`npx jest ${fileList} --passWithNoTests`, {
664
+ cwd: projectRoot, stdio: 'pipe', timeout: 120000
665
+ }).toString();
666
+ return out;
667
+ } catch (e) {
668
+ return (e.stdout?.toString() || '') + (e.stderr?.toString() || '');
669
+ }
670
+ }
671
+
672
+ function extractPassingTests(output) {
673
+ const passing = [];
674
+ const patterns = [
675
+ /^\s*(?:✓|✔|√|PASS)\s+(.+?)(?:\s+\d+\s*m?s)?$/gm,
676
+ /PASSED\s+(.+?)(?:\s+\[)/gm,
677
+ ];
678
+ patterns.forEach(pattern => {
679
+ let m;
680
+ while ((m = pattern.exec(output)) !== null) {
681
+ const name = m[1]?.trim();
682
+ if (name && name.length > 2) passing.push(name);
683
+ }
684
+ });
685
+ return [...new Set(passing)];
686
+ }
687
+
688
+ function extractFailingTests(output) {
689
+ const failing = [];
690
+ const patterns = [
691
+ /^\s*(?:✕|✗|×|FAIL|●)\s+(.+?)(?:\s+\d+\s*m?s)?$/gm,
692
+ /FAILED\s+(.+?)(?:\s+\[)/gm,
693
+ ];
694
+ patterns.forEach(pattern => {
695
+ let m;
696
+ while ((m = pattern.exec(output)) !== null) {
697
+ const name = m[1]?.trim();
698
+ if (name && name.length > 2) failing.push(name);
699
+ }
700
+ });
701
+ return [...new Set(failing)];
702
+ }
703
+
704
+ // ─── STATUS Y REPORTES ───────────────────────────────────────────────────────
705
+
706
+ function getStatus(db) {
707
+ try {
708
+ const total = db.prepare("SELECT COUNT(*) as n FROM verified_contracts").get()?.n || 0;
709
+ const protected_= db.prepare("SELECT COUNT(*) as n FROM verified_contracts WHERE status='protected'").get()?.n || 0;
710
+ const verified = db.prepare("SELECT COUNT(*) as n FROM verified_contracts WHERE status='verified'").get()?.n || 0;
711
+ const candidate = db.prepare("SELECT COUNT(*) as n FROM verified_contracts WHERE status='candidate'").get()?.n || 0;
712
+ const invalidated=db.prepare("SELECT COUNT(*) as n FROM verified_contracts WHERE status='invalidated'").get()?.n || 0;
713
+ const violations= db.prepare("SELECT COUNT(*) as n FROM contract_violations WHERE recovered=0").get()?.n || 0;
714
+
715
+ return { total, protected: protected_, verified, candidate, invalidated, open_violations: violations,
716
+ coverage_level: total === 0 ? 'NONE' : protected_ >= 5 ? 'STRONG' : protected_ >= 2 ? 'MODERATE' : 'WEAK' };
717
+ } catch { return { total: 0, error: 'Schema not migrated — run: akdd update' }; }
718
+ }
719
+
720
+ function listContracts(db, module) {
721
+ try {
722
+ const query = module
723
+ ? `SELECT * FROM verified_contracts WHERE module = ? ORDER BY status DESC, verification_count DESC`
724
+ : `SELECT * FROM verified_contracts ORDER BY status DESC, verification_count DESC LIMIT 50`;
725
+ return module ? db.prepare(query).all(module) : db.prepare(query).all();
726
+ } catch { return []; }
727
+ }
728
+
729
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
730
+
731
+ if (require.main === module) {
732
+ const [,, cmd, ...args] = process.argv;
733
+ const projectRoot = process.cwd();
734
+
735
+ let db;
736
+ try {
737
+ db = openDB(projectRoot);
738
+ migrateSchema(db);
739
+ } catch (e) {
740
+ console.error('[CONTRACT] DB error:', e.message);
741
+ process.exit(1);
742
+ }
743
+
744
+ switch (cmd) {
745
+ case 'status': {
746
+ const s = getStatus(db);
747
+ console.log('\n══════════════════════════════════════════════');
748
+ console.log(' Contract Guard — Status');
749
+ console.log('══════════════════════════════════════════════');
750
+ console.log(` PROTECTED: ${s.protected} (intocables — ${PROMOTION_RULES.VERIFIED_TO_PROTECTED.min_passes}+ passes)`);
751
+ console.log(` VERIFIED: ${s.verified} (verificados — ${PROMOTION_RULES.CANDIDATE_TO_VERIFIED.min_passes}+ passes)`);
752
+ console.log(` CANDIDATE: ${s.candidate} (< ${PROMOTION_RULES.CANDIDATE_TO_VERIFIED.min_passes} passes)`);
753
+ console.log(` INVALIDATED: ${s.invalidated} (rotos en ciclo reciente)`);
754
+ console.log(` Violations: ${s.open_violations} abiertas`);
755
+ console.log(` Coverage: ${s.coverage_level}`);
756
+ console.log(` Total: ${s.total}`);
757
+ console.log('══════════════════════════════════════════════\n');
758
+ break;
759
+ }
760
+
761
+ case 'list': {
762
+ const contracts = listContracts(db, args[0]);
763
+ const statusIcon = { protected: '🛡️', verified: '✅', candidate: '🔄', invalidated: '❌' };
764
+ console.log(`\nContracts${args[0] ? ` [${args[0]}]` : ''} (${contracts.length}):\n`);
765
+ contracts.forEach(c => {
766
+ const icon = statusIcon[c.status] || '?';
767
+ console.log(` ${icon} [${c.id}] ${c.name}`);
768
+ console.log(` Module: ${c.module} | Passes: ${c.verification_count} | Fails: ${c.failure_count}`);
769
+ });
770
+ break;
771
+ }
772
+
773
+ case 'blast': {
774
+ const file = args[0];
775
+ if (!file) { console.error('Uso: contract-guard.cjs blast <archivo>'); break; }
776
+ const report = getBlastRadiusReport(db, projectRoot, file);
777
+ console.log(`\nBlast Radius: ${file}`);
778
+ console.log(` Contratos en riesgo: ${report.contracts_at_risk}`);
779
+ console.log(` Severidad: ${report.severity}`);
780
+ console.log(` Protected: ${report.protected_contracts} | Verified: ${report.verified_contracts}`);
781
+ console.log(` → ${report.recommendation}\n`);
782
+ break;
783
+ }
784
+
785
+ case 'gate': {
786
+ const modifiedFiles = args;
787
+ console.log('\n[CONTRACT] Corriendo Preservation Gate...');
788
+ const result = runPreservationGate(db, projectRoot, `manual-${Date.now()}`, modifiedFiles);
789
+ if (result.passed) {
790
+ console.log(`\n ✅ Preservation Gate PASSED`);
791
+ console.log(` ${result.contracts_checked} contratos verificados`);
792
+ if (result.skipped_reason) console.log(` (${result.skipped_reason})`);
793
+ } else {
794
+ console.log(`\n ❌ Preservation Gate FAILED — ${result.violations.length} violation(s)\n`);
795
+ result.violations.forEach(v => {
796
+ console.log(` [${v.severity}] ${v.contract_id}: ${v.message}`);
797
+ });
798
+ }
799
+ process.exit(result.passed ? 0 : 1);
800
+ }
801
+
802
+ case 'promote': {
803
+ const candidates = db.prepare(`
804
+ SELECT * FROM verified_contracts WHERE status IN ('candidate','verified')
805
+ `).all();
806
+ let promoted = 0;
807
+ candidates.forEach(c => {
808
+ autoPromote(db, c.id, c.consecutive_passes, c.verification_count, c.failure_count);
809
+ promoted++;
810
+ });
811
+ console.log(`Reviewed ${promoted} contracts for promotion.`);
812
+ break;
813
+ }
814
+
815
+ case 'verify': {
816
+ const module = args[0];
817
+ console.log(`\n[CONTRACT] Running preservation gate${module ? ` for ${module}` : ''}...`);
818
+ const result = runPreservationGate(db, projectRoot, `verify-${Date.now()}`, []);
819
+ console.log(result.passed
820
+ ? `\n✅ All ${result.contracts_checked} contracts passing\n`
821
+ : `\n❌ ${result.violations.length} contracts broken:\n${result.violations.map(v => ` - ${v.contract_name}`).join('\n')}\n`
822
+ );
823
+ break;
824
+ }
825
+
826
+ case 'migrate': {
827
+ migrateSchema(db);
828
+ console.log('✅ Schema migrated');
829
+ break;
830
+ }
831
+
832
+ default:
833
+ console.log('Uso: node contract-guard.cjs [status | list [module] | blast <file> | gate [files...] | verify | promote | migrate]');
834
+ }
835
+ }
836
+
837
+ module.exports = {
838
+ migrateSchema,
839
+ ingestFromCycle,
840
+ runPreservationGate,
841
+ getBlastRadiusReport,
842
+ getContractsInBlastRadius,
843
+ getStatus,
844
+ listContracts,
845
+ takeSnapshot,
846
+ upsertContract,
847
+ recordContractFailure,
848
+ extractContractsFromTestOutput,
849
+ generateContractId,
850
+ STATUS,
851
+ };