b93e5af439
Code-quality review of Task B (Phase 4) flagged two minor fixes: - Export CHAIN_OUTCOME_BUCKETS for external consumers (test + future cuts) no longer hard-code bucket names. - Replace fs.readFileSync via duplicate `import fs from 'fs'` with the already-imported named `readFileSync` in helpers test. +1 regression test on the export.
479 lines
20 KiB
JavaScript
479 lines
20 KiB
JavaScript
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,
|
|
findOverrideAttempt,
|
|
isProductionCodePath,
|
|
isMemoryPath,
|
|
isDocsOnlyPath,
|
|
isDocsOnlyChange,
|
|
detectGitCommandKind,
|
|
detectFullTestRun,
|
|
sessionToolUses,
|
|
logHookOutcome,
|
|
runtimeDir,
|
|
} from './enforce-hook-helpers.mjs';
|
|
|
|
describe('logHookOutcome', () => {
|
|
const ledgerPath = () => join(runtimeDir(), 'hook-outcomes.jsonl');
|
|
|
|
beforeEach(() => {
|
|
try { fs.unlinkSync(ledgerPath()); } catch { /* may not exist */ }
|
|
});
|
|
|
|
it('appends a JSONL line with rule/outcome/session_id/ts', () => {
|
|
logHookOutcome('chain-recommendation', 'blocked', 'sess-abc');
|
|
const raw = readFileSync(ledgerPath(), 'utf-8');
|
|
const line = JSON.parse(raw.trim().split('\n').pop());
|
|
expect(line.rule).toBe('chain-recommendation');
|
|
expect(line.outcome).toBe('blocked');
|
|
expect(line.session_id).toBe('sess-abc');
|
|
expect(typeof line.ts).toBe('string');
|
|
expect(line.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
|
|
it('does not throw on null session_id', () => {
|
|
expect(() => logHookOutcome('rule', 'passed-skill', null)).not.toThrow();
|
|
});
|
|
|
|
it('appends, not overwrites', () => {
|
|
logHookOutcome('rule', 'blocked', 's1');
|
|
logHookOutcome('rule', 'passed-skill', 's1');
|
|
const lines = readFileSync(ledgerPath(), 'utf-8').trim().split('\n');
|
|
expect(lines.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
describe('sessionToolUses', () => {
|
|
it('returns ALL tool uses across the full session, not just last turn', () => {
|
|
const entries = [
|
|
// turn 1
|
|
{ type: 'user', message: { content: [{ type: 'text', text: 'first' }] } },
|
|
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'echo a' } }] } },
|
|
// turn 2
|
|
{ type: 'user', message: { content: [{ type: 'text', text: 'second' }] } },
|
|
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'composer sast' } }] } },
|
|
// turn 3 (current)
|
|
{ type: 'user', message: { content: [{ type: 'text', text: 'third' }] } },
|
|
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'git status' } }] } },
|
|
];
|
|
const uses = sessionToolUses(entries);
|
|
expect(uses).toHaveLength(3);
|
|
expect(uses.map(u => u.input.command)).toEqual(['echo a', 'composer sast', 'git status']);
|
|
});
|
|
|
|
it('returns [] for empty entries', () => {
|
|
expect(sessionToolUses([])).toEqual([]);
|
|
});
|
|
|
|
it('skips non-tool_use blocks', () => {
|
|
const entries = [
|
|
{ type: 'assistant', message: { content: [
|
|
{ type: 'text', text: 'hi' },
|
|
{ type: 'tool_use', name: 'Bash', input: { command: 'pwd' } },
|
|
] } },
|
|
];
|
|
const uses = sessionToolUses(entries);
|
|
expect(uses).toHaveLength(1);
|
|
expect(uses[0].name).toBe('Bash');
|
|
});
|
|
});
|
|
|
|
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('findOverride — requires_justification (hole 7)', () => {
|
|
const testVocab = {
|
|
phrases: [
|
|
{
|
|
phrase: 'ремонт инфраструктуры',
|
|
suppresses: ['classifier-mismatch'],
|
|
requires_justification: 'ремонт:',
|
|
description: 'master kill — requires justification',
|
|
},
|
|
],
|
|
};
|
|
|
|
it('rejects when phrase present but justification line missing (hole 7)', () => {
|
|
const r = findOverride('ремонт инфраструктуры', 'classifier-mismatch', testVocab);
|
|
expect(r).toBeNull();
|
|
});
|
|
|
|
it('accepts when justification line provides target', () => {
|
|
const r = findOverride('ремонт инфраструктуры\nремонт: enforce-hook-helpers.mjs', 'classifier-mismatch', testVocab);
|
|
expect(r).not.toBeNull();
|
|
expect(r.phrase).toBe('ремонт инфраструктуры');
|
|
});
|
|
|
|
it('rejects when justification line empty after the prefix', () => {
|
|
const r = findOverride('ремонт инфраструктуры\nремонт: ', 'classifier-mismatch', testVocab);
|
|
expect(r).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('findOverrideAttempt — diagnostic helper (silent-reject bug fix)', () => {
|
|
const testVocab = {
|
|
phrases: [
|
|
{
|
|
phrase: 'ремонт инфраструктуры',
|
|
suppresses: ['verify-before-push', 'classifier-mismatch'],
|
|
requires_justification: 'ремонт:',
|
|
description: 'master kill — requires justification',
|
|
},
|
|
{
|
|
phrase: 'срочно',
|
|
suppresses: ['verify-before-push'],
|
|
description: 'no justification required',
|
|
},
|
|
],
|
|
};
|
|
|
|
it('returns phrase even when justification line missing (so caller can emit helpful diagnostic)', () => {
|
|
const r = findOverrideAttempt('ремонт инфраструктуры', 'verify-before-push', testVocab);
|
|
expect(r).not.toBeNull();
|
|
expect(r.phrase).toBe('ремонт инфраструктуры');
|
|
expect(r.requires_justification).toBe('ремонт:');
|
|
});
|
|
|
|
it('returns phrase when justification IS provided (same behaviour as findOverride for success path)', () => {
|
|
const r = findOverrideAttempt('ремонт инфраструктуры\nремонт: observer refresh', 'verify-before-push', testVocab);
|
|
expect(r).not.toBeNull();
|
|
expect(r.phrase).toBe('ремонт инфраструктуры');
|
|
});
|
|
|
|
it('returns phrase for non-justification overrides (e.g., срочно)', () => {
|
|
const r = findOverrideAttempt('срочно надо', 'verify-before-push', testVocab);
|
|
expect(r).not.toBeNull();
|
|
expect(r.phrase).toBe('срочно');
|
|
});
|
|
|
|
it('returns null when phrase substring not in prompt', () => {
|
|
expect(findOverrideAttempt('hello world', 'verify-before-push', testVocab)).toBeNull();
|
|
});
|
|
|
|
it('returns null when rule key not in suppresses (phrase irrelevant)', () => {
|
|
const r = findOverrideAttempt('ремонт инфраструктуры', 'tdd-gate-other', testVocab);
|
|
expect(r).toBeNull();
|
|
});
|
|
|
|
it('returns null on empty / null prompt', () => {
|
|
expect(findOverrideAttempt('', 'verify-before-push', testVocab)).toBeNull();
|
|
expect(findOverrideAttempt(null, 'verify-before-push', testVocab)).toBeNull();
|
|
});
|
|
});
|
|
|
|
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('isDocsOnlyPath', () => {
|
|
it('matches .md files at any depth', () => {
|
|
expect(isDocsOnlyPath('CLAUDE.md')).toBe(true);
|
|
expect(isDocsOnlyPath('docs/Pravila_raboty_Claude_v1_1.md')).toBe(true);
|
|
expect(isDocsOnlyPath('docs/superpowers/specs/2026-05-27-foo-design.md')).toBe(true);
|
|
expect(isDocsOnlyPath('memory/feedback_xyz.md')).toBe(true);
|
|
expect(isDocsOnlyPath('.claude/skills/audit-portal/SKILL.md')).toBe(true);
|
|
expect(isDocsOnlyPath('.claude/agents/normative-sync.md')).toBe(true);
|
|
expect(isDocsOnlyPath('db/CHANGELOG_schema.md')).toBe(true);
|
|
});
|
|
it('is case-insensitive on extension', () => {
|
|
expect(isDocsOnlyPath('README.MD')).toBe(true);
|
|
expect(isDocsOnlyPath('Foo.Md')).toBe(true);
|
|
});
|
|
it('rejects code / config / schema files', () => {
|
|
expect(isDocsOnlyPath('app/app/Http/Controllers/X.php')).toBe(false);
|
|
expect(isDocsOnlyPath('tools/enforce-hook-helpers.mjs')).toBe(false);
|
|
expect(isDocsOnlyPath('resources/js/views/Dashboard.vue')).toBe(false);
|
|
expect(isDocsOnlyPath('db/schema.sql')).toBe(false);
|
|
expect(isDocsOnlyPath('.claude/settings.json')).toBe(false);
|
|
expect(isDocsOnlyPath('composer.json')).toBe(false);
|
|
expect(isDocsOnlyPath('lefthook.yml')).toBe(false);
|
|
expect(isDocsOnlyPath('Dockerfile')).toBe(false);
|
|
});
|
|
it('rejects empty / non-string inputs', () => {
|
|
expect(isDocsOnlyPath('')).toBe(false);
|
|
expect(isDocsOnlyPath(null)).toBe(false);
|
|
expect(isDocsOnlyPath(undefined)).toBe(false);
|
|
expect(isDocsOnlyPath(42)).toBe(false);
|
|
});
|
|
it('does not match files merely containing ".md" mid-name', () => {
|
|
expect(isDocsOnlyPath('foo.mdx')).toBe(false);
|
|
expect(isDocsOnlyPath('app/CHANGELOG.md.bak')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isDocsOnlyChange', () => {
|
|
it('true when every path is .md', () => {
|
|
expect(isDocsOnlyChange(['CLAUDE.md'])).toBe(true);
|
|
expect(isDocsOnlyChange(['CLAUDE.md', 'docs/x.md', 'memory/y.md'])).toBe(true);
|
|
});
|
|
it('false when ANY path is non-md', () => {
|
|
expect(isDocsOnlyChange(['CLAUDE.md', 'app/Foo.php'])).toBe(false);
|
|
expect(isDocsOnlyChange(['tools/x.mjs'])).toBe(false);
|
|
expect(isDocsOnlyChange(['docs/x.md', '.claude/settings.json'])).toBe(false);
|
|
});
|
|
it('false on empty array (unknown → conservative)', () => {
|
|
expect(isDocsOnlyChange([])).toBe(false);
|
|
});
|
|
it('false on non-array input', () => {
|
|
expect(isDocsOnlyChange(null)).toBe(false);
|
|
expect(isDocsOnlyChange(undefined)).toBe(false);
|
|
expect(isDocsOnlyChange('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();
|
|
});
|
|
|
|
it('returns null when "vitest run" appears INSIDE a git commit message (false-positive guard)', () => {
|
|
// Real bug we hit during bootstrap: commit message saying "full vitest run
|
|
// (8092/8092)" caused detectFullTestRun to match and overwrite sentinel.
|
|
expect(detectFullTestRun('git commit -m "feat: full vitest run all green"')).toBeNull();
|
|
expect(detectFullTestRun('LEFTHOOK=0 git commit -m "ran pest"')).toBeNull();
|
|
expect(detectFullTestRun('echo "pest passed" && ls')).toBeNull();
|
|
expect(detectFullTestRun('cat sentinel | grep vitest')).toBeNull();
|
|
});
|
|
|
|
it('still detects vitest in compound command starting with cd or having cat/echo segments', () => {
|
|
// Second bug: overly aggressive guard blocked legitimate vitest run that
|
|
// appeared in a compound command with cd / cat / echo somewhere.
|
|
// We want: ANY segment starting with `npx vitest run` (or pest) counts.
|
|
expect(detectFullTestRun('cd /path && npx vitest run tools/ 2>&1 | tail -5')).toBe('vitest-full');
|
|
expect(detectFullTestRun('LEFTHOOK=0 npx vitest run')).toBe('vitest-full');
|
|
expect(detectFullTestRun('npx vitest run && echo done')).toBe('vitest-full');
|
|
expect(detectFullTestRun('cd app && composer test')).toBe('pest');
|
|
expect(detectFullTestRun('cd app && php artisan test')).toBe('pest');
|
|
expect(detectFullTestRun('./vendor/bin/pest')).toBe('pest');
|
|
});
|
|
|
|
it('returns null when git commit message itself contains a compound that looks like test run (third false-positive)', () => {
|
|
// Third bug: split-by-&& naively splits inside quoted commit messages.
|
|
// A commit message like `git commit -m "... npx vitest run ..."` would
|
|
// produce a segment `npx vitest run` from inside the quoted string.
|
|
// Fix: identify FIRST real command (after cd/env), if it's git/etc → null.
|
|
expect(detectFullTestRun('git commit -m "fix: command like cd ... && npx vitest run"')).toBeNull();
|
|
expect(detectFullTestRun('cd /path && git commit -m "and then npx vitest run && echo done"')).toBeNull();
|
|
expect(detectFullTestRun('git push origin main')).toBeNull();
|
|
expect(detectFullTestRun('cd app && cp src dst')).toBeNull();
|
|
});
|
|
});
|