From d9ec0d0fc0310972340d5c06d5c946fe0a27471e Mon Sep 17 00:00:00 2001 From: peterexcel494-oss Date: Mon, 17 Aug 2026 18:22:13 +0000 Subject: [PATCH] feat(validation): enforce label-to-score-band consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend validate-fixtures.mjs to compare each fixture's score against the documented band for its label (CONTRIBUTING.md: clean 0–25, suspicious 40–70, malicious 75–100). Intentional gaps (26–39 and 71–74) are preserved and not filled. Changes: - Export SCORE_BANDS constant with min/max for each label - Export scoreMatchesBand(label, score) helper used in the validator loop - Export bandRangeLabel(label) for human-readable error messages - Band-check loop in the main validator produces actionable errors that name the fixture ID, the actual score, and the expected range - Add scripts/validate-fixtures.test.mjs (node:test, 78 tests) covering boundary values, gap values, mismatched labels, and all three bands - Add 'test' script to package.json Closes #62 --- package.json | 3 +- scripts/validate-fixtures.mjs | 49 ++++++- scripts/validate-fixtures.test.mjs | 200 +++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 scripts/validate-fixtures.test.mjs diff --git a/package.json b/package.json index f220cbc..816ea8f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "license": "MIT", "private": true, "scripts": { - "validate": "node scripts/validate-fixtures.mjs" + "validate": "node scripts/validate-fixtures.mjs", + "test": "node --test scripts/validate-fixtures.test.mjs" } } diff --git a/scripts/validate-fixtures.mjs b/scripts/validate-fixtures.mjs index e7f6980..5f5ac32 100644 --- a/scripts/validate-fixtures.mjs +++ b/scripts/validate-fixtures.mjs @@ -11,6 +11,39 @@ const VALID_RISK_PATTERNS = new Set([ 'scam-trustline', 'signer-takeover', 'memo-impersonation', 'sponsored-mule', 'cold-start', 'adversarial-clean', 'none' ]); + +/** + * Score bands per label as documented in CONTRIBUTING.md. + * Gaps (26–39 and 71–74) are intentionally unused — do not fill them. + */ +export const SCORE_BANDS = { + clean: { min: 0, max: 25 }, + suspicious: { min: 40, max: 70 }, + malicious: { min: 75, max: 100 }, +}; + +/** + * Return a human-readable range string for a label, e.g. "0–25". + * @param {string} label + * @returns {string} + */ +export function bandRangeLabel(label) { + const band = SCORE_BANDS[label]; + return band ? `${band.min}–${band.max}` : 'unknown'; +} + +/** + * Check whether a numeric score falls within the expected band for a given label. + * @param {string} label - one of 'clean' | 'suspicious' | 'malicious' + * @param {number} score - integer 0–100 + * @returns {boolean} + */ +export function scoreMatchesBand(label, score) { + const band = SCORE_BANDS[label]; + if (!band) return false; + return score >= band.min && score <= band.max; +} + const errors = []; for (const d of destinations) { @@ -28,11 +61,23 @@ for (const d of destinations) { } for (const [id, score] of Object.entries(scores)) { - if (typeof score !== 'number' || score < 0 || score > 100) { - errors.push(id + ': score ' + score + ' out of range 0-100'); + if (typeof score !== 'number' || !Number.isInteger(score) || score < 0 || score > 100) { + errors.push(id + ': score ' + score + ' is not an integer in 0–100'); + continue; // skip band check when score itself is invalid } if (!destinations.some((d) => d.id === id)) { errors.push(id + ': present in scores.json but not in destinations.json'); + continue; + } + + const dest = destinations.find((d) => d.id === id); + if (dest && VALID_LABELS.has(dest.label)) { + if (!scoreMatchesBand(dest.label, score)) { + errors.push( + `${id}: score ${score} is outside the expected band for label "${dest.label}" ` + + `(expected ${bandRangeLabel(dest.label)})` + ); + } } } diff --git a/scripts/validate-fixtures.test.mjs b/scripts/validate-fixtures.test.mjs new file mode 100644 index 0000000..b2d3d1e --- /dev/null +++ b/scripts/validate-fixtures.test.mjs @@ -0,0 +1,200 @@ +/** + * Tests for label-to-score-band consistency (issue #62). + * + * Covers: + * - Boundary values (min and max of each band) + * - Values in intentional gaps (26–39 and 71–74) + * - Values just outside each band edge + * - Mismatched label/score combinations + * - All three valid labels at their boundaries + * + * Run with: npm test + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { SCORE_BANDS, scoreMatchesBand, bandRangeLabel } from './validate-fixtures.mjs'; + +// --------------------------------------------------------------------------- +// SCORE_BANDS shape +// --------------------------------------------------------------------------- + +describe('SCORE_BANDS', () => { + it('exports bands for all three labels', () => { + assert.ok(SCORE_BANDS.clean, 'clean band missing'); + assert.ok(SCORE_BANDS.suspicious, 'suspicious band missing'); + assert.ok(SCORE_BANDS.malicious, 'malicious band missing'); + }); + + it('clean band is 0–25', () => { + assert.equal(SCORE_BANDS.clean.min, 0); + assert.equal(SCORE_BANDS.clean.max, 25); + }); + + it('suspicious band is 40–70', () => { + assert.equal(SCORE_BANDS.suspicious.min, 40); + assert.equal(SCORE_BANDS.suspicious.max, 70); + }); + + it('malicious band is 75–100', () => { + assert.equal(SCORE_BANDS.malicious.min, 75); + assert.equal(SCORE_BANDS.malicious.max, 100); + }); +}); + +// --------------------------------------------------------------------------- +// bandRangeLabel +// --------------------------------------------------------------------------- + +describe('bandRangeLabel', () => { + it('returns "0–25" for clean', () => { + assert.equal(bandRangeLabel('clean'), '0–25'); + }); + it('returns "40–70" for suspicious', () => { + assert.equal(bandRangeLabel('suspicious'), '40–70'); + }); + it('returns "75–100" for malicious', () => { + assert.equal(bandRangeLabel('malicious'), '75–100'); + }); + it('returns "unknown" for an unrecognised label', () => { + assert.equal(bandRangeLabel('unknown-label'), 'unknown'); + }); +}); + +// --------------------------------------------------------------------------- +// scoreMatchesBand — clean (0–25) +// --------------------------------------------------------------------------- + +describe('scoreMatchesBand — clean', () => { + // Lower boundary + it('accepts lower boundary: 0', () => assert.ok(scoreMatchesBand('clean', 0))); + it('accepts score 1 (just above lower boundary)', () => assert.ok(scoreMatchesBand('clean', 1))); + + // Upper boundary + it('accepts upper boundary: 25', () => assert.ok(scoreMatchesBand('clean', 25))); + it('accepts score 24 (just below upper boundary)', () => assert.ok(scoreMatchesBand('clean', 24))); + + // Mid-band values from existing fixtures + it('accepts existing fixture score 2', () => assert.ok(scoreMatchesBand('clean', 2))); + it('accepts existing fixture score 3', () => assert.ok(scoreMatchesBand('clean', 3))); + it('accepts existing fixture score 4', () => assert.ok(scoreMatchesBand('clean', 4))); + it('accepts existing fixture score 6', () => assert.ok(scoreMatchesBand('clean', 6))); + + // Just outside upper boundary — intentional gap starts at 26 + it('rejects score 26 (first value in gap 26–39)', () => assert.ok(!scoreMatchesBand('clean', 26))); + it('rejects score 39 (last value in gap 26–39)', () => assert.ok(!scoreMatchesBand('clean', 39))); + + // Wrong band entirely + it('rejects suspicious-range score 55', () => assert.ok(!scoreMatchesBand('clean', 55))); + it('rejects malicious-range score 85', () => assert.ok(!scoreMatchesBand('clean', 85))); +}); + +// --------------------------------------------------------------------------- +// scoreMatchesBand — suspicious (40–70) +// --------------------------------------------------------------------------- + +describe('scoreMatchesBand — suspicious', () => { + // Lower boundary + it('accepts lower boundary: 40', () => assert.ok(scoreMatchesBand('suspicious', 40))); + it('accepts score 41 (just above lower boundary)', () => assert.ok(scoreMatchesBand('suspicious', 41))); + + // Upper boundary + it('accepts upper boundary: 70', () => assert.ok(scoreMatchesBand('suspicious', 70))); + it('accepts score 69 (just below upper boundary)', () => assert.ok(scoreMatchesBand('suspicious', 69))); + + // Mid-band values from existing fixtures + it('accepts existing fixture score 55', () => assert.ok(scoreMatchesBand('suspicious', 55))); + it('accepts existing fixture score 58', () => assert.ok(scoreMatchesBand('suspicious', 58))); + it('accepts existing fixture score 62', () => assert.ok(scoreMatchesBand('suspicious', 62))); + + // Below lower boundary — gap ends at 39, band starts at 40 + it('rejects score 39 (last value in gap 26–39)', () => assert.ok(!scoreMatchesBand('suspicious', 39))); + it('rejects score 25 (clean upper boundary)', () => assert.ok(!scoreMatchesBand('suspicious', 25))); + + // Above upper boundary — intentional gap 71–74 + it('rejects score 71 (first value in gap 71–74)', () => assert.ok(!scoreMatchesBand('suspicious', 71))); + it('rejects score 74 (last value in gap 71–74)', () => assert.ok(!scoreMatchesBand('suspicious', 74))); + + // Wrong band entirely + it('rejects clean-range score 10', () => assert.ok(!scoreMatchesBand('suspicious', 10))); + it('rejects malicious-range score 90', () => assert.ok(!scoreMatchesBand('suspicious', 90))); +}); + +// --------------------------------------------------------------------------- +// scoreMatchesBand — malicious (75–100) +// --------------------------------------------------------------------------- + +describe('scoreMatchesBand — malicious', () => { + // Lower boundary + it('accepts lower boundary: 75', () => assert.ok(scoreMatchesBand('malicious', 75))); + it('accepts score 76 (just above lower boundary)', () => assert.ok(scoreMatchesBand('malicious', 76))); + + // Upper boundary + it('accepts upper boundary: 100', () => assert.ok(scoreMatchesBand('malicious', 100))); + it('accepts score 99 (just below upper boundary)', () => assert.ok(scoreMatchesBand('malicious', 99))); + + // Mid-band values from existing fixtures + it('accepts existing fixture score 85', () => assert.ok(scoreMatchesBand('malicious', 85))); + it('accepts existing fixture score 89', () => assert.ok(scoreMatchesBand('malicious', 89))); + it('accepts existing fixture score 92', () => assert.ok(scoreMatchesBand('malicious', 92))); + it('accepts existing fixture score 95', () => assert.ok(scoreMatchesBand('malicious', 95))); + it('accepts existing fixture score 97', () => assert.ok(scoreMatchesBand('malicious', 97))); + + // Below lower boundary — gap 71–74 ends at 74, band starts at 75 + it('rejects score 74 (last value in gap 71–74)', () => assert.ok(!scoreMatchesBand('malicious', 74))); + it('rejects score 70 (suspicious upper boundary)', () => assert.ok(!scoreMatchesBand('malicious', 70))); + + // Wrong band entirely + it('rejects clean-range score 5', () => assert.ok(!scoreMatchesBand('malicious', 5))); + it('rejects suspicious-range score 60', () => assert.ok(!scoreMatchesBand('malicious', 60))); +}); + +// --------------------------------------------------------------------------- +// Intentional gap values — must fail ALL labels +// --------------------------------------------------------------------------- + +describe('intentional gap values (26–39 and 71–74) are rejected by all labels', () => { + for (const gapScore of [26, 30, 35, 39, 71, 72, 73, 74]) { + it(`score ${gapScore} is rejected by clean`, () => assert.ok(!scoreMatchesBand('clean', gapScore))); + it(`score ${gapScore} is rejected by suspicious`, () => assert.ok(!scoreMatchesBand('suspicious', gapScore))); + it(`score ${gapScore} is rejected by malicious`, () => assert.ok(!scoreMatchesBand('malicious', gapScore))); + } +}); + +// --------------------------------------------------------------------------- +// Cross-label mismatch scenarios (the core issue #62 case) +// --------------------------------------------------------------------------- + +describe('cross-label mismatch detection', () => { + it('clean label with a suspicious-range score (55) is rejected', () => { + assert.ok(!scoreMatchesBand('clean', 55)); + }); + it('clean label with a malicious-range score (90) is rejected', () => { + assert.ok(!scoreMatchesBand('clean', 90)); + }); + it('suspicious label with a clean-range score (10) is rejected', () => { + assert.ok(!scoreMatchesBand('suspicious', 10)); + }); + it('suspicious label with a malicious-range score (80) is rejected', () => { + assert.ok(!scoreMatchesBand('suspicious', 80)); + }); + it('malicious label with a clean-range score (15) is rejected', () => { + assert.ok(!scoreMatchesBand('malicious', 15)); + }); + it('malicious label with a suspicious-range score (65) is rejected', () => { + assert.ok(!scoreMatchesBand('malicious', 65)); + }); +}); + +// --------------------------------------------------------------------------- +// Unknown / invalid label +// --------------------------------------------------------------------------- + +describe('scoreMatchesBand with invalid label', () => { + it('returns false for an unknown label', () => { + assert.ok(!scoreMatchesBand('unknown', 50)); + }); + it('returns false for an empty label', () => { + assert.ok(!scoreMatchesBand('', 50)); + }); +});