#!/usr/bin/env node // tools/save-format-doc/scripts/lint-save-formats.mjs // Source: Phase 3 D-22 (SDOC-03 lint). // Validates docs/extracted-server/save-formats.json schema + AUTOGEN block presence. // Usage: node lint-save-formats.mjs // Exit: 0 success, 1 failure, 2 usage error. import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; function printUsage() { process.stderr.write('Usage: node lint-save-formats.mjs \n'); process.stderr.write( ' docs-dir: path to docs/extracted-server (containing save-formats.json + save-formats.md)\n', ); } const arg = process.argv[2]; if (arg === '--help' || arg === '-h') { printUsage(); process.exit(0); } if (!arg) { printUsage(); process.exit(2); } const sfPath = join(arg, 'save-formats.json'); if (!existsSync(sfPath)) { process.stderr.write(`save-formats.json not found at ${sfPath}\n`); process.exit(1); } let table; try { table = JSON.parse(readFileSync(sfPath, 'utf-8')); } catch (e) { process.stderr.write(`save-formats.json: invalid JSON: ${e.message}\n`); process.exit(1); } const VALID_EXT = new Set(['.bno', '.bnb', '.bnu', '.txt']); const VALID_ENC = new Set(['windows-1252', 'utf-8']); const VALID_KIND = new Set(['flat', 'section_marker', 'loop', 'nested_loop']); const VALID_TYPE = new Set(['string', 'real']); // CLI-08 first-login restore needs these patterns to be present (D-22 mvp-coverage). const MVP_FILENAMES_REQUIRED = [ 'User_DBUpdated', 'UserData/HXB/Bridges_', 'UserData/Inv/Inventory_', ]; let errors = 0; if (!Array.isArray(table.formats)) { process.stderr.write('save-formats.json: missing formats[] array\n'); process.exit(1); } for (const f of table.formats) { const tag = `format ${f.extension}/${f.filename_pattern || '?'}`; if (!VALID_EXT.has(f.extension)) { process.stderr.write( `${tag}: extension must be one of ${[...VALID_EXT].join(',')}\n`, ); errors++; } if (typeof f.filename_pattern !== 'string' || !f.filename_pattern) { process.stderr.write(`${tag}: filename_pattern required\n`); errors++; } // load_script required (D-22) if ( typeof f.load_script !== 'string' || !/\.gml(:\d+)?$/.test(f.load_script) ) { process.stderr.write( `${tag}: load_script must be a 'NNNN-name.gml[:LINE]' citation (D-22)\n`, ); errors++; } // save_script null OR a valid citation; archived/load-only allowed null. if ( f.save_script !== null && (typeof f.save_script !== 'string' || !/\.gml(:\d+)?$/.test(f.save_script)) ) { process.stderr.write( `${tag}: save_script must be null or 'NNNN-name.gml[:LINE]' (D-22)\n`, ); errors++; } // D-22 strict: most formats must have BOTH load+save (only archived/load-only // may have null save_script). if ( f.save_script === null && f.archived !== true && !/localList\.txt|User_DB_Superweird|User_DBUpdated/.test( f.filename_pattern || '', ) ) { process.stderr.write( `${tag}: save_script is null but format is not archived; D-22 requires both citations unless archived:true\n`, ); errors++; } if (typeof f.mvp !== 'boolean') { process.stderr.write(`${tag}: mvp must be boolean\n`); errors++; } if (!VALID_ENC.has(f.encoding)) { process.stderr.write( `${tag}: encoding must be windows-1252 or utf-8 (D-08 + Pitfall 6)\n`, ); errors++; } if (typeof f.archived !== 'boolean') { process.stderr.write(`${tag}: archived must be boolean\n`); errors++; } // grammar shape if (!Array.isArray(f.grammar)) { process.stderr.write(`${tag}: grammar must be array\n`); errors++; } else { walkGrammar(f.grammar, tag, msg => { process.stderr.write(`${tag}: ${msg}\n`); errors++; }); } // gml_origin >=1 with valid script/line/snippet shape (D-22). if (!Array.isArray(f.gml_origin) || f.gml_origin.length === 0) { process.stderr.write(`${tag}: gml_origin must have >=1 entry (D-22)\n`); errors++; } else { for (const o of f.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. 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++; } } } // sample_records >=1 unless archived without sample. if (!Array.isArray(f.sample_records)) { process.stderr.write(`${tag}: sample_records must be array\n`); errors++; } // PII guard: no obviously-leaked plaintext in sample_records. const sStr = JSON.stringify(f.sample_records || []); if ( /(jarhead111|harrypotter|ilovepizza|"password"\s*:\s*"(?! (f.filename_pattern || '').includes(required)) ) { process.stderr.write( `save-formats.json: missing required MVP filename pattern '${required}' (D-22 mvp-coverage)\n`, ); errors++; } } // AUTOGEN block presence in save-formats.md. const mdPath = join(arg, 'save-formats.md'); if (existsSync(mdPath)) { const md = readFileSync(mdPath, 'utf-8'); for (const blk of [ 'save-formats-bnu', 'save-formats-bnb', 'save-formats-bno', 'mvp-save-formats', ]) { if (!new RegExp(``).test(md)) { process.stderr.write(`save-formats.md: missing AUTOGEN:${blk}:start\n`); errors++; } if (!new RegExp(``).test(md)) { process.stderr.write(`save-formats.md: missing AUTOGEN:${blk}:end\n`); errors++; } } } else { process.stderr.write(`save-formats.md not found at ${mdPath}\n`); errors++; } if (errors > 0) { process.stderr.write(`lint-save-formats: ${errors} error(s)\n`); process.exit(1); } process.stdout.write( `lint-save-formats: OK (${table.formats.length} formats validated)\n`, ); process.exit(0);