#!/usr/bin/env node // tools/asset-catalog/scripts/lint-parity-checklist.mjs // Source: Phase 3 D-22 (SDOC-05 lint). // // Validates docs/extracted-server/parity-checklist.json schema + // cross-resolution against protocol.json + save-formats.json + // SUBSYSTEM-MAP.json. // // Schema invariants enforced (per Plan 03-05 + RESEARCH §Open Question 5): // - Every row has non-empty originating_gml[] (D-22). // - Every originating_opcodes[] entry resolves to an opcode_byte in // protocol.json (cross-resolution). // - Every originating_save_fields[] entry's filename matches a // filename_pattern in save-formats.json. // - Every row's subsystem ∈ {9 D-15 keys} (excludes the synthetic `misc` // partition from SUBSYSTEM-MAP.json — `misc` is for catalog partitioning // only, not parity-feature classification). // - Every row's disposition ∈ {in-phase-6, in-phase-7, deferred-stage-8, // rejected-with-reason}. // - Every disposition='rejected-with-reason' row has non-empty reason. // - Every originating_gml entry ends in `.gml` or `.gml:LINE` (path-shape // guard) and does NOT reference `legacy/servers/` (D-03 source-of-truth // lock — only legacy/open-source-release/ + extracted/server-5-4/ may be // cited). // - mvp:true rows are exactly 7 with locked feature names per CLI-08 slice // (movement, chat-public, login, login-response, room-join, room-leave, // heartbeat). // - Feature keys are unique across all rows. // // D-22 reporting requirement: aggregate disposition counts logged to stdout // even on success. // // Usage: node tools/asset-catalog/scripts/lint-parity-checklist.mjs // Exit: 0 success, 1 schema/cross-ref failure, 2 usage error. import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const arg = process.argv[2]; if (arg === '--help' || arg === '-h') { process.stdout.write( 'Usage: node lint-parity-checklist.mjs \n' + ' docs-dir: path to docs/extracted-server (containing parity-checklist.json)\n', ); process.exit(0); } if (!arg) { process.stderr.write('Usage: node lint-parity-checklist.mjs \n'); process.exit(2); } const checklistPath = join(arg, 'parity-checklist.json'); const protoPath = join(arg, 'protocol.json'); const sfPath = join(arg, 'save-formats.json'); const mapPath = join(arg, 'SUBSYSTEM-MAP.json'); for (const p of [checklistPath, protoPath, sfPath, mapPath]) { if (!existsSync(p)) { process.stderr.write(`missing ${p}\n`); process.exit(1); } } let checklist, proto, sf, map; try { checklist = JSON.parse(readFileSync(checklistPath, 'utf-8')); proto = JSON.parse(readFileSync(protoPath, 'utf-8')); sf = JSON.parse(readFileSync(sfPath, 'utf-8')); map = JSON.parse(readFileSync(mapPath, 'utf-8')); } catch (e) { process.stderr.write(`invalid JSON: ${e.message}\n`); process.exit(1); } const VALID_DISP = new Set([ 'in-phase-6', 'in-phase-7', 'deferred-stage-8', 'rejected-with-reason', ]); // Per Plan 03-05 + RESEARCH §Open Question 5: parity-checklist subsystem field // references the 9 D-15 narrative subsystems. SUBSYSTEM-MAP.json carries an // extra synthetic `misc` partition key for catalog ID-bucketing (Plan 03-04 // note in 03-04-SUMMARY); `misc` is NOT a valid parity-row subsystem because // every parity feature lives in a real subsystem narrative. const VALID_SUBSYS = new Set( Object.keys(map).filter(k => k !== 'misc'), ); const opcodeBytes = new Set(proto.opcodes.map(o => o.opcode_byte)); const filenamePatterns = new Set(sf.formats.map(f => f.filename_pattern)); // MVP coverage lock per D-18 / CLI-08 slice (Phase 6 hard milestone). const MVP_REQUIRED = new Set([ 'movement', 'chat-public', 'login', 'login-response', 'room-join', 'room-leave', 'heartbeat', ]); let errors = 0; if (!Array.isArray(checklist.rows)) { process.stderr.write('parity-checklist.json: missing rows[] array\n'); process.exit(1); } const counts = { 'in-phase-6': 0, 'in-phase-7': 0, 'deferred-stage-8': 0, 'rejected-with-reason': 0, }; const featureSet = new Set(); for (const r of checklist.rows) { const tag = `row '${r.feature || '?'}'`; if (typeof r.feature !== 'string' || !r.feature) { process.stderr.write(`${tag}: feature required\n`); errors++; } else if (featureSet.has(r.feature)) { process.stderr.write(`${tag}: duplicate feature key\n`); errors++; } else { featureSet.add(r.feature); } if (typeof r.subsystem !== 'string' || !VALID_SUBSYS.has(r.subsystem)) { process.stderr.write( `${tag}: subsystem '${r.subsystem}' must be one of ${[...VALID_SUBSYS].sort().join(',')}\n`, ); errors++; } if (!Array.isArray(r.originating_gml) || r.originating_gml.length === 0) { process.stderr.write( `${tag}: originating_gml must be non-empty array (D-22)\n`, ); errors++; } else { for (const g of r.originating_gml) { if (typeof g !== 'string') { process.stderr.write(`${tag}: originating_gml entry must be string\n`); errors++; continue; } // Allow `.gml` paths, `.gml:LINE` line-citations, and object directories // (e.g. `extracted/server-5-4/objects/0123-roomchangeob`) — objects are // legal source citations because they own GML event handlers. const isGml = /\.gml(:\d+)?$/.test(g); const isObjectDir = /\/objects\/[^/]+$/.test(g); if (!isGml && !isObjectDir) { process.stderr.write( `${tag}: originating_gml entry '${g}' must end in .gml, .gml:LINE, or be an objects/ directory citation\n`, ); errors++; } // D-03 source-of-truth lock: parity-checklist may only cite // legacy/open-source-release/ (the two .gmd files) or extracted/ // (the per-resource trees derived from those .gmd files). Older // legacy/servers/ snapshots are reference-only. if (/legacy[\\/]servers/.test(g)) { process.stderr.write( `${tag}: originating_gml '${g}' references legacy/servers (D-03 forbids — extract/ tree only)\n`, ); errors++; } } } if (!Array.isArray(r.originating_opcodes)) { process.stderr.write(`${tag}: originating_opcodes must be array\n`); errors++; } else { for (const op of r.originating_opcodes) { if (typeof op !== 'number' || !opcodeBytes.has(op)) { process.stderr.write( `${tag}: originating_opcode ${op} not present in protocol.json\n`, ); errors++; } } } if (!Array.isArray(r.originating_save_fields)) { process.stderr.write(`${tag}: originating_save_fields must be array\n`); errors++; } else { for (const sfRef of r.originating_save_fields) { const m = String(sfRef).match(/^([^#]+)#/); if (!m || !filenamePatterns.has(m[1])) { process.stderr.write( `${tag}: originating_save_field '${sfRef}' filename '${m?.[1] ?? '?'}' not in save-formats.json\n`, ); errors++; } } } if (typeof r.mvp !== 'boolean') { process.stderr.write(`${tag}: mvp must be boolean\n`); errors++; } if (!VALID_DISP.has(r.disposition)) { process.stderr.write(`${tag}: disposition '${r.disposition}' invalid\n`); errors++; } if ( r.disposition === 'rejected-with-reason' && (typeof r.reason !== 'string' || !r.reason.length) ) { process.stderr.write( `${tag}: disposition=rejected-with-reason requires non-empty reason\n`, ); errors++; } if ( r.modernized_replacement !== null && typeof r.modernized_replacement !== 'string' ) { process.stderr.write( `${tag}: modernized_replacement must be string or null\n`, ); errors++; } if (VALID_DISP.has(r.disposition)) counts[r.disposition]++; } // MVP coverage lock — exactly 7 mvp:true rows with locked feature names. const mvpRows = checklist.rows .filter(r => r.mvp === true) .map(r => r.feature); for (const req of MVP_REQUIRED) { if (!mvpRows.includes(req)) { process.stderr.write( `MVP coverage gap: '${req}' must be present as a mvp:true row\n`, ); errors++; } } if (mvpRows.length !== 7) { process.stderr.write( `MVP row count = ${mvpRows.length}; expected 7 (CLI-08 slice)\n`, ); errors++; } // inputs.{protocol,save_formats}_sha256 sanity — log a non-blocking warning if // the recorded hashes don't match the live files (T-3-05-06 mitigation: // drift visible but non-blocking; future plans 02/03 re-runs will re-record). import('node:crypto').then(({ createHash }) => { const livePr = createHash('sha256') .update(readFileSync(protoPath)) .digest('hex'); const liveSf = createHash('sha256') .update(readFileSync(sfPath)) .digest('hex'); if ( checklist.inputs && typeof checklist.inputs.protocol_sha256 === 'string' && checklist.inputs.protocol_sha256 !== livePr ) { process.stderr.write( `WARN: parity-checklist.inputs.protocol_sha256 stale — expected ${livePr}, got ${checklist.inputs.protocol_sha256}\n`, ); } if ( checklist.inputs && typeof checklist.inputs.save_formats_sha256 === 'string' && checklist.inputs.save_formats_sha256 !== liveSf ) { process.stderr.write( `WARN: parity-checklist.inputs.save_formats_sha256 stale — expected ${liveSf}, got ${checklist.inputs.save_formats_sha256}\n`, ); } // Aggregate disposition log (D-22 reporting requirement). process.stdout.write( `Parity disposition counts: ${JSON.stringify(counts)}\n`, ); if (errors > 0) { process.stderr.write(`lint-parity-checklist: ${errors} error(s)\n`); process.exit(1); } process.stdout.write( `lint-parity-checklist: OK (${checklist.rows.length} rows validated)\n`, ); process.exit(0); });