Files
ttrpg-initiative-tracker/shared/tests/static.no-unlogged.test.js
T
david raistrick f2797595f4 test: static guard — every mutation logged
Per-op logging test encoded gaps as expected (asserted log:null = pass).
Missed 4 real bugs (BUG-7 + deathSave + addParticipants + updateParticipant).
Bunk. Static scan catches regressions regardless of test enumeration.

static.no-unlogged.test.js: scans turn.js source, extracts function bodies
via brace counting, finds any return with patch!=null AND log:null.
Verified: injected fake unlogged mutation in endEncounter → guard fails
with offender name + return expr. Restored → 128 green.

Logging contract now enforced structurally, not by enumeration.
2026-07-04 17:26:59 -04:00

99 lines
3.2 KiB
JavaScript

// 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([]);
});
});