fix(turn): slot-not-sort in add/update; static guard against stray .sort()
addParticipant + updateParticipant used sortParticipantsByInitiative (stable sort, tie-break = original array index). That destroyed manually-dragged tie order when a drag moved a same-init pair — violated the slot-not-sort design (docs/INITIATIVE_ORDERING.md: 'Re-slotting on add/edit must preserve drag-established tie order'). Fix: new slotIndexForInit(list, init) returns splice index into the CURRENT list (initiative-descending). addParticipant inserts there; updateParticipant re-inserts only when initiative changed (unrelated edits keep slot — avoids mid-round rotation dupes surfaced by the 100-round combat test). Tests: - turn.slot-not-sort.test.js: drag tie order survives add/edit (4 cases). - static.no-sort.test.js: errs if .sort( added outside allowlist (sortParticipantsByInitiative only). Verified by injecting a stray sort — guard caught it.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// STATIC GUARD: no `.sort(` introduced in shared/turn.js outside the ONE
|
||||
// allowed function (sortParticipantsByInitiative, used by startEncounter to
|
||||
// freeze the list once). Slot-not-sort design (docs/INITIATIVE_ORDERING.md):
|
||||
// mutations = insert/move, never wholesale re-sort. A stray `.sort(` after
|
||||
// start destroys drag tie-break order.
|
||||
//
|
||||
// This test errs the moment someone adds `.sort(` anywhere but the allowlist.
|
||||
// Maintenance: add a function to ALLOWED only if it runs at startEncounter
|
||||
// (one-time freeze), NOT in add/update/reorder/nextTurn paths.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
|
||||
|
||||
// Functions permitted to call .sort(. Listed so intent is explicit + reviewed.
|
||||
const ALLOWED_SORT_FUNCTIONS = new Set([
|
||||
'sortParticipantsByInitiative',
|
||||
]);
|
||||
|
||||
// Collect top-level function bodies by brace counting.
|
||||
// Matches `function NAME(` decls AND `const NAME = ... =>` arrow fns.
|
||||
// Returns [{ name, body }].
|
||||
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;
|
||||
}
|
||||
|
||||
describe('STATIC: no .sort( outside allowlist (slot-not-sort design)', () => {
|
||||
test('every .sort( call lives in an allowed function', () => {
|
||||
const fns = extractFunctions(SRC);
|
||||
const offenders = [];
|
||||
for (const { name, body } of fns) {
|
||||
if (!ALLOWED_SORT_FUNCTIONS.has(name) && /\.sort\(/.test(body)) {
|
||||
offenders.push(name);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('allowed sort function is declared (no silent allowlist drift)', () => {
|
||||
const declared = new Set(extractFunctions(SRC).map(f => f.name));
|
||||
for (const name of ALLOWED_SORT_FUNCTIONS) {
|
||||
expect(declared.has(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user