84231a1470
Доска «кто на посту» (STATUS.md §7) теперь показывает реальные недавние escape владельца и блоки машин М1–М6 вместо хардкода []/[]. - new tools/guard-block-log.mjs: logGuardBlock (best-effort, fail-quiet, Node fs append в guard-blocks-<sess>.jsonl) + loadRecentBlocks/ loadRecentEscapes (скан session-файлов runtime, окно 24ч + cap 10, ts→ISO). - проводка logGuardBlock в block-ветку main() 9 машинных хуков (floor / supreme-gate / judge-gate / snapshot / read-path-deny / mcp-classification / normative-content-rules / verify-gate / criterion-gate). Логгер вызывается ПОСЛЕ решения, не влияет на block; decide() pure не тронут. - status-md-generator CLI: recentEscapes/recentBlocks из читателей вместо []/[]. До флипа Фазы 8 доска показывает 0/0 (хуки не зарегистрированы — данных нет); реальная польза — пост-флип наблюдаемость. TDD: guard-block-log.test (6) + 9 структурных wiring-тестов + 1 board-тест. Гейт закрытия: sharp-edges (промежуточный по 9 хукам + читатели) + variant-analysis (все block-ветки покрыты, иных источников нет). Регрессия tools-only 3465 passed / 2 skipped / 0 failed (было 3449+2skip). 0 регрессий. Plan: docs/superpowers/plans/2026-06-10-guard-board-live-source.md
77 lines
4.5 KiB
JavaScript
77 lines
4.5 KiB
JavaScript
// tools/guard-block-log.test.mjs
|
||
import { describe, it, expect } from 'vitest';
|
||
import { join } from 'node:path';
|
||
import { buildGuardBlockEntry, logGuardBlock, loadRecentBlocks, loadRecentEscapes } from './guard-block-log.mjs';
|
||
|
||
// OS-agnostic memFs: нормализует \\→/ (node:path.join на Windows даёт backslash; сиды — forward slash).
|
||
function memFs(seed = {}) {
|
||
const norm = (p) => String(p).replace(/\\/g, '/');
|
||
const s = new Map(Object.entries(seed).map(([k, v]) => [norm(k), v]));
|
||
return { s,
|
||
existsSync: (p) => s.has(norm(p)),
|
||
readdirSync: (d) => { const nd = norm(d); return [...s.keys()].filter((k) => k.startsWith(nd + '/')).map((k) => k.slice(nd.length + 1)); },
|
||
readFileSync: (p) => { const np = norm(p); if (!s.has(np)) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(np); },
|
||
appendFileSync: (p, d) => { const np = norm(p); s.set(np, (s.get(np) || '') + d); },
|
||
mkdirSync: () => {} };
|
||
}
|
||
const DIR = '/rt';
|
||
|
||
describe('buildGuardBlockEntry (pure)', () => {
|
||
it('собирает {ts,machine,action,reason}', () => {
|
||
const e = buildGuardBlockEntry({ machine: 'М5 Пол', action: 'bash:git push --force', reason: 'необратимое', now: 1000 });
|
||
expect(e).toEqual({ ts: 1000, machine: 'М5 Пол', action: 'bash:git push --force', reason: 'необратимое' });
|
||
});
|
||
});
|
||
|
||
describe('logGuardBlock (fail-quiet append)', () => {
|
||
it('пишет строку в guard-blocks-<sess>.jsonl, action из canonicalAction', () => {
|
||
const fs = memFs();
|
||
logGuardBlock({ tool_name: 'Bash', tool_input: { command: 'git push --force' }, session_id: 's1' },
|
||
'М5 Пол', 'необратимое', { fsImpl: fs, runtimeDir: DIR, now: 5 });
|
||
const raw = fs.s.get(join(DIR, 'guard-blocks-s1.jsonl').replace(/\\/g, '/'));
|
||
const rec = JSON.parse(raw.trim());
|
||
expect(rec.machine).toBe('М5 Пол');
|
||
expect(rec.reason).toBe('необратимое');
|
||
expect(rec.action).toBe('bash:git push --force'); // нормализовано
|
||
expect(rec.ts).toBe(5);
|
||
});
|
||
it('никогда не бросает (битый fs / битый event)', () => {
|
||
const throwFs = { appendFileSync: () => { throw new Error('disk'); }, mkdirSync: () => {} };
|
||
expect(() => logGuardBlock(null, 'X', 'y', { fsImpl: throwFs, runtimeDir: DIR, now: 1 })).not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe('loadRecentBlocks (скан session-файлов, окно+сорт+cap)', () => {
|
||
it('собирает из всех guard-blocks-*.jsonl, окно, сорт desc, cap, ts→ISO', () => {
|
||
const fs = memFs({
|
||
[join(DIR, 'guard-blocks-a.jsonl')]: JSON.stringify({ ts: 100, machine: 'М5 Пол', action: 'bash:rm', reason: 'r1' }) + '\n',
|
||
[join(DIR, 'guard-blocks-b.jsonl')]: JSON.stringify({ ts: 300, machine: 'М2 Стена', action: 'write:x', reason: 'r2' }) + '\n'
|
||
+ JSON.stringify({ ts: 50, machine: 'М2 Стена', action: 'write:y', reason: 'old' }) + '\n',
|
||
[join(DIR, 'other.jsonl')]: 'ignored\n',
|
||
});
|
||
const r = loadRecentBlocks({ fsImpl: fs, runtimeDir: DIR, now: 350, windowMs: 1000, limit: 10 });
|
||
expect(r.map((x) => x.action)).toEqual(['write:x', 'bash:rm', 'write:y']); // desc by ts
|
||
expect(r[0].ts).toBe(new Date(300).toISOString());
|
||
});
|
||
it('окно отсекает старое; cap ограничивает; нет файлов → []', () => {
|
||
const fs = memFs({ [join(DIR, 'guard-blocks-a.jsonl')]: JSON.stringify({ ts: 10, machine: 'M', action: 'a', reason: 'r' }) + '\n' });
|
||
expect(loadRecentBlocks({ fsImpl: fs, runtimeDir: DIR, now: 100000, windowMs: 1000, limit: 10 })).toEqual([]);
|
||
expect(loadRecentBlocks({ fsImpl: memFs(), runtimeDir: DIR, now: 1, windowMs: 1000, limit: 10 })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('loadRecentEscapes (askuser-decisions floor_escape)', () => {
|
||
it('собирает floor_escape из всех askuser-decisions-*.jsonl, reason=label', () => {
|
||
const fs = memFs({
|
||
[join(DIR, 'askuser-decisions-s1.jsonl')]:
|
||
JSON.stringify({ type: 'floor_escape', action: 'bash:git push', ts: 200 }) + '\n'
|
||
+ JSON.stringify({ type: 'approve_git_operation', action: 'x', ts: 210 }) + '\n', // не floor_escape
|
||
});
|
||
const r = loadRecentEscapes({ fsImpl: fs, runtimeDir: DIR, now: 250, windowMs: 1000, limit: 10 });
|
||
expect(r).toHaveLength(1);
|
||
expect(r[0].action).toBe('bash:git push');
|
||
expect(r[0].reason).toBe('escape владельца');
|
||
expect(r[0].ts).toBe(new Date(200).toISOString());
|
||
});
|
||
});
|