397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
6.1 KiB
JavaScript
155 lines
6.1 KiB
JavaScript
// tools/skill-scope-verifier.test.mjs
|
||
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
V4_1_SCOPE_THRESHOLDS, initTracker, updateTracker, evaluateScope,
|
||
contentScopeCheck, maxCallsCheck,
|
||
} from './skill-scope-verifier.mjs';
|
||
|
||
const writingPlans = {
|
||
expected_tools: ['Read', 'Grep', 'AskUserQuestion', 'Write', 'TodoWrite'],
|
||
expected_output_glob: 'docs/superpowers/plans/*.md',
|
||
content_scope: {
|
||
Edit: { allowed_globs: ['docs/superpowers/plans/*.md'], max_count: 5 },
|
||
Write: { allowed_globs: ['docs/superpowers/plans/*.md'], max_count: 3 },
|
||
},
|
||
max_tool_calls_post_skill: 30,
|
||
};
|
||
|
||
function mk() {
|
||
return initTracker({
|
||
skillInvoked: 'superpowers:writing-plans',
|
||
turnId: 't1', toolUseIdOfSkill: 'u1',
|
||
scopeConfig: writingPlans, tsSkillInvokedIso: '2026-05-29T00:00:00Z',
|
||
});
|
||
}
|
||
|
||
describe('initTracker', () => {
|
||
it('starts with zeroed counters', () => {
|
||
const t = mk();
|
||
expect(t.tool_count_since_skill).toBe(0);
|
||
expect(t.off_scope_count).toBe(0);
|
||
expect(t.expected_output_written).toBe(false);
|
||
expect(t.skill_invoked).toBe('superpowers:writing-plans');
|
||
});
|
||
});
|
||
|
||
describe('updateTracker', () => {
|
||
it('counts in-scope tool without off_scope', () => {
|
||
const t = updateTracker(mk(), 'Read', {});
|
||
expect(t.tool_count_since_skill).toBe(1);
|
||
expect(t.off_scope_count).toBe(0);
|
||
});
|
||
it('counts off-scope tool', () => {
|
||
const t = updateTracker(mk(), 'Bash', { command: 'ls' });
|
||
expect(t.off_scope_count).toBe(1);
|
||
});
|
||
it('sets expected_output_written on matching Write', () => {
|
||
const t = updateTracker(mk(), 'Write', { file_path: 'docs/superpowers/plans/x.md' });
|
||
expect(t.expected_output_written).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('evaluateScope', () => {
|
||
it('allows when below min_tools=3', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 2, off_scope_count: 2 };
|
||
expect(evaluateScope(t).action).toBe('allow');
|
||
});
|
||
it('hard_block at ratio 0.30 (3/10)', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 10, off_scope_count: 3 };
|
||
expect(evaluateScope(t).action).toBe('hard_block');
|
||
});
|
||
it('soft_flag at ratio 0.20 (2/10)', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 10, off_scope_count: 2 };
|
||
expect(evaluateScope(t).action).toBe('soft_flag');
|
||
});
|
||
it('allow at ratio 0.10 (1/10)', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 10, off_scope_count: 1 };
|
||
expect(evaluateScope(t).action).toBe('allow');
|
||
});
|
||
it('hard_block at ratio 0.5 (2/4)', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 4, off_scope_count: 2 };
|
||
expect(evaluateScope(t).action).toBe('hard_block');
|
||
});
|
||
});
|
||
|
||
describe('contentScopeCheck', () => {
|
||
it('allows Write to matching glob', () => {
|
||
expect(contentScopeCheck('Write', 'docs/superpowers/plans/x.md', writingPlans).action).toBe('allow');
|
||
});
|
||
it('hard_block Edit to off-domain file', () => {
|
||
expect(contentScopeCheck('Edit', 'app/Models/User.php', writingPlans).action).toBe('hard_block');
|
||
});
|
||
it('allows Read (not a write-tool)', () => {
|
||
expect(contentScopeCheck('Read', 'app/Models/User.php', writingPlans).action).toBe('allow');
|
||
});
|
||
it('allows Write when no content_scope defined', () => {
|
||
expect(contentScopeCheck('Write', 'x.md', { expected_tools: [] }).action).toBe('allow');
|
||
});
|
||
it('allows Edit to matching test glob', () => {
|
||
const tdd = { content_scope: { Edit: { allowed_globs: ['**/*.test.*', 'tests/**'] } } };
|
||
expect(contentScopeCheck('Edit', 'app/Foo.test.mjs', tdd).action).toBe('allow');
|
||
});
|
||
it('hard_block Edit to non-test file', () => {
|
||
const tdd = { content_scope: { Edit: { allowed_globs: ['**/*.test.*', 'tests/**'] } } };
|
||
expect(contentScopeCheck('Edit', 'app/Foo.php', tdd).action).toBe('hard_block');
|
||
});
|
||
});
|
||
|
||
describe('maxCallsCheck', () => {
|
||
it('hard_block when tool_count >= max and no expected output written', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 30, expected_output_written: false };
|
||
expect(maxCallsCheck(t).action).toBe('hard_block');
|
||
});
|
||
it('allows when expected_output_written even at max calls', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 30, expected_output_written: true };
|
||
expect(maxCallsCheck(t).action).toBe('allow');
|
||
});
|
||
it('allows when below max calls', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 5, expected_output_written: false };
|
||
expect(maxCallsCheck(t).action).toBe('allow');
|
||
});
|
||
});
|
||
|
||
describe('reason strings', () => {
|
||
it('evaluateScope hard_block reason contains "invoke matching Skill"', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 10, off_scope_count: 3 };
|
||
const r = evaluateScope(t);
|
||
expect(r.action).toBe('hard_block');
|
||
expect(r.reason).toContain('invoke matching Skill');
|
||
});
|
||
it('contentScopeCheck hard_block reason contains "не соответствует"', () => {
|
||
const r = contentScopeCheck('Edit', 'app/Models/User.php', writingPlans);
|
||
expect(r.action).toBe('hard_block');
|
||
expect(r.reason).toContain('не соответствует');
|
||
});
|
||
it('maxCallsCheck hard_block reason contains "Skill scope exceeded"', () => {
|
||
const t = { ...mk(), tool_count_since_skill: 30, expected_output_written: false };
|
||
const r = maxCallsCheck(t);
|
||
expect(r.action).toBe('hard_block');
|
||
expect(r.reason).toContain('Skill scope exceeded');
|
||
});
|
||
});
|
||
|
||
describe('updateTracker — accumulation', () => {
|
||
it('chain Read×3 + Bash×2 → ratio 0.4 → hard_block', () => {
|
||
let t = mk();
|
||
t = updateTracker(t, 'Read', {});
|
||
t = updateTracker(t, 'Read', {});
|
||
t = updateTracker(t, 'Read', {});
|
||
t = updateTracker(t, 'Bash', {});
|
||
t = updateTracker(t, 'Bash', {});
|
||
expect(t.tool_count_since_skill).toBe(5);
|
||
expect(t.off_scope_count).toBe(2);
|
||
expect(evaluateScope(t).action).toBe('hard_block');
|
||
});
|
||
it('off_scope never increments when expected_tools is empty', () => {
|
||
const tracker = initTracker({
|
||
skillInvoked: 'foo', turnId: 't2', toolUseIdOfSkill: 'u2',
|
||
scopeConfig: { expected_tools: [], max_tool_calls_post_skill: 100 },
|
||
tsSkillInvokedIso: '2026-05-29T00:00:00Z',
|
||
});
|
||
const t = updateTracker(updateTracker(tracker, 'Bash', {}), 'Edit', {});
|
||
expect(t.off_scope_count).toBe(0);
|
||
});
|
||
});
|