feat(m7-phase0): disciplineOutcome fail-CLOSE + P-7 списки + контракт helpers:7 (правило 1)

This commit is contained in:
Дмитрий
2026-06-08 10:02:43 +03:00
parent bbd66c9b61
commit dc30c5daee
2 changed files with 93 additions and 2 deletions
+48 -2
View File
@@ -4,8 +4,13 @@
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
* Plan: docs/superpowers/plans/2026-05-25-enforce-hard-rules.md
*
* Design contract: ALL hooks MUST fail-quiet on internal error (exit 0 with empty {}).
* Only deliberate enforcement violations exit 2.
* Design contract (M7 Фаза 0, правило 1 — уточнение helpers:7):
* - НАБЛЮДАТЕЛЬНЫЕ хуки (FAIL_QUIET_OBSERVATION_HOOKS) — fail-quiet (exit 0, {}).
* - ДИСЦИПЛИНАРНЫЕ/ЗАЩИТНЫЕ хуки (FAIL_CLOSE_DISCIPLINE_HOOKS) — fail-CLOSE: любая
* внутренняя ошибка → block (exit 2), НЕ тихий пропуск (иначе баг хука = тихий
* обход дисциплины, SE2). Используют disciplineOutcome / exitDisciplineDecision.
* Прецедент: enforce-normative-content-rules:201-205.
* Only deliberate violations (or discipline-hook internal errors) exit 2.
*
* Security note: this file uses child_process.execFileSync with FIXED arguments
* (no user input concatenation) — pattern is safe by construction. No injection
@@ -362,6 +367,47 @@ export function exitDecision({ block, message } = {}) {
process.exit(0);
}
/**
* Дисциплинарный исход (pure, тестируемо): выполнить decideFn; ЛЮБАЯ внутренняя
* ошибка → fail-CLOSE (block:true), НЕ тихий пропуск (M7 Фаза 0, правило 1).
* Наблюдательные хуки это НЕ используют — остаются fail-quiet.
*/
export async function disciplineOutcome(decideFn, { label = 'discipline' } = {}) {
try {
const o = await decideFn();
return { block: !!(o && o.block), message: o && o.message };
} catch {
return { block: true, message: `[${label}] внутренняя ошибка — fail-CLOSE` };
}
}
/** Thin-main: дисциплинарный exit. Ошибка decideFn → блок (fail-CLOSE). */
export async function exitDisciplineDecision(decideFn, opts = {}) {
exitDecision(await disciplineOutcome(decideFn, opts));
}
/**
* P-7 (M7 Фаза 0): дисциплинарные/защитные хуки обязаны быть fail-CLOSE; наблюдательные —
* fail-quiet. Манифест-тест (Фаза 6 / P-6) сверит, что каждый зарегистрированный
* дисциплинарный хук числится здесь и использует fail-CLOSE-обёртку.
* NB: enforce-skill-journaler добавляется в Фазе 3; поглощённая дисциплина §4.2 — в Фазе 4.
*/
export const FAIL_CLOSE_DISCIPLINE_HOOKS = Object.freeze([
'enforce-floor',
'enforce-supreme-gate',
'enforce-judge-gate',
'enforce-snapshot',
'enforce-floor-escape-consume',
'enforce-read-path-deny',
'enforce-mcp-classification',
'enforce-normative-content-rules',
]);
export const FAIL_QUIET_OBSERVATION_HOOKS = Object.freeze([
'observer-stop-hook',
'cost-stop-hook',
]);
export function isProductionCodePath(p) {
if (typeof p !== 'string') return false;
const n = p.replace(/\\/g, '/');
+45
View File
@@ -27,6 +27,51 @@ import {
readSafeBaselineActions,
} from './enforce-hook-helpers.mjs';
import {
disciplineOutcome as _disc,
FAIL_CLOSE_DISCIPLINE_HOOKS as _failClose,
FAIL_QUIET_OBSERVATION_HOOKS as _failQuiet,
} from './enforce-hook-helpers.mjs';
const disciplineOutcome = _disc;
const FAIL_CLOSE_DISCIPLINE_HOOKS = _failClose;
const FAIL_QUIET_OBSERVATION_HOOKS = _failQuiet;
describe('disciplineOutcome — fail-CLOSE (M7 Фаза 0, правило 1)', () => {
it('decideFn бросает → block:true (fail-CLOSE, не тихий пропуск)', async () => {
const r = await disciplineOutcome(() => { throw new Error('boom'); }, { label: 'floor' });
expect(r.block).toBe(true);
expect(r.message).toMatch(/fail-CLOSE/);
});
it('decideFn → {block:false} → пропуск', async () => {
const r = await disciplineOutcome(() => ({ block: false }));
expect(r.block).toBe(false);
});
it('async decideFn → {block:true,message} → блок с сообщением', async () => {
const r = await disciplineOutcome(async () => ({ block: true, message: 'нельзя' }));
expect(r.block).toBe(true);
expect(r.message).toBe('нельзя');
});
it('не-truthy исход (undefined) → block:false', async () => {
const r = await disciplineOutcome(() => undefined);
expect(r.block).toBe(false);
});
});
describe('FAIL_CLOSE / FAIL_QUIET списки (P-7)', () => {
it('оба непусты', () => {
expect(FAIL_CLOSE_DISCIPLINE_HOOKS.length).toBeGreaterThan(0);
expect(FAIL_QUIET_OBSERVATION_HOOKS.length).toBeGreaterThan(0);
});
it('дисциплина и наблюдение не пересекаются', () => {
const q = new Set(FAIL_QUIET_OBSERVATION_HOOKS);
for (const h of FAIL_CLOSE_DISCIPLINE_HOOKS) expect(q.has(h)).toBe(false);
});
it('ядро защиты числится в fail-CLOSE', () => {
for (const h of ['enforce-floor', 'enforce-supreme-gate', 'enforce-snapshot'])
expect(FAIL_CLOSE_DISCIPLINE_HOOKS).toContain(h);
});
});
describe('safe-baseline action log', () => {
it('appends and reads action records', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-'));