#!/usr/bin/env node // tools/protocol-doc/scripts/lint-protocol.mjs // Source: Phase 3 D-22 (SDOC-02 lint). // // Validates docs/extracted-server/protocol.json schema + cross-checks rendered // AUTOGEN blocks in protocol.md match JSON-computed tables. // // Usage: node tools/protocol-doc/scripts/lint-protocol.mjs // Exit: 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: node lint-protocol.mjs \n'); process.stderr.write( ' docs-dir: path to docs/extracted-server (containing protocol.json + protocol.md)\n', ); } const arg = process.argv[2]; if (arg === '--help' || arg === '-h') { printUsage(); process.exit(0); } if (!arg) { printUsage(); process.exit(2); } const protoPath = join(arg, 'protocol.json'); if (!existsSync(protoPath)) { process.stderr.write(`protocol.json not found at ${protoPath}\n`); process.exit(1); } let table; try { table = JSON.parse(readFileSync(protoPath, 'utf-8')); } catch (e) { process.stderr.write(`protocol.json: invalid JSON: ${e.message}\n`); process.exit(1); } // CLI-08 MVP names — every row with mvp:true MUST be in this set. const MVP_NAMES = new Set([ 'movement', 'chat', 'login', 'login-response', 'room-join', 'room-leave', 'heartbeat', ]); let errors = 0; if (!table.opcodes || !Array.isArray(table.opcodes)) { process.stderr.write('protocol.json: missing opcodes[] array\n'); process.exit(1); } for (const row of table.opcodes) { const tag = `opcode ${row.opcode_byte}/${row.direction || '?'}`; // opcode_byte 0..255 (or -1 for runtime-variable byte the scanner could not // resolve — emitter currently drops these but the lint is permissive). if ( typeof row.opcode_byte !== 'number' || row.opcode_byte < -1 || row.opcode_byte > 255 ) { process.stderr.write( `${tag}: opcode_byte out of range (0..255 or -1 for var-byte)\n`, ); errors++; } // direction if (row.direction !== 'c2s' && row.direction !== 's2c') { process.stderr.write(`${tag}: direction must be 'c2s' or 's2c'\n`); errors++; } // mvp boolean if (typeof row.mvp !== 'boolean') { process.stderr.write(`${tag}: mvp must be boolean\n`); errors++; } // name non-empty if (typeof row.name !== 'string' || row.name.length === 0) { process.stderr.write(`${tag}: name must be non-empty string\n`); errors++; } // gml_origin >= 1 with valid script/line/snippet shape if (!Array.isArray(row.gml_origin) || row.gml_origin.length === 0) { process.stderr.write(`${tag}: gml_origin must have >=1 entry (D-22)\n`); errors++; } else { for (const o of row.gml_origin) { if (typeof o.script !== 'string' || !o.script.endsWith('.gml')) { process.stderr.write( `${tag}: gml_origin.script must end in .gml; got ${o.script}\n`, ); errors++; } if (typeof o.line !== 'number' || o.line < 1) { process.stderr.write( `${tag}: gml_origin.line must be 1-indexed positive int\n`, ); errors++; } if (typeof o.snippet !== 'string' || o.snippet.length === 0) { process.stderr.write(`${tag}: gml_origin.snippet required\n`); errors++; } // D-03 lock: extraction must come from extracted/server-5-4/ only // (no legacy/servers paths). if (typeof o.script === 'string' && /legacy[\\/]servers/.test(o.script)) { process.stderr.write( `${tag}: gml_origin.script references legacy/servers (D-03 forbids older-snapshot extraction)\n`, ); errors++; } } } // Either fields or (discriminator+variants); not both, not neither. const hasFlat = Array.isArray(row.fields); const hasDU = !!(row.discriminator && row.variants); if (!hasFlat && !hasDU) { process.stderr.write( `${tag}: must have either fields[] or discriminator+variants\n`, ); errors++; } if (hasFlat && hasDU) { process.stderr.write( `${tag}: cannot have both fields[] and discriminator+variants\n`, ); errors++; } // Flat fields: name + byte_size shape. if (hasFlat) { for (const f of row.fields) { if (typeof f.name !== 'string' || f.name.length === 0) { process.stderr.write(`${tag}: field missing name\n`); errors++; } if (typeof f.byte_size !== 'number') { process.stderr.write(`${tag}: field.byte_size must be number\n`); errors++; } } } // Discriminated-union shape: variants is a non-empty record + each variant // carries a fields[] array. if (hasDU) { if ( typeof row.discriminator.field !== 'string' || row.discriminator.field.length === 0 ) { process.stderr.write(`${tag}: discriminator.field must be non-empty string\n`); errors++; } const variantNames = Object.keys(row.variants); if (variantNames.length === 0) { process.stderr.write(`${tag}: variants must have >=1 entry\n`); errors++; } for (const vn of variantNames) { const v = row.variants[vn]; if (!v || !Array.isArray(v.fields)) { process.stderr.write(`${tag}: variants.${vn}.fields must be array\n`); errors++; } } } // sample_bytes hex string if (typeof row.sample_bytes !== 'string') { process.stderr.write(`${tag}: sample_bytes must be string (hex test vector)\n`); errors++; } // mvp:true → name MUST be in CLI-08 list (D-22). if (row.mvp === true && !MVP_NAMES.has(row.name)) { process.stderr.write( `${tag}: mvp:true but name '${row.name}' not in CLI-08 list ` + `(movement|chat|login|login-response|room-join|room-leave|heartbeat) — D-22\n`, ); errors++; } } // Cross-check rendered AUTOGEN blocks in protocol.md vs JSON-computed tables. const mdPath = join(arg, 'protocol.md'); if (existsSync(mdPath)) { const md = readFileSync(mdPath, 'utf-8'); for (const blockName of ['opcodes-c2s', 'opcodes-s2c', 'mvp-opcodes']) { const startRe = new RegExp(``); const endRe = new RegExp(``); if (!startRe.test(md)) { process.stderr.write(`protocol.md: missing AUTOGEN:${blockName}:start\n`); errors++; } if (!endRe.test(md)) { process.stderr.write(`protocol.md: missing AUTOGEN:${blockName}:end\n`); errors++; } } } else { process.stderr.write(`protocol.md not found at ${mdPath}\n`); errors++; } if (errors > 0) { process.stderr.write( `lint-protocol: ${errors} error(s). Re-run pnpm protocol-doc:catalog if drift; otherwise fix the source.\n`, ); process.exit(1); } process.stdout.write( `lint-protocol: OK (${table.opcodes.length} opcodes validated)\n`, ); process.exit(0);