feat(enforce): T1 — shared hook helpers + override vocab
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Shared helpers for the 10-rule enforcement hook layer.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Security note: this file uses child_process.execFileSync with FIXED arguments
|
||||
* (no user input concatenation) — pattern is safe by construction. No injection
|
||||
* surface. See readGitBranch().
|
||||
*
|
||||
* Security Guidance #40: pure parsing — no exec/execSync except readGitBranch which
|
||||
* is the documented use case (fixed args, no user input).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/** Read full stdin as utf-8 string. Returns '' on empty/error. */
|
||||
export async function readStdin(stdinStream = process.stdin) {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => { timedOut = true; resolve(data); }, 4500);
|
||||
stdinStream.setEncoding('utf-8');
|
||||
stdinStream.on('data', (chunk) => { data += chunk; });
|
||||
stdinStream.on('end', () => {
|
||||
if (timedOut) return;
|
||||
clearTimeout(timer);
|
||||
resolve(data);
|
||||
});
|
||||
stdinStream.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
resolve('');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseEventJson(raw) {
|
||||
try { return JSON.parse(raw || '{}'); } catch { return {}; }
|
||||
}
|
||||
|
||||
/** Runtime directory: ~/.claude/runtime/ */
|
||||
export function runtimeDir() {
|
||||
const dir = join(homedir(), '.claude', 'runtime');
|
||||
try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function sentinelPath(name, sessionId) {
|
||||
return join(runtimeDir(), `${name}-${sessionId || 'unknown'}.json`);
|
||||
}
|
||||
|
||||
export function writeSentinel(name, sessionId, data) {
|
||||
try {
|
||||
const p = sentinelPath(name, sessionId);
|
||||
writeFileSync(p, JSON.stringify({ ...data, written_at: new Date().toISOString() }, null, 2));
|
||||
return p;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function readSentinel(name, sessionId) {
|
||||
try {
|
||||
const p = sentinelPath(name, sessionId);
|
||||
if (!existsSync(p)) return null;
|
||||
return JSON.parse(readFileSync(p, 'utf-8'));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function sentinelAgeSec(name, sessionId) {
|
||||
const s = readSentinel(name, sessionId);
|
||||
if (!s || !s.written_at) return null;
|
||||
const ms = Date.now() - new Date(s.written_at).getTime();
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return Math.floor(ms / 1000);
|
||||
}
|
||||
|
||||
export function readTranscript(transcriptPath) {
|
||||
if (!transcriptPath || typeof transcriptPath !== 'string') return [];
|
||||
if (!existsSync(transcriptPath)) return [];
|
||||
try {
|
||||
const raw = readFileSync(transcriptPath, 'utf-8');
|
||||
const lines = raw.split('\n').filter(Boolean);
|
||||
const out = [];
|
||||
for (const l of lines) {
|
||||
try { out.push(JSON.parse(l)); } catch { /* skip */ }
|
||||
}
|
||||
return out;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function lastTurnEntries(entries) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) return [];
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const e = entries[i];
|
||||
if (e && e.message && e.message.role === 'user') {
|
||||
const c = e.message.content;
|
||||
if (typeof c === 'string' && c.trim().length > 0) return entries.slice(i);
|
||||
if (Array.isArray(c)) {
|
||||
const hasToolResult = c.some((b) => b && b.type === 'tool_result');
|
||||
const hasText = c.some((b) => b && b.type === 'text');
|
||||
if (hasText && !hasToolResult) return entries.slice(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function lastUserPromptText(entries) {
|
||||
const turn = lastTurnEntries(entries);
|
||||
if (!turn || turn.length === 0) return '';
|
||||
const e = turn[0];
|
||||
if (!e || !e.message) return '';
|
||||
const c = e.message.content;
|
||||
if (typeof c === 'string') return c;
|
||||
if (Array.isArray(c)) {
|
||||
return c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function lastAssistantText(entries) {
|
||||
const turn = lastTurnEntries(entries);
|
||||
let out = '';
|
||||
for (const e of turn) {
|
||||
if (e && e.message && e.message.role === 'assistant') {
|
||||
const c = e.message.content;
|
||||
if (Array.isArray(c)) {
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseCoverageLine(text) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const m = text.match(/coverage:\s*(skill|node|chain|hook|agent|direct)\s*:\s*([^\s\n<>]+)/i);
|
||||
if (!m) return null;
|
||||
return { channel: m[1].toLowerCase(), id: m[2] };
|
||||
}
|
||||
|
||||
export function turnToolUses(entries) {
|
||||
const turn = lastTurnEntries(entries);
|
||||
const uses = [];
|
||||
for (const e of turn) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} });
|
||||
}
|
||||
}
|
||||
return uses;
|
||||
}
|
||||
|
||||
export function turnToolResults(entries) {
|
||||
const turn = lastTurnEntries(entries);
|
||||
const results = [];
|
||||
for (const e of turn) {
|
||||
const c = e && e.message && e.message.content;
|
||||
if (!Array.isArray(c)) continue;
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_result') {
|
||||
const txt = typeof b.content === 'string' ? b.content
|
||||
: Array.isArray(b.content) ? b.content.map((p) => (p && p.text) || '').join('\n') : '';
|
||||
results.push({ tool_use_id: b.tool_use_id, is_error: b.is_error === true, content: txt });
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
let _vocabCache = null;
|
||||
export function loadOverrideVocab(path) {
|
||||
if (_vocabCache) return _vocabCache;
|
||||
try {
|
||||
const p = path || join(__dirname, 'enforce-override-vocab.json');
|
||||
if (!existsSync(p)) return { phrases: [] };
|
||||
_vocabCache = JSON.parse(readFileSync(p, 'utf-8'));
|
||||
return _vocabCache;
|
||||
} catch { return { phrases: [] }; }
|
||||
}
|
||||
|
||||
export function _resetVocabCache() { _vocabCache = null; }
|
||||
|
||||
export function findOverride(userPrompt, ruleKey, vocab) {
|
||||
if (!userPrompt || typeof userPrompt !== 'string') return null;
|
||||
const v = vocab || loadOverrideVocab();
|
||||
const lo = userPrompt.toLowerCase();
|
||||
for (const p of v.phrases || []) {
|
||||
if (!p.phrase || !Array.isArray(p.suppresses)) continue;
|
||||
if (!lo.includes(p.phrase.toLowerCase())) continue;
|
||||
if (p.suppresses.includes(ruleKey)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function logOverride(ruleKey, phraseObj, sessionId) {
|
||||
try {
|
||||
const f = join(runtimeDir(), 'override-usage.jsonl');
|
||||
appendFileSync(f, JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
session_id: sessionId || null,
|
||||
rule: ruleKey,
|
||||
phrase: phraseObj && phraseObj.phrase,
|
||||
}) + '\n');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current git branch via execFileSync with fixed args (no shell, no user
|
||||
* input concatenation — safe by construction). Returns empty string on error.
|
||||
*/
|
||||
export function readGitBranch(cwd) {
|
||||
try {
|
||||
return execFileSync('git', ['branch', '--show-current'], {
|
||||
cwd: cwd || process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export function expectedBranchPath(sessionId) {
|
||||
return join(runtimeDir(), `expected-branch-${sessionId || 'unknown'}`);
|
||||
}
|
||||
|
||||
export function getExpectedBranch(sessionId) {
|
||||
try {
|
||||
const p = expectedBranchPath(sessionId);
|
||||
if (!existsSync(p)) return '';
|
||||
return readFileSync(p, 'utf-8').trim();
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
export function setExpectedBranch(sessionId, branch) {
|
||||
try {
|
||||
writeFileSync(expectedBranchPath(sessionId), String(branch || '').trim());
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function appendRationalizationFlag(sessionId, kind, evidence) {
|
||||
try {
|
||||
const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`);
|
||||
appendFileSync(f, JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
kind,
|
||||
evidence: typeof evidence === 'string' ? evidence.slice(0, 240) : evidence,
|
||||
}) + '\n');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function readRationalizationFlags(sessionId) {
|
||||
try {
|
||||
const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`);
|
||||
if (!existsSync(f)) return [];
|
||||
return readFileSync(f, 'utf-8').split('\n').filter(Boolean).map((l) => {
|
||||
try { return JSON.parse(l); } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function readRouterState(sessionId) {
|
||||
try {
|
||||
const p = join(runtimeDir(), `router-state-${sessionId || 'unknown'}.json`);
|
||||
if (!existsSync(p)) return null;
|
||||
return JSON.parse(readFileSync(p, 'utf-8'));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function exitDecision({ block, message } = {}) {
|
||||
if (block) {
|
||||
if (message) process.stderr.write(message + '\n');
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
try { process.stdout.write('{}'); } catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export function isProductionCodePath(p) {
|
||||
if (typeof p !== 'string') return false;
|
||||
const n = p.replace(/\\/g, '/');
|
||||
if (/\.(test|spec)\.[a-z0-9]+$/i.test(n)) return false;
|
||||
if (/(?:^|\/)tests?\//.test(n) || /(?:^|\/)spec\//.test(n)) return false;
|
||||
if (/(?:^|\/)tools\/[^/]+\.mjs$/.test(n)) return true;
|
||||
if (/(?:^|\/)app\/app\/.+\.php$/.test(n)) return true;
|
||||
if (/(?:^|\/)resources\/js\/.+\.(vue|ts|tsx|js)$/.test(n)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isMemoryPath(p) {
|
||||
if (typeof p !== 'string') return false;
|
||||
const n = p.replace(/\\/g, '/');
|
||||
if (/\/memory\/[^/]+\.md$/i.test(n)) return true;
|
||||
if (/\/MEMORY\.md$/i.test(n)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function detectGitCommandKind(cmd) {
|
||||
if (typeof cmd !== 'string') return null;
|
||||
const c = cmd.trim();
|
||||
if (/(^|\s|;|&&|\|\|)git\s+push\b/i.test(c)) return 'push';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+commit\b/i.test(c)) return 'commit';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+cherry-pick\b/i.test(c)) return 'cherry-pick';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+reset\s+--hard\b/i.test(c)) return 'reset-hard';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+rebase\b/i.test(c)) return 'rebase';
|
||||
if (/(^|\s|;|&&|\|\|)git\s+branch\s+-[df]\b/i.test(c)) return 'branch-force';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectFullTestRun(cmd) {
|
||||
if (typeof cmd !== 'string') return null;
|
||||
const c = cmd.toLowerCase();
|
||||
if (/\bvitest\s+run\b/.test(c) && !/\btools\/[^\s]+\.test\.mjs\b/.test(c)) return 'vitest-full';
|
||||
if (/\bnpm\s+run\s+test\b/.test(c)) return 'npm-test';
|
||||
if (/\bphp\s+artisan\s+test\b/.test(c) || /\bcomposer\s+test\b/.test(c)) return 'pest';
|
||||
if (/\bpest\b/.test(c)) return 'pest';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isVerificationFresh(sessionId, maxAgeSec = 1800) {
|
||||
const s = readSentinel('verify-pass', sessionId);
|
||||
if (!s || s.result !== 'pass') return false;
|
||||
const age = sentinelAgeSec('verify-pass', sessionId);
|
||||
return age !== null && age <= maxAgeSec;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
parseEventJson,
|
||||
parseCoverageLine,
|
||||
lastTurnEntries,
|
||||
lastUserPromptText,
|
||||
lastAssistantText,
|
||||
turnToolUses,
|
||||
turnToolResults,
|
||||
loadOverrideVocab,
|
||||
_resetVocabCache,
|
||||
findOverride,
|
||||
isProductionCodePath,
|
||||
isMemoryPath,
|
||||
detectGitCommandKind,
|
||||
detectFullTestRun,
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
|
||||
describe('parseEventJson', () => {
|
||||
it('parses well-formed JSON', () => {
|
||||
expect(parseEventJson('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
it('returns empty object on broken JSON', () => {
|
||||
expect(parseEventJson('not-json')).toEqual({});
|
||||
});
|
||||
it('returns empty object on empty input', () => {
|
||||
expect(parseEventJson('')).toEqual({});
|
||||
expect(parseEventJson(null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCoverageLine', () => {
|
||||
it('extracts skill coverage', () => {
|
||||
const t = 'экономия: 100%\n\ncoverage: skill:superpowers:test-driven-development\n\nок поехали';
|
||||
expect(parseCoverageLine(t)).toEqual({ channel: 'skill', id: 'superpowers:test-driven-development' });
|
||||
});
|
||||
it('extracts direct coverage', () => {
|
||||
expect(parseCoverageLine('coverage: direct:memory-sync')).toEqual({ channel: 'direct', id: 'memory-sync' });
|
||||
});
|
||||
it('extracts node coverage', () => {
|
||||
expect(parseCoverageLine('coverage: node:#19')).toEqual({ channel: 'node', id: '#19' });
|
||||
});
|
||||
it('is case-insensitive on channel keyword', () => {
|
||||
expect(parseCoverageLine('Coverage: Skill:foo')).toEqual({ channel: 'skill', id: 'foo' });
|
||||
});
|
||||
it('returns null when no coverage line present', () => {
|
||||
expect(parseCoverageLine('just some text')).toBeNull();
|
||||
});
|
||||
it('returns null on non-string input', () => {
|
||||
expect(parseCoverageLine(null)).toBeNull();
|
||||
expect(parseCoverageLine(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lastTurnEntries / lastUserPromptText / lastAssistantText / turnToolUses', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'old prompt' } },
|
||||
{ message: { role: 'assistant', content: [{ type: 'text', text: 'old reply' }] } },
|
||||
{ message: { role: 'user', content: 'new prompt' } },
|
||||
{ message: { role: 'assistant', content: [
|
||||
{ type: 'text', text: 'I will edit' },
|
||||
{ type: 'tool_use', name: 'Edit', input: { file_path: 'a.mjs' } },
|
||||
] } },
|
||||
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'ok', is_error: false }] } },
|
||||
];
|
||||
|
||||
it('lastTurnEntries starts from last real user prompt', () => {
|
||||
const turn = lastTurnEntries(entries);
|
||||
expect(turn).toHaveLength(3); // new prompt + assistant + tool_result
|
||||
expect(turn[0].message.content).toBe('new prompt');
|
||||
});
|
||||
|
||||
it('lastUserPromptText returns last user prompt string', () => {
|
||||
expect(lastUserPromptText(entries)).toBe('new prompt');
|
||||
});
|
||||
|
||||
it('lastAssistantText concatenates assistant text blocks of last turn only', () => {
|
||||
expect(lastAssistantText(entries)).toContain('I will edit');
|
||||
expect(lastAssistantText(entries)).not.toContain('old reply');
|
||||
});
|
||||
|
||||
it('turnToolUses returns only tool_use blocks from last turn', () => {
|
||||
const uses = turnToolUses(entries);
|
||||
expect(uses).toHaveLength(1);
|
||||
expect(uses[0].name).toBe('Edit');
|
||||
expect(uses[0].input.file_path).toBe('a.mjs');
|
||||
});
|
||||
|
||||
it('turnToolResults includes is_error flag and concatenated text', () => {
|
||||
const results = turnToolResults(entries);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].is_error).toBe(false);
|
||||
expect(results[0].content).toBe('ok');
|
||||
});
|
||||
|
||||
it('handles array text content in user message', () => {
|
||||
const eps = [
|
||||
{ message: { role: 'user', content: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } },
|
||||
];
|
||||
expect(lastUserPromptText(eps)).toBe('hello\n world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadOverrideVocab / findOverride', () => {
|
||||
let tmp;
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'vocab-'));
|
||||
_resetVocabCache();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
_resetVocabCache();
|
||||
});
|
||||
|
||||
it('loads vocab from explicit path', () => {
|
||||
const p = join(tmp, 'vocab.json');
|
||||
writeFileSync(p, JSON.stringify({
|
||||
phrases: [
|
||||
{ phrase: 'без скилов', suppresses: ['skill-required'] },
|
||||
],
|
||||
}));
|
||||
const v = loadOverrideVocab(p);
|
||||
expect(v.phrases).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('findOverride matches case-insensitively', () => {
|
||||
const v = { phrases: [{ phrase: 'СРОЧНО', suppresses: ['verify-before-push'] }] };
|
||||
expect(findOverride('очень срочно нужно', 'verify-before-push', v)).toMatchObject({ phrase: 'СРОЧНО' });
|
||||
expect(findOverride('hello world', 'verify-before-push', v)).toBeNull();
|
||||
});
|
||||
|
||||
it('findOverride returns null if rule key not in suppresses', () => {
|
||||
const v = { phrases: [{ phrase: 'без скилов', suppresses: ['skill-required'] }] };
|
||||
expect(findOverride('без скилов давай', 'tdd-gate', v)).toBeNull();
|
||||
expect(findOverride('без скилов давай', 'skill-required', v)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('findOverride returns null on empty prompt / vocab', () => {
|
||||
expect(findOverride('', 'x', { phrases: [] })).toBeNull();
|
||||
expect(findOverride(null, 'x', { phrases: [{ phrase: 'a', suppresses: ['x'] }] })).toBeNull();
|
||||
});
|
||||
|
||||
it('loads default vocab file when no path given (smoke)', () => {
|
||||
_resetVocabCache();
|
||||
const v = loadOverrideVocab();
|
||||
expect(Array.isArray(v.phrases)).toBe(true);
|
||||
expect(v.phrases.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProductionCodePath', () => {
|
||||
it('classifies tools/*.mjs as production', () => {
|
||||
expect(isProductionCodePath('tools/router-classifier.mjs')).toBe(true);
|
||||
expect(isProductionCodePath('c:/моя/проекты/портал crm/Документация/tools/foo.mjs')).toBe(true);
|
||||
});
|
||||
it('excludes test files', () => {
|
||||
expect(isProductionCodePath('tools/router-classifier.test.mjs')).toBe(false);
|
||||
expect(isProductionCodePath('tools/foo.spec.mjs')).toBe(false);
|
||||
});
|
||||
it('classifies app/app/**.php as production', () => {
|
||||
expect(isProductionCodePath('app/app/Http/Controllers/X.php')).toBe(true);
|
||||
});
|
||||
it('excludes app/tests/**', () => {
|
||||
expect(isProductionCodePath('app/tests/Feature/X.php')).toBe(false);
|
||||
});
|
||||
it('classifies resources/js/**.vue|ts|tsx|js as production', () => {
|
||||
expect(isProductionCodePath('resources/js/views/Dashboard.vue')).toBe(true);
|
||||
expect(isProductionCodePath('resources/js/api/admin.ts')).toBe(true);
|
||||
});
|
||||
it('excludes *.spec.ts/*.test.ts', () => {
|
||||
expect(isProductionCodePath('resources/js/views/Dashboard.spec.ts')).toBe(false);
|
||||
expect(isProductionCodePath('resources/js/views/Dashboard.test.ts')).toBe(false);
|
||||
});
|
||||
it('returns false for non-production paths', () => {
|
||||
expect(isProductionCodePath('docs/x.md')).toBe(false);
|
||||
expect(isProductionCodePath('CLAUDE.md')).toBe(false);
|
||||
expect(isProductionCodePath('package.json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMemoryPath', () => {
|
||||
it('matches user-memory store .md files', () => {
|
||||
expect(isMemoryPath('C:\\Users\\Administrator\\.claude\\projects\\proj\\memory\\reference.md')).toBe(true);
|
||||
expect(isMemoryPath('/Users/x/.claude/projects/proj/memory/foo.md')).toBe(true);
|
||||
});
|
||||
it('matches MEMORY.md regardless of folder', () => {
|
||||
expect(isMemoryPath('C:\\Users\\x\\.claude\\projects\\proj\\memory\\MEMORY.md')).toBe(true);
|
||||
expect(isMemoryPath('/foo/MEMORY.md')).toBe(true);
|
||||
});
|
||||
it('returns false for normal docs', () => {
|
||||
expect(isMemoryPath('docs/x.md')).toBe(false);
|
||||
expect(isMemoryPath('CLAUDE.md')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectGitCommandKind', () => {
|
||||
it('detects push', () => {
|
||||
expect(detectGitCommandKind('git push origin main')).toBe('push');
|
||||
expect(detectGitCommandKind('LEFTHOOK=0 git push')).toBe('push');
|
||||
});
|
||||
it('detects commit', () => {
|
||||
expect(detectGitCommandKind('git commit -m "x"')).toBe('commit');
|
||||
});
|
||||
it('detects cherry-pick', () => {
|
||||
expect(detectGitCommandKind('git cherry-pick abc123')).toBe('cherry-pick');
|
||||
});
|
||||
it('detects branch -f', () => {
|
||||
expect(detectGitCommandKind('git branch -f main HEAD')).toBe('branch-force');
|
||||
expect(detectGitCommandKind('git branch -d feature')).toBe('branch-force');
|
||||
});
|
||||
it('detects rebase', () => {
|
||||
expect(detectGitCommandKind('git rebase main')).toBe('rebase');
|
||||
});
|
||||
it('returns null for non-git commands', () => {
|
||||
expect(detectGitCommandKind('ls -la')).toBeNull();
|
||||
expect(detectGitCommandKind('git status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectFullTestRun', () => {
|
||||
it('detects vitest run as full when no specific path', () => {
|
||||
expect(detectFullTestRun('npx vitest run')).toBe('vitest-full');
|
||||
expect(detectFullTestRun('npx vitest run --reporter=basic')).toBe('vitest-full');
|
||||
});
|
||||
it('returns null for narrow vitest with specific test path', () => {
|
||||
expect(detectFullTestRun('npx vitest run tools/foo.test.mjs')).toBeNull();
|
||||
});
|
||||
it('detects pest / composer test', () => {
|
||||
expect(detectFullTestRun('php artisan test')).toBe('pest');
|
||||
expect(detectFullTestRun('composer test')).toBe('pest');
|
||||
expect(detectFullTestRun('./vendor/bin/pest')).toBe('pest');
|
||||
});
|
||||
it('returns null for non-test commands', () => {
|
||||
expect(detectFullTestRun('git status')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"comment": "Hard-coded override phrases. Substring-match (case-insensitive) against user's last prompt. Each phrase suppresses one or more rule categories for ONE prompt only.",
|
||||
"phrases": [
|
||||
{
|
||||
"phrase": "без скилов",
|
||||
"suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch"],
|
||||
"description": "Skill discipline relaxed for this one prompt"
|
||||
},
|
||||
{
|
||||
"phrase": "direct ok",
|
||||
"suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch"],
|
||||
"description": "Direct work allowed without skill invocation"
|
||||
},
|
||||
{
|
||||
"phrase": "срочно",
|
||||
"suppresses": ["verify-before-commit", "verify-before-push", "tdd-gate"],
|
||||
"description": "Urgency override: skip verification + TDD gate"
|
||||
},
|
||||
{
|
||||
"phrase": "быстрый коммит",
|
||||
"suppresses": ["verify-before-commit", "tdd-gate", "writing-plans-required"],
|
||||
"description": "Quick commit: skip TDD + verify + plans"
|
||||
},
|
||||
{
|
||||
"phrase": "recovery",
|
||||
"suppresses": ["branch-switch", "git-recovery"],
|
||||
"description": "Git recovery operation, branch-state mismatch ok"
|
||||
},
|
||||
{
|
||||
"phrase": "memory dump",
|
||||
"suppresses": ["memory-sync-coverage", "skill-required"],
|
||||
"description": "Memory write without separate coverage announcement"
|
||||
},
|
||||
{
|
||||
"phrase": "ремонт инфраструктуры",
|
||||
"suppresses": ["tdd-gate", "verify-before-commit", "verify-before-push", "writing-plans-required", "skill-required", "memory-sync-coverage", "classifier-mismatch", "coverage-skill-match"],
|
||||
"description": "Bypass all rules (full opt-out). Use only when literally fixing the enforce-infrastructure itself."
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user