diff --git a/app/test/coverage-matrix-parser.test.ts b/app/test/coverage-matrix-parser.test.ts index 852a315453..d6ec3ce7af 100644 --- a/app/test/coverage-matrix-parser.test.ts +++ b/app/test/coverage-matrix-parser.test.ts @@ -38,4 +38,39 @@ Trailing prose.`; expect(parsed.rows[0].id).toBe('4.4.4'); expect(parsed.errors).toHaveLength(1); }); + + it('reports line-numbered errors for invalid ID and status', () => { + const md = `header line +| 1.1.1 | Good | RU | a.rs | ✅ | ok | +| 2.2.2 | Bad status | RU | b.rs | ⚠️ | nope |`; + const parsed = parseMatrix(md); + expect(parsed.errors[0]).toMatch(/^Line 3 \(2\.2\.2\): invalid status/); + }); + + it('handles a row with empty notes', () => { + const md = `| 6.6.6 | No notes | RU | a.rs | ✅ | |`; + const parsed = parseMatrix(md); + expect(parsed.rows).toHaveLength(1); + expect(parsed.rows[0].notes).toBe(''); + }); + + it('matches rows with trailing whitespace after final pipe', () => { + const md = `| 7.7.7 | Trailing | RU | a.rs | ✅ | ok | `; + const parsed = parseMatrix(md); + expect(parsed.rows).toHaveLength(1); + expect(parsed.rows[0].id).toBe('7.7.7'); + }); + + it('returns totalRows and uniqueRows from validateAgainstCatalog', () => { + const rows = [ + { id: '1.1.1', name: 'A', layer: 'RU', path: 'a', status: '✅', notes: '' }, + { id: '1.1.1', name: 'B', layer: 'RU', path: 'b', status: '✅', notes: '' }, + { id: '2.2.2', name: 'C', layer: 'RU', path: 'c', status: '✅', notes: '' }, + ]; + const result = validateAgainstCatalog(rows, ['1.1.1', '2.2.2']); + expect(result.totalRows).toBe(3); + expect(result.uniqueRows).toBe(2); + expect(result.duplicates).toEqual(['1.1.1']); + expect(result.missingFromMatrix).toEqual([]); + }); }); diff --git a/scripts/lib/coverage-matrix-parser.mjs b/scripts/lib/coverage-matrix-parser.mjs index 450cf88082..26f16a4b9e 100644 --- a/scripts/lib/coverage-matrix-parser.mjs +++ b/scripts/lib/coverage-matrix-parser.mjs @@ -1,37 +1,86 @@ -const ROW_REGEX = /^\| (\d+(?:\.\d+){2,3}) \| ([^|]+) \| ([^|]+) \| ([^|]+) \| ([^|]+) \| ([^|]+) \|/; +const ROW_REGEX = + /^\|\s*(\d+(?:\.\d+){2,3})\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*$/u; + const ID_REGEX = /^\d+(?:\.\d+){2,3}$/; -const VALID_STATUS = new Set(['✅', '🟡', '❌', '🚫']); + +const VALID_STATUS = new Set(["✅", "🟡", "❌", "🚫"]); export function parseMatrix(markdown) { + if (typeof markdown !== "string") { + return { + rows: [], + errors: ["Input must be a string"], + }; + } + const rows = []; const errors = []; - if (typeof markdown !== 'string') { - return { rows, errors }; - } - for (const line of markdown.split(/\r?\n/)) { - const match = line.match(ROW_REGEX); + + const lines = markdown.split(/\r?\n/); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + const match = ROW_REGEX.exec(line); if (!match) continue; - const [, id, name, layer, path, status, notes] = match.map((v) => (typeof v === 'string' ? v.trim() : v)); + + const [, id, name, layer, path, rawStatus, notes] = match; + + const status = rawStatus.trim(); + if (!ID_REGEX.test(id)) { - errors.push(`Invalid ID format: ${id}`); + errors.push(`Line ${i + 1}: Invalid ID format "${id}"`); continue; } + if (!VALID_STATUS.has(status)) { - errors.push(`Row ${id}: invalid status "${status}" (must be one of ${[...VALID_STATUS].join(' ')})`); + errors.push(`Line ${i + 1} (${id}): invalid status "${status}"`); continue; } - rows.push({ id, name, layer, path, status, notes }); + + rows.push({ + id, + name: name.trim(), + layer: layer.trim(), + path: path.trim(), + status, + notes: notes.trim(), + }); } + return { rows, errors }; } -export function validateAgainstCatalog(parsedRows, catalogIds) { - const seen = new Map(); - for (const row of parsedRows) { - seen.set(row.id, (seen.get(row.id) ?? 0) + 1); +export function validateAgainstCatalog(rows, catalogIds) { + const counts = new Map(); + + for (const { id } of rows) { + counts.set(id, (counts.get(id) ?? 0) + 1); + } + + const duplicates = []; + + for (const [id, count] of counts) { + if (count > 1) { + duplicates.push(id); + } + } + + const catalogSet = + catalogIds instanceof Set ? catalogIds : new Set(catalogIds); + + const missingFromMatrix = []; + + for (const id of catalogSet) { + if (!counts.has(id)) { + missingFromMatrix.push(id); + } } - const duplicates = [...seen.entries()].filter(([, count]) => count > 1).map(([id]) => id); - const present = new Set(seen.keys()); - const missingFromMatrix = [...catalogIds].filter((id) => !present.has(id)); - return { missingFromMatrix, duplicates }; + + return { + missingFromMatrix, + duplicates, + totalRows: rows.length, + uniqueRows: counts.size, + }; }