diff --git a/lib/utils/health.js b/lib/utils/health.js new file mode 100644 index 0000000..acafa28 --- /dev/null +++ b/lib/utils/health.js @@ -0,0 +1,26 @@ +'use strict'; + +function num(x) { return typeof x === 'number' && Number.isFinite(x) ? x : 0; } + +/** + * Compute health scores based on violation density per 1K executable LOC. + * Expects a summary with fields: lines.executable (or physical) and counts per category. + * Returns integers 0..100 for overall, structural, semantic. + */ +function computeHealth(summary) { + const exec = Math.max(1, num(summary && summary.lines && summary.lines.executable) || num(summary && summary.lines && summary.lines.physical) || 1000); + const total = num(summary && summary.magicNumbers) + num(summary && summary.complexity) + num(summary && summary.domainTerms) + num(summary && summary.architecture); + const structuralTotal = num(summary && summary.complexity) + num(summary && summary.architecture); + const semanticTotal = num(summary && summary.domainTerms) + num(summary && summary.magicNumbers); + const perK = total / (exec / 1000); + const perKStructural = structuralTotal / (exec / 1000); + const perKSemantic = semanticTotal / (exec / 1000); + const toScore = (d) => Math.max(0, Math.min(100, 100 - d * 10)); + return { + overall: Math.round(toScore(perK)), + structural: Math.round(toScore(perKStructural)), + semantic: Math.round(toScore(perKSemantic)) + }; +} + +module.exports = { computeHealth }; \ No newline at end of file diff --git a/scripts/ratchet.js b/scripts/ratchet.js index e24e373..ee41558 100755 --- a/scripts/ratchet.js +++ b/scripts/ratchet.js @@ -85,22 +85,8 @@ function summarize(payload) { }; } -// Rough health scoring using violation density (per K executable LOC if available) -function computeHealth(summary) { - const exec = Math.max(1, num(summary.lines && summary.lines.executable) || num(summary.lines && summary.lines.physical) || 1000); - const total = num(summary.magicNumbers) + num(summary.complexity) + num(summary.domainTerms) + num(summary.architecture); - const structuralTotal = num(summary.complexity) + num(summary.architecture); - const semanticTotal = num(summary.domainTerms) + num(summary.magicNumbers); - const perK = total / (exec / 1000); - const perKStructural = structuralTotal / (exec / 1000); - const perKSemantic = semanticTotal / (exec / 1000); - const toScore = (d) => Math.max(0, Math.min(100, 100 - d * 10)); - return { - overall: Math.round(toScore(perK)), - structural: Math.round(toScore(perKStructural)), - semantic: Math.round(toScore(perKSemantic)) - }; -} +// Health scoring moved to utility for testability +const { computeHealth } = require(path.join(__dirname, '..', 'lib', 'utils', 'health.js')); function detectIntent(args) { if (args && (args.refactoring || args.intent === 'refactoring')) return 'refactoring'; diff --git a/tests/integration/health-gating.test.js b/tests/integration/health-gating.test.js index c3e86fc..3d4b7cf 100644 --- a/tests/integration/health-gating.test.js +++ b/tests/integration/health-gating.test.js @@ -49,6 +49,56 @@ describe('health gating', function () { assert.ok(res.out.includes('HEALTH-GATE FAIL')); }); + it('passes gate when score equals threshold (>= semantics)', function () { + const cwd = mkTmp(); + // exec=1000, structural=3 → overall=70 + const cats = { magicNumbers: [], complexity: Array.from({length:3},()=>({})), domainTerms: [], architecture: [], counts: {} }; + const base = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} }; + const curr = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} }; + const cfg = { ratchet: { health: { enabled: true, gateOn: 'overall', minOverall: 70 } } }; + const res = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfg) }); + assert.strictEqual(res.code, 0); + }); + + it('respects gateOn: passes with structural gate, fails with semantic gate', function () { + const cwd = mkTmp(); + // structural=1 (score=90), semantic=5 (score=50), overall=6 (score=40) + const cats = { magicNumbers: Array.from({length:5},()=>({})), complexity: Array.from({length:1},()=>({})), domainTerms: [], architecture: [], counts: {} }; + const base = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} }; + const curr = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} }; + const cfgStruct = { ratchet: { health: { enabled: true, gateOn: 'structural', minOverall: 90 } } }; + const cfgSem = { ratchet: { health: { enabled: true, gateOn: 'semantic', minOverall: 90 } } }; + const resStruct = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfgStruct) }); + const resSem = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfgSem) }); + assert.strictEqual(resStruct.code, 0); + assert.ok(resSem.out.includes('HEALTH-GATE FAIL')); + }); + + it('applies intentOverrides when intent=refactoring', function () { + const cwd = mkTmp(); + const cats = { magicNumbers: [], complexity: Array.from({length:3},()=>({})), domainTerms: [], architecture: [], counts: {} }; // overall=70 + const base = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} }; + const curr = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} }; + const cfg = { ratchet: { health: { enabled: true, gateOn: 'overall', minOverall: 80, intentOverrides: { refactoring: { minOverall: 60 } } } } }; + // Without intent override → fail + const res1 = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfg) }); + assert.ok(res1.out.includes('HEALTH-GATE FAIL')); + // With intent=refactoring → pass + const env2 = { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfg) }; + // pass extra arg via environment by setting AI_SNIFFTEST_ARGS? Not supported; append to argv instead + // We call node directly including --intent=refactoring by modifying runRatchet here + const b = path.join(cwd, 'baseline.json'); + const c = path.join(cwd, 'current.json'); + writeJson(b, base); + writeJson(c, curr); + try { + const out = cp.execFileSync(process.execPath, [path.join(process.cwd(), 'scripts/ratchet.js'), `--baseline=${b}`, `--current=${c}`, '--intent=refactoring'], { cwd, env: { ...process.env, ...env2 }, stdio: 'pipe' }); + assert.strictEqual(String(out).includes('HEALTH-GATE FAIL'), false); + } catch (e) { + assert.fail('expected pass with intent override'); + } + }); + it('bypasses gate when HEALTH_BYPASS=true', function () { const cwd = mkTmp(); // Make baseline and current have same category counts so deltas=0 (avoid traditional ratchet failure) @@ -60,4 +110,20 @@ describe('health gating', function () { // Bypass active: gating not enforced; exit should be success since there are no deltas assert.strictEqual(res.code, 0); }); + + it('bypasses gate via commit token in latest commit message', function () { + const cwd = mkTmp(); + // Keep deltas=0 + const cats = { magicNumbers: Array.from({length:20},()=>({})), complexity: [], domainTerms: [], architecture: [], counts: {} }; // semantic heavy → low score + const base = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} }; + const curr = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} }; + // Initialize a git repo and commit with bypass token + cp.execSync('git init', { cwd }); + fs.writeFileSync(path.join(cwd, 'tmp.txt'), 'x'); + cp.execSync('git add .', { cwd }); + cp.execSync("git -c user.email=test@example.com -c user.name='Test' commit -m '[health-bypass] demo'", { cwd }); + const cfg = { ratchet: { health: { enabled: true, gateOn: 'semantic', minOverall: 90, bypass: { commitToken: '[health-bypass]' } } } }; + const res = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfg) }); + assert.strictEqual(res.code, 0); + }); }); diff --git a/tests/lib/utils/health.test.js b/tests/lib/utils/health.test.js new file mode 100644 index 0000000..12d6082 --- /dev/null +++ b/tests/lib/utils/health.test.js @@ -0,0 +1,43 @@ +/* eslint-env mocha */ +/* global describe, it */ +'use strict'; + +const assert = require('assert'); +const { computeHealth } = require('../../../lib/utils/health'); + +function summary({ exec = 1000, magicNumbers = 0, complexity = 0, domainTerms = 0, architecture = 0 } = {}) { + return { + magicNumbers, + complexity, + domainTerms, + architecture, + lines: { executable: exec } + }; +} + +describe('utils/health.computeHealth', function () { + it('returns 100s when there are no violations', function () { + const s = summary({ exec: 1000 }); + const h = computeHealth(s); + assert.deepStrictEqual(h, { overall: 100, structural: 100, semantic: 100 }); + }); + + it('drops to 0 when perK density is 10 (1000 exec, 10 total)', function () { + const s = summary({ exec: 1000, complexity: 5, architecture: 5 }); + const h = computeHealth(s); + assert.strictEqual(h.overall, 0); + assert.strictEqual(h.structural, 0); + assert.strictEqual(h.semantic, 100); + }); + + it('computes expected rounding at partial densities', function () { + // 2000 exec LOC, total=5 → perK=2.5 → score=100 - 25 = 75 + const s = summary({ exec: 2000, magicNumbers: 2, complexity: 3 }); + const h = computeHealth(s); + assert.strictEqual(h.overall, 75); + // structural=3, perK=1.5 → 85 + assert.strictEqual(h.structural, 85); + // semantic=2, perK=1.0 → 90 + assert.strictEqual(h.semantic, 90); + }); +}); \ No newline at end of file