import assert from 'node:assert/strict'; import test from 'node:test'; import type { RuleSet } from '../rules/RuleSet'; import { evaluateRules } from './evaluateRules'; test('evaluateRules genera finding cuando la condicion coincide y usa code explicito', () => { const rules: RuleSet = [ { id: 'rule.explicit.code', description: 'Detecta cambios en backend', severity: 'WARN', when: { kind: 'FileChange', where: { pathPrefix: 'apps/backend/', changeType: 'modified' }, }, then: { kind: 'Finding', message: 'Backend file modified.', code: 'BACKEND_FILE_MODIFIED', }, }, ]; const facts = [ { kind: 'FileChange', path: 'apps/backend/src/main.ts', changeType: 'modified', source: 'git', }, ] as const; const findings = evaluateRules(rules, facts); assert.equal(findings.length, 1); assert.equal(findings[0]?.ruleId, 'rule.explicit.code'); assert.equal(findings[0]?.severity, 'WARN'); assert.equal(findings[0]?.code, 'BACKEND_FILE_MODIFIED'); assert.equal(findings[0]?.message, 'Backend file modified.'); assert.equal(findings[0]?.filePath, 'apps/backend/src/main.ts'); assert.equal(findings[0]?.matchedBy, 'FileChange'); assert.equal(findings[0]?.source, 'git'); assert.equal(findings[0]?.blocking, true); }); test('evaluateRules marca cualquier severity como bloqueante bajo contrato zero-violations', () => { const rules: RuleSet = [ { id: 'rule.info.blocking', description: 'Info still blocks in enterprise zero-violations mode.', severity: 'INFO', when: { kind: 'FileChange', where: { pathPrefix: 'apps/frontend/', changeType: 'modified' }, }, then: { kind: 'Finding', message: 'Any finding blocks.', }, }, ]; const facts = [ { kind: 'FileChange', path: 'apps/frontend/src/App.tsx', changeType: 'modified', source: 'git', }, ] as const; const findings = evaluateRules(rules, facts); assert.equal(findings.length, 1); assert.equal(findings[0]?.severity, 'INFO'); assert.equal(findings[0]?.blocking, true); }); test('evaluateRules usa id de la regla como code cuando no se define en consecuencia', () => { const rules: RuleSet = [ { id: 'rule.code.fallback', description: 'Detecta dependencia concreta', severity: 'ERROR', when: { kind: 'Dependency', where: { from: 'core/gate/evaluateGate', to: 'core/gate/evaluateRules' }, }, then: { kind: 'Finding', message: 'Dependency matched.', }, }, ]; const facts = [ { kind: 'Dependency', from: 'core/gate/evaluateGate', to: 'core/gate/evaluateRules', source: 'depcruise', }, ] as const; const findings = evaluateRules(rules, facts); assert.equal(findings.length, 1); assert.equal(findings[0]?.code, 'rule.code.fallback'); assert.equal(findings[0]?.severity, 'ERROR'); }); test('evaluateRules combina source del fact y source declarada por la regla', () => { const rules: RuleSet = [ { id: 'skills.backend.no-empty-catch', description: 'Disallow empty catch blocks in backend runtime code.', severity: 'ERROR', when: { kind: 'FileContent', regex: ['catch\\s*\\{\\s*\\}'], }, then: { kind: 'Finding', message: 'Disallow empty catch blocks in backend runtime code.', code: 'SKILLS_BACKEND_NO_EMPTY_CATCH', source: 'skills-ir:rule=skills.backend.no-empty-catch;source_skill=backend-guidelines;source_path=docs/codex-skills/windsurf-rules-backend.md;evaluation_mode=AUTO;ast_nodes=[heuristics.ts.empty-catch.ast]', }, scope: { include: ['apps/backend/'], }, }, ]; const facts = [ { kind: 'FileContent', path: 'apps/backend/src/service.ts', content: 'try { doStuff(); } catch {}', source: 'git:staged', }, ] as const; const findings = evaluateRules(rules, facts); assert.equal(findings.length, 1); assert.equal( findings[0]?.source, 'git:staged|skills-ir:rule=skills.backend.no-empty-catch;source_skill=backend-guidelines;source_path=docs/codex-skills/windsurf-rules-backend.md;evaluation_mode=AUTO;ast_nodes=[heuristics.ts.empty-catch.ast]' ); }); test('evaluateRules respeta scope y no genera hallazgo cuando no coincide', () => { const rules: RuleSet = [ { id: 'rule.scope.filtered', description: 'Busca token en backend', severity: 'WARN', when: { kind: 'FileContent', contains: ['token'], }, then: { kind: 'Finding', message: 'Token found.', }, scope: { include: ['apps/backend/*'], }, }, ]; const facts = [ { kind: 'FileContent', path: 'apps/frontend/src/App.tsx', content: 'const token = "abc";', source: 'repo', }, ] as const; const findings = evaluateRules(rules, facts); assert.deepEqual(findings, []); }); test('evaluateRules genera un finding por cada archivo que coincide en FileContent', () => { const rules: RuleSet = [ { id: 'rule.multi.filecontent', description: 'Detecta any en backend', severity: 'WARN', when: { kind: 'FileContent', regex: [':\\s*any\\b'], }, then: { kind: 'Finding', message: 'Avoid any', }, scope: { include: ['apps/backend/'], }, }, ]; const facts = [ { kind: 'FileContent', path: 'apps/backend/src/a.ts', content: 'const a: any = 1;', source: 'repo', }, { kind: 'FileContent', path: 'apps/backend/src/b.ts', content: 'const b: any = 2;', source: 'repo', }, ] as const; const findings = evaluateRules(rules, facts); assert.equal(findings.length, 2); assert.deepEqual( findings.map((finding) => finding.filePath).sort(), ['apps/backend/src/a.ts', 'apps/backend/src/b.ts'] ); }); test('evaluateRules genera un finding por cada heuristica coincidente', () => { const rules: RuleSet = [ { id: 'rule.multi.heuristic', description: 'Mapea heuristicas de console.log', severity: 'ERROR', when: { kind: 'Heuristic', where: { ruleId: 'heuristics.ts.console-log.ast', }, }, then: { kind: 'Finding', message: 'console.log detected', }, }, ]; const facts = [ { kind: 'Heuristic', ruleId: 'heuristics.ts.console-log.ast', severity: 'WARN', code: 'HEURISTICS_CONSOLE_LOG_AST', message: 'console.log', filePath: 'core/a.ts', source: 'heuristics:ast', }, { kind: 'Heuristic', ruleId: 'heuristics.ts.console-log.ast', severity: 'WARN', code: 'HEURISTICS_CONSOLE_LOG_AST', message: 'console.log', filePath: 'core/b.ts', source: 'heuristics:ast', }, ] as const; const findings = evaluateRules(rules, facts); assert.equal(findings.length, 2); assert.deepEqual( findings.map((finding) => finding.filePath).sort(), ['core/a.ts', 'core/b.ts'] ); assert.equal(findings.every((finding) => finding.matchedBy === 'Heuristic'), true); }); test('evaluateRules no genera finding de iOS cuando el scope es swift y el archivo es TypeScript', () => { const rules: RuleSet = [ { id: 'ios.no-force-unwrap', description: 'Disallows force unwraps in iOS code.', severity: 'CRITICAL', scope: { include: ['**/*.swift'], }, when: { kind: 'All', conditions: [ { kind: 'FileContent', contains: ['!'], }, { kind: 'Not', condition: { kind: 'FileContent', contains: ['IBOutlet'], }, }, ], }, then: { kind: 'Finding', message: 'Force unwraps are not allowed in iOS code.', code: 'IOS_NO_FORCE_UNWRAP', }, }, ]; const facts = [ { kind: 'FileContent', path: 'apps/admin-dashboard/middleware.ts', content: 'if (token != null) { return NextResponse.next(); }', source: 'repo', }, ] as const; const findings = evaluateRules(rules, facts); assert.deepEqual(findings, []); });