Skip to content

Commit 13c5591

Browse files
authored
Merge pull request #217 from mojoatomic/tests/health-gating-214
test(health): unit + integration coverage for health gating (#214)
2 parents 71f5cdd + 0092cce commit 13c5591

File tree

4 files changed

+137
-16
lines changed

4 files changed

+137
-16
lines changed

lib/utils/health.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use strict';
2+
3+
function num(x) { return typeof x === 'number' && Number.isFinite(x) ? x : 0; }
4+
5+
/**
6+
* Compute health scores based on violation density per 1K executable LOC.
7+
* Expects a summary with fields: lines.executable (or physical) and counts per category.
8+
* Returns integers 0..100 for overall, structural, semantic.
9+
*/
10+
function computeHealth(summary) {
11+
const exec = Math.max(1, num(summary && summary.lines && summary.lines.executable) || num(summary && summary.lines && summary.lines.physical) || 1000);
12+
const total = num(summary && summary.magicNumbers) + num(summary && summary.complexity) + num(summary && summary.domainTerms) + num(summary && summary.architecture);
13+
const structuralTotal = num(summary && summary.complexity) + num(summary && summary.architecture);
14+
const semanticTotal = num(summary && summary.domainTerms) + num(summary && summary.magicNumbers);
15+
const perK = total / (exec / 1000);
16+
const perKStructural = structuralTotal / (exec / 1000);
17+
const perKSemantic = semanticTotal / (exec / 1000);
18+
const toScore = (d) => Math.max(0, Math.min(100, 100 - d * 10));
19+
return {
20+
overall: Math.round(toScore(perK)),
21+
structural: Math.round(toScore(perKStructural)),
22+
semantic: Math.round(toScore(perKSemantic))
23+
};
24+
}
25+
26+
module.exports = { computeHealth };

scripts/ratchet.js

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -85,22 +85,8 @@ function summarize(payload) {
8585
};
8686
}
8787

88-
// Rough health scoring using violation density (per K executable LOC if available)
89-
function computeHealth(summary) {
90-
const exec = Math.max(1, num(summary.lines && summary.lines.executable) || num(summary.lines && summary.lines.physical) || 1000);
91-
const total = num(summary.magicNumbers) + num(summary.complexity) + num(summary.domainTerms) + num(summary.architecture);
92-
const structuralTotal = num(summary.complexity) + num(summary.architecture);
93-
const semanticTotal = num(summary.domainTerms) + num(summary.magicNumbers);
94-
const perK = total / (exec / 1000);
95-
const perKStructural = structuralTotal / (exec / 1000);
96-
const perKSemantic = semanticTotal / (exec / 1000);
97-
const toScore = (d) => Math.max(0, Math.min(100, 100 - d * 10));
98-
return {
99-
overall: Math.round(toScore(perK)),
100-
structural: Math.round(toScore(perKStructural)),
101-
semantic: Math.round(toScore(perKSemantic))
102-
};
103-
}
88+
// Health scoring moved to utility for testability
89+
const { computeHealth } = require(path.join(__dirname, '..', 'lib', 'utils', 'health.js'));
10490

10591
function detectIntent(args) {
10692
if (args && (args.refactoring || args.intent === 'refactoring')) return 'refactoring';

tests/integration/health-gating.test.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,56 @@ describe('health gating', function () {
4949
assert.ok(res.out.includes('HEALTH-GATE FAIL'));
5050
});
5151

52+
it('passes gate when score equals threshold (>= semantics)', function () {
53+
const cwd = mkTmp();
54+
// exec=1000, structural=3 → overall=70
55+
const cats = { magicNumbers: [], complexity: Array.from({length:3},()=>({})), domainTerms: [], architecture: [], counts: {} };
56+
const base = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} };
57+
const curr = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} };
58+
const cfg = { ratchet: { health: { enabled: true, gateOn: 'overall', minOverall: 70 } } };
59+
const res = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfg) });
60+
assert.strictEqual(res.code, 0);
61+
});
62+
63+
it('respects gateOn: passes with structural gate, fails with semantic gate', function () {
64+
const cwd = mkTmp();
65+
// structural=1 (score=90), semantic=5 (score=50), overall=6 (score=40)
66+
const cats = { magicNumbers: Array.from({length:5},()=>({})), complexity: Array.from({length:1},()=>({})), domainTerms: [], architecture: [], counts: {} };
67+
const base = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} };
68+
const curr = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} };
69+
const cfgStruct = { ratchet: { health: { enabled: true, gateOn: 'structural', minOverall: 90 } } };
70+
const cfgSem = { ratchet: { health: { enabled: true, gateOn: 'semantic', minOverall: 90 } } };
71+
const resStruct = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfgStruct) });
72+
const resSem = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfgSem) });
73+
assert.strictEqual(resStruct.code, 0);
74+
assert.ok(resSem.out.includes('HEALTH-GATE FAIL'));
75+
});
76+
77+
it('applies intentOverrides when intent=refactoring', function () {
78+
const cwd = mkTmp();
79+
const cats = { magicNumbers: [], complexity: Array.from({length:3},()=>({})), domainTerms: [], architecture: [], counts: {} }; // overall=70
80+
const base = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} };
81+
const curr = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} };
82+
const cfg = { ratchet: { health: { enabled: true, gateOn: 'overall', minOverall: 80, intentOverrides: { refactoring: { minOverall: 60 } } } } };
83+
// Without intent override → fail
84+
const res1 = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfg) });
85+
assert.ok(res1.out.includes('HEALTH-GATE FAIL'));
86+
// With intent=refactoring → pass
87+
const env2 = { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfg) };
88+
// pass extra arg via environment by setting AI_SNIFFTEST_ARGS? Not supported; append to argv instead
89+
// We call node directly including --intent=refactoring by modifying runRatchet here
90+
const b = path.join(cwd, 'baseline.json');
91+
const c = path.join(cwd, 'current.json');
92+
writeJson(b, base);
93+
writeJson(c, curr);
94+
try {
95+
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' });
96+
assert.strictEqual(String(out).includes('HEALTH-GATE FAIL'), false);
97+
} catch (e) {
98+
assert.fail('expected pass with intent override');
99+
}
100+
});
101+
52102
it('bypasses gate when HEALTH_BYPASS=true', function () {
53103
const cwd = mkTmp();
54104
// Make baseline and current have same category counts so deltas=0 (avoid traditional ratchet failure)
@@ -60,4 +110,20 @@ describe('health gating', function () {
60110
// Bypass active: gating not enforced; exit should be success since there are no deltas
61111
assert.strictEqual(res.code, 0);
62112
});
113+
114+
it('bypasses gate via commit token in latest commit message', function () {
115+
const cwd = mkTmp();
116+
// Keep deltas=0
117+
const cats = { magicNumbers: Array.from({length:20},()=>({})), complexity: [], domainTerms: [], architecture: [], counts: {} }; // semantic heavy → low score
118+
const base = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} };
119+
const curr = { categories: cats, effort: { byCategory: {} }, lines: { physical: 1000, executable: 1000 }, meta: {} };
120+
// Initialize a git repo and commit with bypass token
121+
cp.execSync('git init', { cwd });
122+
fs.writeFileSync(path.join(cwd, 'tmp.txt'), 'x');
123+
cp.execSync('git add .', { cwd });
124+
cp.execSync("git -c user.email=test@example.com -c user.name='Test' commit -m '[health-bypass] demo'", { cwd });
125+
const cfg = { ratchet: { health: { enabled: true, gateOn: 'semantic', minOverall: 90, bypass: { commitToken: '[health-bypass]' } } } };
126+
const res = runRatchet(cwd, base, curr, { AI_SNIFFTEST_CONFIG_JSON: JSON.stringify(cfg) });
127+
assert.strictEqual(res.code, 0);
128+
});
63129
});

tests/lib/utils/health.test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* eslint-env mocha */
2+
/* global describe, it */
3+
'use strict';
4+
5+
const assert = require('assert');
6+
const { computeHealth } = require('../../../lib/utils/health');
7+
8+
function summary({ exec = 1000, magicNumbers = 0, complexity = 0, domainTerms = 0, architecture = 0 } = {}) {
9+
return {
10+
magicNumbers,
11+
complexity,
12+
domainTerms,
13+
architecture,
14+
lines: { executable: exec }
15+
};
16+
}
17+
18+
describe('utils/health.computeHealth', function () {
19+
it('returns 100s when there are no violations', function () {
20+
const s = summary({ exec: 1000 });
21+
const h = computeHealth(s);
22+
assert.deepStrictEqual(h, { overall: 100, structural: 100, semantic: 100 });
23+
});
24+
25+
it('drops to 0 when perK density is 10 (1000 exec, 10 total)', function () {
26+
const s = summary({ exec: 1000, complexity: 5, architecture: 5 });
27+
const h = computeHealth(s);
28+
assert.strictEqual(h.overall, 0);
29+
assert.strictEqual(h.structural, 0);
30+
assert.strictEqual(h.semantic, 100);
31+
});
32+
33+
it('computes expected rounding at partial densities', function () {
34+
// 2000 exec LOC, total=5 → perK=2.5 → score=100 - 25 = 75
35+
const s = summary({ exec: 2000, magicNumbers: 2, complexity: 3 });
36+
const h = computeHealth(s);
37+
assert.strictEqual(h.overall, 75);
38+
// structural=3, perK=1.5 → 85
39+
assert.strictEqual(h.structural, 85);
40+
// semantic=2, perK=1.0 → 90
41+
assert.strictEqual(h.semantic, 90);
42+
});
43+
});

0 commit comments

Comments
 (0)