feat(m7-phase0): disciplineOutcome fail-CLOSE + P-7 списки + контракт helpers:7 (правило 1)
This commit is contained in:
@@ -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, '/');
|
||||
|
||||
@@ -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-'));
|
||||
|
||||
Reference in New Issue
Block a user