From 59c3ef4112a52f31cba434dd204fa7efca8a77fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 25 May 2026 18:24:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(enforce):=20T9=20=E2=80=94=20Rule=20#10=20?= =?UTF-8?q?rationalization=20audit=20(PostToolUse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/enforce-rationalization-audit.mjs | 104 +++++++++++++++++++ tools/enforce-rationalization-audit.test.mjs | 80 ++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 tools/enforce-rationalization-audit.mjs create mode 100644 tools/enforce-rationalization-audit.test.mjs diff --git a/tools/enforce-rationalization-audit.mjs b/tools/enforce-rationalization-audit.mjs new file mode 100644 index 00000000..c2c26221 --- /dev/null +++ b/tools/enforce-rationalization-audit.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Rule #10 — Rationalization audit (PostToolUse). + * + * Reads the last assistant text + nearby tool history. Detects rationalization + * phrases and weak-test signals. Appends each flag to a JSONL file consumed by + * Rule #1 injection on next prompt. + * + * NEVER blocks — soft visibility. Failure modes: + * - skipped writing-plans for a feature task + * - prod-code edit without matching test in same turn (despite TDD-gate + * letting it through via override) + * - assistant text contains rationalization phrases + * + * Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md + */ + +import { + readStdin, + parseEventJson, + readTranscript, + lastAssistantText, + turnToolUses, + appendRationalizationFlag, + exitDecision, + isProductionCodePath, +} from './enforce-hook-helpers.mjs'; + +const RATIONALIZATION_PHRASES = [ + 'just this once', + 'пока без', + 'сейчас быстрее', + 'потом разберусь', + 'временно', + 'просто рационализация', + "i'll come back to", + 'i will come back to', + 'we can skip', + 'rationalize', + 'без церемоний', + 'без скила сейчас', +]; + +export function findRationalizationPhrases(text) { + if (typeof text !== 'string') return []; + const lo = text.toLowerCase(); + const hits = []; + for (const p of RATIONALIZATION_PHRASES) { + if (lo.includes(p)) hits.push(p); + } + return hits; +} + +export function detectProdEditWithoutTest(toolUses) { + // Look for Edit/Write on production code; check if any test edit accompanies it. + const prodEdits = []; + let hasTestEdit = false; + for (const u of toolUses) { + if (!['Edit', 'Write', 'MultiEdit'].includes(u.name)) continue; + const p = (u.input && (u.input.file_path || u.input.notebook_path)) || ''; + if (/\.(test|spec)\.[a-z0-9]+$/i.test(p) || /Test\.php$/.test(p)) { hasTestEdit = true; continue; } + if (isProductionCodePath(p)) prodEdits.push(p); + } + return prodEdits.length > 0 && !hasTestEdit ? prodEdits : []; +} + +export function audit(transcriptEntries) { + const flags = []; + const text = lastAssistantText(transcriptEntries); + const phrases = findRationalizationPhrases(text); + for (const p of phrases) flags.push({ kind: 'rationalization-phrase', evidence: p }); + + const toolUses = turnToolUses(transcriptEntries); + const orphanProdEdits = detectProdEditWithoutTest(toolUses); + for (const p of orphanProdEdits) flags.push({ kind: 'prod-edit-without-test', evidence: p }); + + // Weak commit-message: git commit with very short message + for (const u of toolUses) { + if (u.name !== 'Bash') continue; + const cmd = (u.input && u.input.command) || ''; + if (!/git\s+commit/.test(cmd)) continue; + const m = cmd.match(/-m\s+["']([^"']+)["']/); + if (m && m[1].length < 12) { + flags.push({ kind: 'weak-commit-message', evidence: m[1] }); + } + } + return flags; +} + +async function main() { + try { + const raw = await readStdin(); + const event = parseEventJson(raw); + const transcript = readTranscript(event.transcript_path); + const flags = audit(transcript); + for (const f of flags) appendRationalizationFlag(event.session_id, f.kind, f.evidence); + exitDecision({ block: false }); + } catch { + exitDecision({ block: false }); + } +} + +const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-rationalization-audit.mjs'); +if (isCli) main(); diff --git a/tools/enforce-rationalization-audit.test.mjs b/tools/enforce-rationalization-audit.test.mjs new file mode 100644 index 00000000..6446d96e --- /dev/null +++ b/tools/enforce-rationalization-audit.test.mjs @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { findRationalizationPhrases, detectProdEditWithoutTest, audit } from './enforce-rationalization-audit.mjs'; + +describe('findRationalizationPhrases', () => { + it('detects "just this once" in mixed case', () => { + expect(findRationalizationPhrases('Hmm, Just This Once we will skip')).toContain('just this once'); + }); + it('detects "пока без" Russian', () => { + expect(findRationalizationPhrases('сделаем пока без тестов')).toContain('пока без'); + }); + it('detects multiple phrases in one text', () => { + const hits = findRationalizationPhrases('временно делаем потом разберусь'); + expect(hits.length).toBeGreaterThanOrEqual(2); + }); + it('returns empty array on clean text', () => { + expect(findRationalizationPhrases('coverage: skill:tdd')).toEqual([]); + }); +}); + +describe('detectProdEditWithoutTest', () => { + it('flags prod edit without any test edit in turn', () => { + const uses = [{ name: 'Edit', input: { file_path: 'tools/foo.mjs' } }]; + expect(detectProdEditWithoutTest(uses)).toEqual(['tools/foo.mjs']); + }); + it('does NOT flag when test also edited', () => { + const uses = [ + { name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }, + { name: 'Edit', input: { file_path: 'tools/foo.mjs' } }, + ]; + expect(detectProdEditWithoutTest(uses)).toEqual([]); + }); + it('does NOT flag for non-prod paths', () => { + expect(detectProdEditWithoutTest([{ name: 'Edit', input: { file_path: 'docs/x.md' } }])).toEqual([]); + }); +}); + +describe('audit', () => { + it('flags rationalization phrases in assistant text', () => { + const entries = [ + { message: { role: 'user', content: 'go' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'just this once без скила' }] } }, + ]; + const flags = audit(entries); + expect(flags.find((f) => f.kind === 'rationalization-phrase')).toBeTruthy(); + }); + + it('flags prod-edit-without-test', () => { + const entries = [ + { message: { role: 'user', content: 'go' } }, + { message: { role: 'assistant', content: [ + { type: 'tool_use', id: 't1', name: 'Edit', input: { file_path: 'tools/foo.mjs' } }, + ] } }, + ]; + const flags = audit(entries); + expect(flags.find((f) => f.kind === 'prod-edit-without-test')).toBeTruthy(); + }); + + it('flags weak commit messages (<12 chars)', () => { + const entries = [ + { message: { role: 'user', content: 'go' } }, + { message: { role: 'assistant', content: [ + { type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git commit -m "fix"' } }, + ] } }, + ]; + const flags = audit(entries); + expect(flags.find((f) => f.kind === 'weak-commit-message')).toBeTruthy(); + }); + + it('returns no flags for clean turn', () => { + const entries = [ + { message: { role: 'user', content: 'go' } }, + { message: { role: 'assistant', content: [ + { type: 'text', text: 'coverage: skill:tdd\nworking properly' }, + { type: 'tool_use', id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } }, + { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: 'tools/foo.mjs' } }, + ] } }, + ]; + expect(audit(entries)).toEqual([]); + }); +});