import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import test from 'node:test'; import { withTempDir } from '../__tests__/helpers/tempDir'; import { getCurrentPumukiVersion } from '../lifecycle/packageInfo'; import type { AiEvidenceV2_1 } from './schema'; import { writeEvidence } from './writeEvidence'; const withCwd = async (cwd: string, callback: () => Promise | T): Promise => { const previous = process.cwd(); process.chdir(cwd); try { return await callback(); } finally { process.chdir(previous); } }; const initGitRepo = (cwd: string): void => { execFileSync('git', ['init', '-q'], { cwd, stdio: 'ignore', }); }; const sampleEvidence = (repoRoot: string): AiEvidenceV2_1 => ({ version: '2.1', timestamp: '2026-02-17T00:00:00.000Z', snapshot: { stage: 'PRE_PUSH', audit_mode: 'engine', outcome: 'BLOCK', evaluation_metrics: { facts_total: 1878, rules_total: 25.9, baseline_rules: 0, heuristic_rules: 0, skills_rules: 25, project_rules: 0, matched_rules: 0, unmatched_rules: 25, evaluated_rule_ids: ['skills.backend.no-empty-catch', 'skills.backend.no-console-log'], matched_rule_ids: [], unmatched_rule_ids: ['skills.backend.no-console-log', 'skills.backend.no-empty-catch'], }, rules_coverage: { stage: 'PRE_PUSH', active_rule_ids: ['skills.backend.no-empty-catch', 'skills.backend.no-console-log'], evaluated_rule_ids: ['skills.backend.no-empty-catch'], matched_rule_ids: ['skills.backend.no-empty-catch'], unevaluated_rule_ids: ['skills.backend.no-console-log'], counts: { active: 2, evaluated: 1, matched: 1, unevaluated: 1, }, coverage_ratio: 0.5, }, findings: [ { ruleId: 'z.rule', severity: 'WARN', code: 'Z_RULE', message: 'z finding', file: join(repoRoot, 'apps/ios/B.swift'), lines: [9.8, 1, 1, Number.NaN, 4], matchedBy: 'FileContent', source: 'git:staged', }, { ruleId: 'a.rule', severity: 'ERROR', code: 'A_RULE', message: 'a finding', file: 'apps/backend/A.ts', lines: ' ', matchedBy: 'Heuristic', source: 'heuristics:ast', }, ], }, ledger: [ { ruleId: 'z.rule', file: join(repoRoot, 'apps/ios/B.swift'), lines: 8.9, firstSeen: '2026-02-16T00:00:00.000Z', lastSeen: '2026-02-17T00:00:00.000Z', }, { ruleId: 'a.rule', file: 'apps/backend/A.ts', lines: [3, 2, 2], firstSeen: '2026-02-16T00:00:00.000Z', lastSeen: '2026-02-17T00:00:00.000Z', }, ], platforms: { ios: { detected: true, confidence: 'HIGH' }, backend: { detected: true, confidence: 'MEDIUM' }, }, rulesets: [ { platform: 'ios', bundle: 'z-bundle', hash: 'hash-z' }, { platform: 'backend', bundle: 'a-bundle', hash: 'hash-a' }, ], human_intent: null, ai_gate: { status: 'BLOCKED', violations: [], human_intent: null, }, severity_metrics: { gate_status: 'BLOCKED', total_violations: 2, by_severity: { CRITICAL: 0, ERROR: 1, WARN: 1, INFO: 0, }, }, repo_state: { repo_root: repoRoot, git: { available: true, branch: 'feature/write-evidence', upstream: 'origin/feature/write-evidence', ahead: 0, behind: 0, dirty: false, staged: 0, unstaged: 0, }, lifecycle: { installed: true, package_version: getCurrentPumukiVersion(), lifecycle_version: getCurrentPumukiVersion(), hooks: { pre_commit: 'managed', pre_push: 'managed', }, }, }, sdd_metrics: { enforced: true, stage: 'PRE_PUSH', decision: { allowed: true, code: 'ALLOWED', message: 'sdd policy passed', }, }, }); test('writeEvidence escribe archivo estable y normaliza paths/orden/lineas', async () => { await withTempDir('pumuki-write-evidence-', async (tempRoot) => { initGitRepo(tempRoot); await withCwd(tempRoot, async () => { const result = writeEvidence(sampleEvidence(tempRoot)); assert.equal(result.ok, true); assert.equal(result.path.endsWith('/.ai_evidence.json'), true); assert.equal(existsSync(result.path), true); const written = JSON.parse(readFileSync(result.path, 'utf8')) as AiEvidenceV2_1; assert.equal(written.snapshot.findings.length, 2); assert.equal(written.snapshot.audit_mode, 'engine'); assert.equal(written.snapshot.files_affected, 2); assert.deepEqual(written.snapshot.evaluation_metrics, { facts_total: 1878, rules_total: 25, baseline_rules: 0, heuristic_rules: 0, skills_rules: 25, project_rules: 0, matched_rules: 0, unmatched_rules: 25, evaluated_rule_ids: ['skills.backend.no-console-log', 'skills.backend.no-empty-catch'], matched_rule_ids: [], unmatched_rule_ids: ['skills.backend.no-console-log', 'skills.backend.no-empty-catch'], }); assert.deepEqual(written.snapshot.rules_coverage, { stage: 'PRE_PUSH', active_rule_ids: ['skills.backend.no-console-log', 'skills.backend.no-empty-catch'], evaluated_rule_ids: ['skills.backend.no-empty-catch'], matched_rule_ids: ['skills.backend.no-empty-catch'], unevaluated_rule_ids: ['skills.backend.no-console-log'], counts: { active: 2, evaluated: 1, matched: 1, unevaluated: 1, }, coverage_ratio: 0.5, }); assert.equal(written.snapshot.findings[0]?.ruleId, 'a.rule'); assert.equal(written.snapshot.findings[0]?.file, 'apps/backend/A.ts'); assert.equal('lines' in (written.snapshot.findings[0] ?? {}), false); assert.equal(written.snapshot.findings[0]?.matchedBy, 'Heuristic'); assert.equal(written.snapshot.findings[0]?.source, 'heuristics:ast'); assert.equal(written.snapshot.findings[1]?.ruleId, 'z.rule'); assert.equal(written.snapshot.findings[1]?.file.endsWith('/apps/ios/B.swift'), true); assert.deepEqual(written.snapshot.findings[1]?.lines, [1, 4, 9]); assert.equal(written.snapshot.findings[1]?.matchedBy, 'FileContent'); assert.equal(written.snapshot.findings[1]?.source, 'git:staged'); assert.equal(written.ledger[0]?.ruleId, 'a.rule'); assert.deepEqual(written.ledger[0]?.lines, [2, 3]); assert.equal(written.ledger[1]?.ruleId, 'z.rule'); assert.equal(written.ledger[1]?.lines, 8); assert.deepEqual(Object.keys(written.platforms), ['backend', 'ios']); assert.deepEqual(written.rulesets.map((item) => `${item.platform}:${item.bundle}`), [ 'backend:a-bundle', 'ios:z-bundle', ]); assert.deepEqual(written.severity_metrics.by_enterprise_severity, { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, }); assert.deepEqual(written.ai_gate.violations.map((item) => item.ruleId), ['a.rule', 'z.rule']); assert.deepEqual( written.ai_gate.violations.map((item) => [item.ruleId, item.matchedBy, item.source]), [ ['a.rule', 'Heuristic', 'heuristics:ast'], ['z.rule', 'FileContent', 'git:staged'], ] ); assert.deepEqual(written.sdd_metrics, { enforced: true, stage: 'PRE_PUSH', decision: { allowed: true, code: 'ALLOWED', message: 'sdd policy passed', }, }); assert.equal(written.repo_state?.git.branch, 'feature/write-evidence'); assert.equal(written.repo_state?.lifecycle.hooks.pre_commit, 'managed'); }); }); }); test('writeEvidence aplica snapshot.audit_mode=gate por defecto cuando no viene en entrada', async () => { await withTempDir('pumuki-write-evidence-audit-mode-default-', async (tempRoot) => { initGitRepo(tempRoot); await withCwd(tempRoot, async () => { const evidence = sampleEvidence(tempRoot); delete evidence.snapshot.audit_mode; const result = writeEvidence(evidence); assert.equal(result.ok, true); const written = JSON.parse(readFileSync(result.path, 'utf8')) as AiEvidenceV2_1; assert.equal(written.snapshot.audit_mode, 'gate'); }); }); }); test('writeEvidence devuelve ok=false cuando no puede escribir el archivo', async () => { await withTempDir('pumuki-write-evidence-error-', async (tempRoot) => { initGitRepo(tempRoot); await withCwd(tempRoot, async () => { mkdirSync('.ai_evidence.json'); const originalWarn = console.warn; console.warn = () => {}; try { const result = writeEvidence(sampleEvidence(tempRoot)); assert.equal(result.ok, false); assert.equal(result.path.endsWith('/.ai_evidence.json'), true); assert.equal(typeof result.error, 'string'); } finally { console.warn = originalWarn; } }); }); }); test('writeEvidence conserva paths externos y elimina lines no finitas', async () => { await withTempDir('pumuki-write-evidence-external-', async (tempRoot) => { initGitRepo(tempRoot); await withCwd(tempRoot, async () => { const externalFilePath = '/tmp/pumuki-external.ts'; const evidence = sampleEvidence(tempRoot); evidence.snapshot.findings = [ { ...evidence.snapshot.findings[0]!, file: externalFilePath, lines: Number.POSITIVE_INFINITY, }, ]; evidence.ledger = [ { ...evidence.ledger[0]!, file: externalFilePath, lines: [Number.NaN, Number.POSITIVE_INFINITY], }, ]; evidence.severity_metrics.total_violations = 1; const result = writeEvidence(evidence); assert.equal(result.ok, true); const written = JSON.parse(readFileSync(result.path, 'utf8')) as AiEvidenceV2_1; assert.equal(written.snapshot.files_affected, 1); assert.equal(written.snapshot.findings[0]?.file, externalFilePath); assert.equal('lines' in (written.snapshot.findings[0] ?? {}), false); assert.equal(written.ledger[0]?.file, externalFilePath); assert.equal('lines' in (written.ledger[0] ?? {}), false); }); }); }); test('writeEvidence preserva snapshot.tdd_bdd cuando viene en evidencia', async () => { await withTempDir('pumuki-write-evidence-tdd-bdd-', async (tempRoot) => { initGitRepo(tempRoot); await withCwd(tempRoot, async () => { const evidence = sampleEvidence(tempRoot); evidence.snapshot.tdd_bdd = { status: 'waived', scope: { in_scope: true, is_new_feature: true, is_complex_change: false, reasons: ['new_feature'], metrics: { changed_files: 1, estimated_loc: 20, critical_path_files: 0, public_interface_files: 1, }, }, evidence: { path: '.pumuki/artifacts/pumuki-evidence-v1.json', state: 'not_required', slices_total: 0, slices_valid: 0, slices_invalid: 0, integrity_ok: true, errors: [], }, waiver: { applied: true, approver: 'tech-lead', reason: 'incident', expires_at: '2026-02-27T10:00:00.000Z', }, }; const result = writeEvidence(evidence); assert.equal(result.ok, true); const written = JSON.parse(readFileSync(result.path, 'utf8')) as AiEvidenceV2_1; assert.equal(written.snapshot.tdd_bdd?.status, 'waived'); assert.equal(written.snapshot.tdd_bdd?.waiver.approver, 'tech-lead'); assert.deepEqual(written.snapshot.tdd_bdd?.scope.reasons, ['new_feature']); }); }); }); test('writeEvidence preserva snapshot.memory_shadow con orden estable', async () => { await withTempDir('pumuki-write-evidence-memory-shadow-', async (tempRoot) => { initGitRepo(tempRoot); await withCwd(tempRoot, async () => { const evidence = sampleEvidence(tempRoot); evidence.snapshot.memory_shadow = { recommended_outcome: 'BLOCK', actual_outcome: 'ALLOW', confidence: 0.987654321, reason_codes: [' tdd_bdd.blocked ', 'shadow.diff', 'shadow.diff'], }; const result = writeEvidence(evidence); assert.equal(result.ok, true); const written = JSON.parse(readFileSync(result.path, 'utf8')) as AiEvidenceV2_1; assert.deepEqual(written.snapshot.memory_shadow, { recommended_outcome: 'BLOCK', actual_outcome: 'ALLOW', confidence: 0.987654, reason_codes: ['shadow.diff', 'tdd_bdd.blocked'], }); }); }); }); test('writeEvidence no deja temporales de evidencia tras escribir atómicamente', async () => { await withTempDir('pumuki-write-evidence-atomic-cleanup-', async (tempRoot) => { initGitRepo(tempRoot); await withCwd(tempRoot, async () => { const result = writeEvidence(sampleEvidence(tempRoot)); assert.equal(result.ok, true); const tempArtifacts = readdirSync(tempRoot).filter((entry) => entry.startsWith('.ai_evidence.json.tmp-') ); assert.deepEqual(tempArtifacts, []); }); }); }); test('writeEvidence mantiene JSON válido bajo ráfaga de escrituras consecutivas', async () => { await withTempDir('pumuki-write-evidence-atomic-stress-', async (tempRoot) => { initGitRepo(tempRoot); await withCwd(tempRoot, async () => { const writes = Array.from({ length: 50 }, (_, index) => Promise.resolve().then(() => { const evidence = sampleEvidence(tempRoot); evidence.timestamp = `2026-02-17T00:00:${String(index).padStart(2, '0')}.000Z`; const result = writeEvidence(evidence); assert.equal(result.ok, true); }) ); await Promise.all(writes); const persisted = JSON.parse( readFileSync(join(tempRoot, '.ai_evidence.json'), 'utf8') ) as AiEvidenceV2_1; assert.equal(persisted.version, '2.1'); assert.equal(typeof persisted.timestamp, 'string'); assert.equal(persisted.snapshot.findings.length > 0, true); }); }); }); test('writeEvidence añade evidence_chain y encadena con la escritura previa', async () => { await withTempDir('pumuki-write-evidence-chain-', async (tempRoot) => { initGitRepo(tempRoot); await withCwd(tempRoot, async () => { const firstResult = writeEvidence(sampleEvidence(tempRoot)); assert.equal(firstResult.ok, true); const firstPersisted = JSON.parse(readFileSync(firstResult.path, 'utf8')) as AiEvidenceV2_1 & { evidence_chain?: { algorithm: 'sha256'; previous_payload_hash: string | null; payload_hash: string; sequence: number; }; }; assert.equal(typeof firstPersisted.evidence_chain?.payload_hash, 'string'); assert.equal(firstPersisted.evidence_chain?.previous_payload_hash, null); assert.equal(firstPersisted.evidence_chain?.sequence, 1); const secondEvidence = sampleEvidence(tempRoot); secondEvidence.timestamp = '2026-02-17T00:10:00.000Z'; const secondResult = writeEvidence(secondEvidence); assert.equal(secondResult.ok, true); const secondPersisted = JSON.parse(readFileSync(secondResult.path, 'utf8')) as AiEvidenceV2_1 & { evidence_chain?: { algorithm: 'sha256'; previous_payload_hash: string | null; payload_hash: string; sequence: number; }; }; assert.equal(secondPersisted.evidence_chain?.algorithm, 'sha256'); assert.equal( secondPersisted.evidence_chain?.previous_payload_hash, firstPersisted.evidence_chain?.payload_hash ); assert.equal(secondPersisted.evidence_chain?.sequence, 2); }); }); });