#!/usr/bin/env node // tools/asset-catalog/scripts/lint-matrix.mjs // Source: Plan 02-06 task 1. // // Validates docs/extracted-engine/MATRIX-rows.json schema + verifies that the // rendered totals in MATRIX.md match the JSON-computed totals (Pitfall 5 // mitigation per RESEARCH §445-453). // // Usage: node tools/asset-catalog/scripts/lint-matrix.mjs // Exit codes: 0 success, 1 schema/totals failure, 2 usage error. import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; function printUsage() { process.stderr.write('Usage: lint-matrix.mjs \n'); process.stderr.write( ' docs-dir: path to docs/extracted-engine (containing MATRIX-rows.json + MATRIX.md)\n', ); } const arg = process.argv[2]; if (arg === '--help' || arg === '-h') { printUsage(); process.exit(0); } if (!arg) { printUsage(); process.exit(2); } const rowsPath = join(arg, 'MATRIX-rows.json'); if (!existsSync(rowsPath)) { process.stderr.write(`MATRIX-rows.json not found at ${rowsPath}\n`); process.exit(1); } let rows; try { rows = JSON.parse(readFileSync(rowsPath, 'utf-8')); } catch (e) { process.stderr.write(`MATRIX-rows.json: invalid JSON: ${e.message}\n`); process.exit(1); } if (!Array.isArray(rows)) { process.stderr.write(`MATRIX-rows.json: top-level must be an array\n`); process.exit(1); } const REQUIRED_ENGINES = ['phaser-3.90', 'phaser-4.1', 'pixi-8.18']; const VALID_GRADES = new Set(['native', 'plugin', 'manual', 'hard']); const VALID_BNO_USAGE = new Set(['heavy', 'light', 'none']); const ROW_ID_RE = /^MX-[A-Z]+-\d+$/; const seenIds = new Set(); let errors = 0; for (let i = 0; i < rows.length; i++) { const r = rows[i]; const where = `row ${i} (${r?.rowId ?? '?'})`; if (!r || typeof r !== 'object') { process.stderr.write(`${where}: row must be an object\n`); errors++; continue; } if (typeof r.rowId !== 'string' || !ROW_ID_RE.test(r.rowId)) { process.stderr.write( `${where}: invalid rowId (must match /^MX-[A-Z]+-\\d+$/); got ${JSON.stringify(r.rowId)}\n`, ); errors++; } if (typeof r.rowId === 'string') { if (seenIds.has(r.rowId)) { process.stderr.write(`${where}: duplicate rowId\n`); errors++; } seenIds.add(r.rowId); } if (typeof r.subsystem !== 'string' || r.subsystem.length === 0) { process.stderr.write(`${where}: missing or empty subsystem\n`); errors++; } if (typeof r.feature !== 'string' || r.feature.length === 0) { process.stderr.write(`${where}: missing or empty feature\n`); errors++; } if (!VALID_BNO_USAGE.has(r.bnoUsage)) { process.stderr.write( `${where}: invalid bnoUsage; must be one of ${[...VALID_BNO_USAGE].join('/')}; got ${JSON.stringify(r.bnoUsage)}\n`, ); errors++; } if (typeof r.weight !== 'number' || r.weight < 1 || r.weight > 5) { process.stderr.write( `${where}: weight must be a number 1..5; got ${JSON.stringify(r.weight)}\n`, ); errors++; } if (typeof r.cite !== 'string' || r.cite.length === 0) { process.stderr.write(`${where}: missing or empty cite\n`); errors++; } if (!r.scores || typeof r.scores !== 'object') { process.stderr.write(`${where}: missing scores object\n`); errors++; continue; } for (const eng of REQUIRED_ENGINES) { const s = r.scores[eng]; if (!s || typeof s !== 'object') { process.stderr.write(`${where}: scores.${eng} missing\n`); errors++; continue; } if (!VALID_GRADES.has(s.grade)) { process.stderr.write( `${where}: scores.${eng}.grade must be one of ${[...VALID_GRADES].join('/')}; got ${JSON.stringify(s.grade)}\n`, ); errors++; } if (typeof s.note !== 'string') { process.stderr.write(`${where}: scores.${eng}.note must be a string\n`); errors++; } } } if (rows.length < 18) { process.stderr.write( `MATRIX-rows.json: expected ≥ 18 rows, found ${rows.length}\n`, ); errors++; } // Pitfall 5: re-compute totals from JSON and verify they match what's rendered // in the matrix-totals AUTOGEN block of MATRIX.md. const matrixMdPath = join(arg, 'MATRIX.md'); if (existsSync(matrixMdPath)) { const md = readFileSync(matrixMdPath, 'utf-8'); const totalsBlockRe = /\s*([\s\S]*?)/; const m = md.match(totalsBlockRe); if (!m) { process.stderr.write( `MATRIX.md: missing AUTOGEN:matrix-totals block (run pnpm catalog:client)\n`, ); errors++; } else { const block = m[1]; const gradeToScore = g => ({ native: 4, plugin: 3, manual: 2, hard: 1 })[g] ?? 0; for (const eng of REQUIRED_ENGINES) { const expected = rows.reduce( (s, r) => s + r.weight * gradeToScore(r.scores?.[eng]?.grade ?? 'manual'), 0, ); // Match `| [ ✓] | |`. Engine names contain dots (e.g. // "phaser-3.90") so we escape them for the regex. const rowRe = new RegExp( `\\|\\s*${eng.replace(/\./g, '\\.')}[^|]*\\|\\s*(\\d+)\\s*\\|`, ); const rowM = block.match(rowRe); if (!rowM) { process.stderr.write( `MATRIX.md: missing total row for ${eng} in matrix-totals block\n`, ); errors++; } else if (Number(rowM[1]) !== expected) { process.stderr.write( `MATRIX.md: ${eng} total ${rowM[1]} does not match JSON-computed ${expected} ` + `(Pitfall 5 — re-run \`pnpm catalog:client\`)\n`, ); errors++; } } } } if (errors > 0) { process.stderr.write(`\nlint-matrix: ${errors} error(s)\n`); process.exit(1); } process.stdout.write( `OK: MATRIX-rows.json (${rows.length} rows) + MATRIX.md totals validated\n`, ); process.exit(0);