diff --git a/plugins/ultraplan-local/lib/parsers/finding-id.mjs b/plugins/ultraplan-local/lib/parsers/finding-id.mjs new file mode 100644 index 0000000..462cf0e --- /dev/null +++ b/plugins/ultraplan-local/lib/parsers/finding-id.mjs @@ -0,0 +1,54 @@ +// lib/parsers/finding-id.mjs +// Stable finding-ID for /ultrareview-local v1.0. +// +// id = sha1(file:line:rule_key) → 40-char hex. +// Same input always produces same output (determinism floor SC4). +// node:crypto is built-in (zero-deps invariant). + +import { createHash } from 'node:crypto'; + +const HEX_RE = /^[0-9a-f]{40}$/; + +/** + * Compute a stable 40-char hex finding-ID. + * @param {string} filePath — relative path (caller normalizes if needed) + * @param {number|string} line — 1-based line number; coerced to string + * @param {string} ruleKey — must be a non-empty string from RULE_KEYS + * @returns {string} 40-char lowercase hex + * @throws {TypeError} on bad input + */ +export function computeFindingId(filePath, line, ruleKey) { + if (typeof filePath !== 'string' || filePath.length === 0) { + throw new TypeError('computeFindingId: filePath must be a non-empty string'); + } + if (line === null || line === undefined) { + throw new TypeError('computeFindingId: line must be a number or numeric string'); + } + if (typeof line === 'number') { + if (!Number.isFinite(line)) { + throw new TypeError('computeFindingId: line must be finite'); + } + } else if (typeof line === 'string') { + if (line.length === 0) { + throw new TypeError('computeFindingId: line must not be empty string'); + } + } else { + throw new TypeError('computeFindingId: line must be a number or numeric string'); + } + if (typeof ruleKey !== 'string' || ruleKey.length === 0) { + throw new TypeError('computeFindingId: ruleKey must be a non-empty string'); + } + + const composite = `${filePath}:${line}:${ruleKey}`; + return createHash('sha1').update(composite).digest('hex'); +} + +/** + * Validate a finding-ID's shape (40-char lowercase hex). + * @param {string} id + * @returns {{valid: boolean}} + */ +export function parseFindingId(id) { + if (typeof id !== 'string') return { valid: false }; + return { valid: HEX_RE.test(id) }; +} diff --git a/plugins/ultraplan-local/tests/lib/finding-id.test.mjs b/plugins/ultraplan-local/tests/lib/finding-id.test.mjs new file mode 100644 index 0000000..86bc5c6 --- /dev/null +++ b/plugins/ultraplan-local/tests/lib/finding-id.test.mjs @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { computeFindingId, parseFindingId } from '../../lib/parsers/finding-id.mjs'; + +test('computeFindingId — deterministic on same inputs', () => { + const a = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST'); + const b = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST'); + assert.equal(a, b); +}); + +test('computeFindingId — different file → different ID', () => { + const a = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST'); + const b = computeFindingId('lib/bar.mjs', 42, 'MISSING_TEST'); + assert.notEqual(a, b); +}); + +test('computeFindingId — different line → different ID', () => { + const a = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST'); + const b = computeFindingId('lib/foo.mjs', 43, 'MISSING_TEST'); + assert.notEqual(a, b); +}); + +test('computeFindingId — different rule_key → different ID', () => { + const a = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST'); + const b = computeFindingId('lib/foo.mjs', 42, 'MISSING_BRIEF_REF'); + assert.notEqual(a, b); +}); + +test('computeFindingId — output is 40-char lowercase hex', () => { + const id = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST'); + assert.match(id, /^[0-9a-f]{40}$/); +}); + +test('computeFindingId — throws TypeError on null/undefined/empty inputs', () => { + assert.throws(() => computeFindingId(null, 1, 'X'), TypeError); + assert.throws(() => computeFindingId('', 1, 'X'), TypeError); + assert.throws(() => computeFindingId('a', null, 'X'), TypeError); + assert.throws(() => computeFindingId('a', undefined, 'X'), TypeError); + assert.throws(() => computeFindingId('a', '', 'X'), TypeError); + assert.throws(() => computeFindingId('a', 1, ''), TypeError); + assert.throws(() => computeFindingId('a', 1, null), TypeError); + assert.throws(() => computeFindingId('a', NaN, 'X'), TypeError); +}); + +test('parseFindingId — valid 40-char hex returns valid:true', () => { + const id = computeFindingId('a', 1, 'X'); + assert.equal(parseFindingId(id).valid, true); +}); + +test('parseFindingId — bad input returns valid:false (no throw)', () => { + assert.equal(parseFindingId('').valid, false); + assert.equal(parseFindingId('xyz').valid, false); + assert.equal(parseFindingId('A'.repeat(40)).valid, false); // uppercase rejected + assert.equal(parseFindingId('0'.repeat(39)).valid, false); // too short + assert.equal(parseFindingId('0'.repeat(41)).valid, false); // too long + assert.equal(parseFindingId(null).valid, false); + assert.equal(parseFindingId(undefined).valid, false); + assert.equal(parseFindingId(42).valid, false); +});