diff --git a/shared/tests/static.no-unlogged.test.js b/shared/tests/static.no-unlogged.test.js new file mode 100644 index 0000000..a55aecb --- /dev/null +++ b/shared/tests/static.no-unlogged.test.js @@ -0,0 +1,98 @@ +// STATIC GUARD: every mutation logged. Scans shared/turn.js source. +// Invariant: any return with patch != null MUST also have log != null. +// return { patch: {...}, log: {...} } — OK (logged mutation) +// return { patch: null, log: null } — OK (no-op) +// return { patch: {...}, log: null } — FAIL (unlogged mutation = BUG-7 class) +// +// BUG-7 history: reorderParticipants + deathSave + addParticipants + +// updateParticipant all returned log:null with real patches. Per-op test +// missed them because gaps section asserted null as "expected." Static scan +// catches any future regression regardless of test enumeration. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8'); + +// Extract function name + body via brace counting. +function collectBody(src, fromIdx) { + let i = fromIdx; + while (i < src.length && src[i] !== '{') i++; + let depth = 0; + const start = i; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) break; } + } + return src.slice(start, i + 1); +} + +function extractFunctions(src) { + const out = []; + const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g; + const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g; + let m; + while ((m = fnRe.exec(src)) !== null) { + out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) }); + } + while ((m = arrowRe.exec(src)) !== null) { + out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) }); + } + return out; +} + +// Split body into return statements at top level of the function. +// Returns array of return-expr strings. +function extractReturns(body) { + const returns = []; + // find `return ` at depth 1 (inside fn body) + let i = 0; + // body starts with `{`. Skip into depth 1. + while (i < body.length && body[i] !== '{') i++; + i++; // past { + let depth = 1; + let start = -1; + for (; i < body.length; i++) { + const ch = body[i]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + if (depth === 1 && body.slice(i, i + 7) === 'return ') { + start = i + 7; + } + if (depth === 1 && ch === ';' && start !== -1) { + returns.push(body.slice(start, i)); + start = -1; + } + } + return returns; +} + +// For a return expr, detect: has `log: null` AND has `patch:` that is NOT null. +// Returns true if suspicious (unlogged mutation). +function isUnloggedMutation(retExpr) { + if (!/log:\s*null/.test(retExpr)) return false; + // patch: null → no-op, OK + if (/patch:\s*null/.test(retExpr)) return false; + // patch: { ... } or patch: someVar → mutation with null log = BAD + if (/patch:/.test(retExpr)) return true; + return false; +} + +describe('STATIC: every mutation logged (logging contract)', () => { + const fns = extractFunctions(SRC); + + test('no function returns patch!=null with log:null', () => { + const offenders = []; + for (const { name, body } of fns) { + const rets = extractReturns(body); + for (const r of rets) { + if (isUnloggedMutation(r)) { + offenders.push(`${name}: return ${r.trim().slice(0, 80)}`); + } + } + } + expect(offenders).toEqual([]); + }); +});