@su-record/vibe 2.12.4 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +10 -3
- package/README.en.md +11 -11
- package/README.md +7 -7
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +24 -1
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/postinstall/claude-agents.d.ts +11 -1
- package/dist/cli/postinstall/claude-agents.d.ts.map +1 -1
- package/dist/cli/postinstall/claude-agents.js +22 -2
- package/dist/cli/postinstall/claude-agents.js.map +1 -1
- package/dist/cli/postinstall/constants.d.ts +18 -0
- package/dist/cli/postinstall/constants.d.ts.map +1 -1
- package/dist/cli/postinstall/constants.js +50 -0
- package/dist/cli/postinstall/constants.js.map +1 -1
- package/dist/cli/postinstall/fs-utils.d.ts +23 -0
- package/dist/cli/postinstall/fs-utils.d.ts.map +1 -1
- package/dist/cli/postinstall/fs-utils.js +71 -0
- package/dist/cli/postinstall/fs-utils.js.map +1 -1
- package/dist/cli/postinstall/fs-utils.test.js +69 -1
- package/dist/cli/postinstall/fs-utils.test.js.map +1 -1
- package/dist/cli/postinstall/index.d.ts +1 -1
- package/dist/cli/postinstall/index.d.ts.map +1 -1
- package/dist/cli/postinstall/index.js +1 -1
- package/dist/cli/postinstall/index.js.map +1 -1
- package/dist/cli/postinstall/main.d.ts.map +1 -1
- package/dist/cli/postinstall/main.js +15 -4
- package/dist/cli/postinstall/main.js.map +1 -1
- package/dist/cli/postinstall.d.ts +1 -1
- package/dist/cli/postinstall.d.ts.map +1 -1
- package/dist/cli/postinstall.js +1 -1
- package/dist/cli/postinstall.js.map +1 -1
- package/dist/cli/setup/CodexHooks.test.js +27 -0
- package/dist/cli/setup/CodexHooks.test.js.map +1 -1
- package/dist/cli/setup/ProjectSetup.js +2 -2
- package/dist/cli/setup/ProjectSetup.js.map +1 -1
- package/dist/infra/lib/ContextCompressor.d.ts +11 -2
- package/dist/infra/lib/ContextCompressor.d.ts.map +1 -1
- package/dist/infra/lib/ContextCompressor.js +26 -41
- package/dist/infra/lib/ContextCompressor.js.map +1 -1
- package/dist/infra/lib/ContextCompressor.test.d.ts +2 -0
- package/dist/infra/lib/ContextCompressor.test.d.ts.map +1 -0
- package/dist/infra/lib/ContextCompressor.test.js +25 -0
- package/dist/infra/lib/ContextCompressor.test.js.map +1 -0
- package/dist/infra/lib/DecisionTracer.d.ts +4 -0
- package/dist/infra/lib/DecisionTracer.d.ts.map +1 -1
- package/dist/infra/lib/DecisionTracer.js +4 -0
- package/dist/infra/lib/DecisionTracer.js.map +1 -1
- package/dist/infra/lib/LoopBreaker.d.ts +4 -0
- package/dist/infra/lib/LoopBreaker.d.ts.map +1 -1
- package/dist/infra/lib/LoopBreaker.js +4 -0
- package/dist/infra/lib/LoopBreaker.js.map +1 -1
- package/dist/infra/lib/ReviewRace.d.ts +4 -0
- package/dist/infra/lib/ReviewRace.d.ts.map +1 -1
- package/dist/infra/lib/ReviewRace.js +4 -0
- package/dist/infra/lib/ReviewRace.js.map +1 -1
- package/dist/infra/lib/SkillQualityGate.d.ts +4 -0
- package/dist/infra/lib/SkillQualityGate.d.ts.map +1 -1
- package/dist/infra/lib/SkillQualityGate.js +4 -0
- package/dist/infra/lib/SkillQualityGate.js.map +1 -1
- package/dist/infra/lib/UltraQA.d.ts +4 -0
- package/dist/infra/lib/UltraQA.d.ts.map +1 -1
- package/dist/infra/lib/UltraQA.js +4 -0
- package/dist/infra/lib/UltraQA.js.map +1 -1
- package/dist/infra/lib/VerificationLoop.d.ts +4 -0
- package/dist/infra/lib/VerificationLoop.d.ts.map +1 -1
- package/dist/infra/lib/VerificationLoop.js +4 -0
- package/dist/infra/lib/VerificationLoop.js.map +1 -1
- package/dist/infra/lib/embedding/VectorStore.d.ts +9 -2
- package/dist/infra/lib/embedding/VectorStore.d.ts.map +1 -1
- package/dist/infra/lib/embedding/VectorStore.js +42 -19
- package/dist/infra/lib/embedding/VectorStore.js.map +1 -1
- package/dist/infra/lib/memory/MemoryStorage.d.ts +12 -0
- package/dist/infra/lib/memory/MemoryStorage.d.ts.map +1 -1
- package/dist/infra/lib/memory/MemoryStorage.js +57 -0
- package/dist/infra/lib/memory/MemoryStorage.js.map +1 -1
- package/dist/infra/lib/memory/ReflectionStore.d.ts.map +1 -1
- package/dist/infra/lib/memory/ReflectionStore.js +8 -27
- package/dist/infra/lib/memory/ReflectionStore.js.map +1 -1
- package/dist/infra/orchestrator/LLMCluster.d.ts +4 -0
- package/dist/infra/orchestrator/LLMCluster.d.ts.map +1 -1
- package/dist/infra/orchestrator/LLMCluster.js +35 -8
- package/dist/infra/orchestrator/LLMCluster.js.map +1 -1
- package/dist/infra/orchestrator/index.d.ts.map +1 -1
- package/dist/infra/orchestrator/index.js +1 -3
- package/dist/infra/orchestrator/index.js.map +1 -1
- package/dist/infra/orchestrator/parallelResearch.d.ts.map +1 -1
- package/dist/infra/orchestrator/parallelResearch.js +1 -4
- package/dist/infra/orchestrator/parallelResearch.js.map +1 -1
- package/dist/tools/convention/validateCodeQuality.d.ts.map +1 -1
- package/dist/tools/convention/validateCodeQuality.js +5 -4
- package/dist/tools/convention/validateCodeQuality.js.map +1 -1
- package/dist/tools/spec/traceabilityMatrix.d.ts +2 -0
- package/dist/tools/spec/traceabilityMatrix.d.ts.map +1 -1
- package/dist/tools/spec/traceabilityMatrix.js +50 -1
- package/dist/tools/spec/traceabilityMatrix.js.map +1 -1
- package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts +10 -0
- package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts.map +1 -0
- package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js +89 -0
- package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js.map +1 -0
- package/dist/tools/spec/traceabilityMatrix.test.js +19 -0
- package/dist/tools/spec/traceabilityMatrix.test.js.map +1 -1
- package/hooks/hooks.json +3 -9
- package/hooks/scripts/__tests__/.vibe/command-log.txt +39 -0
- package/hooks/scripts/__tests__/.vibe/memories/memories.db +0 -0
- package/hooks/scripts/__tests__/.vibe/memories/memories.db-shm +0 -0
- package/hooks/scripts/__tests__/.vibe/memories/memories.db-wal +0 -0
- package/hooks/scripts/__tests__/auto-test-debounce.test.js +145 -0
- package/hooks/scripts/__tests__/code-check-detectors.test.js +155 -0
- package/hooks/scripts/__tests__/dispatcher-inprocess.test.js +99 -0
- package/hooks/scripts/__tests__/post-edit-dispatcher.test.js +139 -0
- package/hooks/scripts/__tests__/pre-tool-guard.test.js +115 -1
- package/hooks/scripts/__tests__/run-ledger-verify-required.test.js +146 -0
- package/hooks/scripts/__tests__/run-ledger.test.js +330 -0
- package/hooks/scripts/__tests__/scope-from-spec.test.js +215 -0
- package/hooks/scripts/__tests__/sentinel-guard.test.js +79 -24
- package/hooks/scripts/__tests__/step-counter.test.js +95 -15
- package/hooks/scripts/__tests__/utils-npm-root.test.js +98 -0
- package/hooks/scripts/auto-commit.js +30 -11
- package/hooks/scripts/auto-format.js +96 -26
- package/hooks/scripts/auto-test.js +187 -37
- package/hooks/scripts/code-check.js +292 -99
- package/hooks/scripts/codex-hook-adapter.js +12 -1
- package/hooks/scripts/command-log.js +26 -16
- package/hooks/scripts/lib/dispatcher.js +38 -0
- package/hooks/scripts/lib/hook-context.js +101 -0
- package/hooks/scripts/lib/pr-gate-runner.js +62 -0
- package/hooks/scripts/lib/run-ledger.js +169 -0
- package/hooks/scripts/lib/scope-from-spec.js +40 -7
- package/hooks/scripts/post-edit-dispatcher.js +93 -20
- package/hooks/scripts/post-edit.js +40 -19
- package/hooks/scripts/pr-test-gate.js +8 -37
- package/hooks/scripts/pre-tool-dispatcher.js +18 -16
- package/hooks/scripts/pre-tool-guard.js +55 -52
- package/hooks/scripts/prompt-dispatcher.js +10 -0
- package/hooks/scripts/scope-guard.js +40 -39
- package/hooks/scripts/sentinel-guard.js +41 -41
- package/hooks/scripts/session-start.js +41 -16
- package/hooks/scripts/step-counter.js +100 -7
- package/hooks/scripts/stop-dispatcher.js +26 -0
- package/hooks/scripts/utils.js +96 -30
- package/hooks/scripts/verify-ledger.js +22 -0
- package/package.json +2 -2
- package/skills/arch-guard/SKILL.md +2 -2
- package/skills/characterization-test/SKILL.md +2 -2
- package/skills/exec-plan/SKILL.md +2 -2
- package/skills/spec/SKILL.md +6 -312
- package/skills/spec/references/askuser-examples.md +57 -0
- package/skills/spec/references/example-session.md +87 -0
- package/skills/spec/references/templates.md +194 -0
- package/skills/vibe.run/SKILL.md +145 -1682
- package/skills/vibe.run/references/brand-assets.md +59 -0
- package/skills/vibe.run/references/parallel-agents.md +326 -0
- package/skills/vibe.run/references/race-review.md +272 -0
- package/skills/vibe.run/references/ralph-loop.md +172 -0
- package/skills/vibe.run/references/ultrawork-mode.md +148 -0
- package/skills/vibe.trace/SKILL.md +25 -38
- package/skills/vibe.verify/SKILL.md +15 -0
- package/vibe/templates/claudemd-template.md +1 -1
- package/hooks/scripts/figma-guard.js +0 -219
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope-from-spec.js 단위 테스트
|
|
3
|
+
*
|
|
4
|
+
* REQ-harness-remediation-009: scope-guard 기본 활성화
|
|
5
|
+
* - enabled 미설정(undefined) → sync 실행 (기본 ON)
|
|
6
|
+
* - enabled === false → skip (명시적 opt-out)
|
|
7
|
+
* - SPEC 없음 → scope.json 생성 안 함 (노이즈 가드)
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import { isScopeGuardEnabled, syncScopeFile, findActiveSpecs } from '../lib/scope-from-spec.js';
|
|
14
|
+
|
|
15
|
+
// 임시 프로젝트 디렉토리 헬퍼
|
|
16
|
+
function makeTempDir() {
|
|
17
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-scope-test-'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cleanDir(dir) {
|
|
21
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// config.json 작성 헬퍼
|
|
25
|
+
function writeConfig(dir, scopeGuard) {
|
|
26
|
+
const vibeDir = path.join(dir, '.vibe');
|
|
27
|
+
fs.mkdirSync(vibeDir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(path.join(vibeDir, 'config.json'), JSON.stringify({ scopeGuard }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 최소 SPEC 작성 헬퍼
|
|
32
|
+
function writeSpec(dir, filename, content, status = 'in-progress') {
|
|
33
|
+
const specsDir = path.join(dir, '.vibe', 'specs');
|
|
34
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
35
|
+
const frontmatter = status ? `---\nstatus: ${status}\n---\n` : '';
|
|
36
|
+
fs.writeFileSync(path.join(specsDir, filename), frontmatter + (content || ''));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ══════════════════════════════════════════════════
|
|
40
|
+
// isScopeGuardEnabled — 기본 ON 동작
|
|
41
|
+
// ══════════════════════════════════════════════════
|
|
42
|
+
describe('isScopeGuardEnabled', () => {
|
|
43
|
+
let tmpDir;
|
|
44
|
+
beforeEach(() => { tmpDir = makeTempDir(); });
|
|
45
|
+
afterEach(() => { cleanDir(tmpDir); });
|
|
46
|
+
|
|
47
|
+
it('config 없음 → true (기본 ON)', () => {
|
|
48
|
+
expect(isScopeGuardEnabled(tmpDir)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('scopeGuard 키 없는 config → true', () => {
|
|
52
|
+
writeConfig(tmpDir, undefined);
|
|
53
|
+
expect(isScopeGuardEnabled(tmpDir)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('scopeGuard.enabled 없는 config → true', () => {
|
|
57
|
+
const vibeDir = path.join(tmpDir, '.vibe');
|
|
58
|
+
fs.mkdirSync(vibeDir, { recursive: true });
|
|
59
|
+
fs.writeFileSync(path.join(vibeDir, 'config.json'), JSON.stringify({ scopeGuard: {} }));
|
|
60
|
+
expect(isScopeGuardEnabled(tmpDir)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('scopeGuard.enabled=true → true', () => {
|
|
64
|
+
writeConfig(tmpDir, { enabled: true });
|
|
65
|
+
expect(isScopeGuardEnabled(tmpDir)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('scopeGuard.enabled=false → false (명시적 opt-out)', () => {
|
|
69
|
+
writeConfig(tmpDir, { enabled: false });
|
|
70
|
+
expect(isScopeGuardEnabled(tmpDir)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('legacy .claude/vibe/config.json에서도 enabled=false 인식', () => {
|
|
74
|
+
const legacyDir = path.join(tmpDir, '.claude', 'vibe');
|
|
75
|
+
fs.mkdirSync(legacyDir, { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(legacyDir, 'config.json'), JSON.stringify({ scopeGuard: { enabled: false } }));
|
|
77
|
+
expect(isScopeGuardEnabled(tmpDir)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ══════════════════════════════════════════════════
|
|
82
|
+
// syncScopeFile — 노이즈 가드: SPEC 없으면 생성 안 함
|
|
83
|
+
// ══════════════════════════════════════════════════
|
|
84
|
+
describe('syncScopeFile — no specs → no scope created', () => {
|
|
85
|
+
let tmpDir;
|
|
86
|
+
beforeEach(() => { tmpDir = makeTempDir(); });
|
|
87
|
+
afterEach(() => { cleanDir(tmpDir); });
|
|
88
|
+
|
|
89
|
+
it('SPEC 없음 → skipped-no-specs, scope.json 미생성', () => {
|
|
90
|
+
fs.mkdirSync(path.join(tmpDir, '.vibe'), { recursive: true });
|
|
91
|
+
const result = syncScopeFile(tmpDir);
|
|
92
|
+
expect(result.action).toBe('skipped-no-specs');
|
|
93
|
+
expect(fs.existsSync(path.join(tmpDir, '.vibe', 'scope.json'))).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('specs 디렉토리 없음 → skipped-no-specs', () => {
|
|
97
|
+
const result = syncScopeFile(tmpDir);
|
|
98
|
+
expect(result.action).toBe('skipped-no-specs');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ══════════════════════════════════════════════════
|
|
103
|
+
// syncScopeFile — SPEC 있을 때 sync 실행
|
|
104
|
+
// ══════════════════════════════════════════════════
|
|
105
|
+
describe('syncScopeFile — spec exists → scope created', () => {
|
|
106
|
+
let tmpDir;
|
|
107
|
+
beforeEach(() => { tmpDir = makeTempDir(); });
|
|
108
|
+
afterEach(() => { cleanDir(tmpDir); });
|
|
109
|
+
|
|
110
|
+
// 파생 glob은 실존 디렉토리만 통과하므로, 참조 경로를 실제로 만들어 준다
|
|
111
|
+
function materialize(dir, relFile) {
|
|
112
|
+
const full = path.join(dir, relFile);
|
|
113
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
114
|
+
fs.writeFileSync(full, '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
it('활성 SPEC 있음 → scope.json 생성', () => {
|
|
118
|
+
writeSpec(tmpDir, 'feature.md', 'edit `src/cli/index.ts`');
|
|
119
|
+
materialize(tmpDir, 'src/cli/index.ts');
|
|
120
|
+
const result = syncScopeFile(tmpDir);
|
|
121
|
+
expect(result.action).toBe('created');
|
|
122
|
+
const scopeJson = path.join(tmpDir, '.vibe', 'scope.json');
|
|
123
|
+
expect(fs.existsSync(scopeJson)).toBe(true);
|
|
124
|
+
const scope = JSON.parse(fs.readFileSync(scopeJson, 'utf-8'));
|
|
125
|
+
expect(scope.auto).toBe(true);
|
|
126
|
+
expect(Array.isArray(scope.allow)).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('status 없는 최근 SPEC은 활성 간주 → scope.json 생성', () => {
|
|
130
|
+
writeSpec(tmpDir, 'no-status.md', 'modify `hooks/scripts/foo.js`', null);
|
|
131
|
+
materialize(tmpDir, 'hooks/scripts/foo.js');
|
|
132
|
+
const result = syncScopeFile(tmpDir);
|
|
133
|
+
expect(result.action).toBe('created');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('status 없는 오래된 SPEC(>14일)은 비활성 → skipped-no-specs', () => {
|
|
137
|
+
writeSpec(tmpDir, 'stale.md', 'modify `hooks/scripts/foo.js`', null);
|
|
138
|
+
materialize(tmpDir, 'hooks/scripts/foo.js');
|
|
139
|
+
const old = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000);
|
|
140
|
+
fs.utimesSync(path.join(tmpDir, '.vibe', 'specs', 'stale.md'), old, old);
|
|
141
|
+
const result = syncScopeFile(tmpDir);
|
|
142
|
+
expect(result.action).toBe('skipped-no-specs');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('활성 SPEC에 산출 가능한 경로 없음 → skipped-no-paths, scope.json 미생성', () => {
|
|
146
|
+
writeSpec(tmpDir, 'no-paths.md', 'just prose, `nonexistent/dir/file` reference only');
|
|
147
|
+
const result = syncScopeFile(tmpDir);
|
|
148
|
+
expect(result.action).toBe('skipped-no-paths');
|
|
149
|
+
expect(fs.existsSync(path.join(tmpDir, '.vibe', 'scope.json'))).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('경로 산출 불가로 전환되면 기존 auto scope.json 제거', () => {
|
|
153
|
+
writeSpec(tmpDir, 'feature.md', 'edit `src/cli/index.ts`');
|
|
154
|
+
materialize(tmpDir, 'src/cli/index.ts');
|
|
155
|
+
syncScopeFile(tmpDir); // 생성
|
|
156
|
+
fs.rmSync(path.join(tmpDir, 'src'), { recursive: true, force: true });
|
|
157
|
+
const result = syncScopeFile(tmpDir);
|
|
158
|
+
expect(result.action).toBe('removed');
|
|
159
|
+
expect(fs.existsSync(path.join(tmpDir, '.vibe', 'scope.json'))).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('completed SPEC만 있을 때 → skipped-no-specs (비활성 SPEC은 제외)', () => {
|
|
163
|
+
writeSpec(tmpDir, 'done.md', 'edit `src/foo.ts`', 'completed');
|
|
164
|
+
const result = syncScopeFile(tmpDir);
|
|
165
|
+
expect(result.action).toBe('skipped-no-specs');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('수동 관리 scope.json(auto:false) → 건드리지 않음', () => {
|
|
169
|
+
writeSpec(tmpDir, 'feature.md', 'edit `src/bar.ts`');
|
|
170
|
+
const scopeDir = path.join(tmpDir, '.vibe');
|
|
171
|
+
fs.mkdirSync(scopeDir, { recursive: true });
|
|
172
|
+
const manual = { auto: false, mode: 'block', allow: ['src/**'], deny: [] };
|
|
173
|
+
fs.writeFileSync(path.join(scopeDir, 'scope.json'), JSON.stringify(manual));
|
|
174
|
+
const result = syncScopeFile(tmpDir);
|
|
175
|
+
expect(result.action).toBe('skipped-manual');
|
|
176
|
+
const existing = JSON.parse(fs.readFileSync(path.join(scopeDir, 'scope.json'), 'utf-8'));
|
|
177
|
+
expect(existing.mode).toBe('block'); // 원본 유지
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('활성 SPEC 제거 후 재호출 → auto scope.json 삭제', () => {
|
|
181
|
+
writeSpec(tmpDir, 'feature.md', 'edit `src/cli/index.ts`');
|
|
182
|
+
materialize(tmpDir, 'src/cli/index.ts');
|
|
183
|
+
syncScopeFile(tmpDir); // 생성
|
|
184
|
+
// specs 디렉토리 비우기
|
|
185
|
+
fs.rmSync(path.join(tmpDir, '.vibe', 'specs'), { recursive: true, force: true });
|
|
186
|
+
const result = syncScopeFile(tmpDir);
|
|
187
|
+
expect(result.action).toBe('removed');
|
|
188
|
+
expect(fs.existsSync(path.join(tmpDir, '.vibe', 'scope.json'))).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ══════════════════════════════════════════════════
|
|
193
|
+
// findActiveSpecs — legacy .claude/vibe/specs 폴백
|
|
194
|
+
// ══════════════════════════════════════════════════
|
|
195
|
+
describe('findActiveSpecs — resolution order', () => {
|
|
196
|
+
let tmpDir;
|
|
197
|
+
beforeEach(() => { tmpDir = makeTempDir(); });
|
|
198
|
+
afterEach(() => { cleanDir(tmpDir); });
|
|
199
|
+
|
|
200
|
+
it('.vibe/specs 우선', () => {
|
|
201
|
+
writeSpec(tmpDir, 'main.md', 'content');
|
|
202
|
+
const specs = findActiveSpecs(tmpDir);
|
|
203
|
+
expect(specs.length).toBe(1);
|
|
204
|
+
expect(specs[0]).toContain('.vibe');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('.vibe 없고 .claude/vibe/specs 있으면 legacy 사용', () => {
|
|
208
|
+
const legacySpecsDir = path.join(tmpDir, '.claude', 'vibe', 'specs');
|
|
209
|
+
fs.mkdirSync(legacySpecsDir, { recursive: true });
|
|
210
|
+
fs.writeFileSync(path.join(legacySpecsDir, 'old.md'), '---\nstatus: in-progress\n---\ncontent');
|
|
211
|
+
const specs = findActiveSpecs(tmpDir);
|
|
212
|
+
expect(specs.length).toBe(1);
|
|
213
|
+
expect(specs[0]).toContain('.claude');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -24,7 +24,6 @@ function runGuard(args = []) {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Run sentinel-guard.js with stdin JSON payload.
|
|
27
|
-
* 스크립트가 fs.readSync(0, ...)로 stdin을 읽으므로 execFileSync input 옵션이 동작.
|
|
28
27
|
*/
|
|
29
28
|
function runGuardWithStdin(payload) {
|
|
30
29
|
const json = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
@@ -41,24 +40,25 @@ function runGuardWithStdin(payload) {
|
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
// ══════════════════════════════════════════════════
|
|
44
|
-
// Sentinel path protection
|
|
43
|
+
// Sentinel path protection — evolution machinery
|
|
45
44
|
// ══════════════════════════════════════════════════
|
|
46
45
|
describe('sentinel-guard', () => {
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// ─── 실제 보호 경로: src/infra/lib/evolution/ ───
|
|
47
|
+
describe('Write/Edit to evolution sentinel path via argv', () => {
|
|
48
|
+
it('should block Write to src/infra/lib/evolution/', () => {
|
|
49
49
|
const result = runGuard([
|
|
50
50
|
'Write',
|
|
51
|
-
JSON.stringify({ file_path: 'src/infra/lib/
|
|
51
|
+
JSON.stringify({ file_path: 'src/infra/lib/evolution/EvolutionOrchestrator.ts' }),
|
|
52
52
|
]);
|
|
53
53
|
expect(result.exitCode).toBe(2);
|
|
54
54
|
expect(result.stdout).toContain('block');
|
|
55
55
|
expect(result.stdout).toContain('Sentinel files are protected');
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it('should block Edit to sentinel path', () => {
|
|
58
|
+
it('should block Edit to evolution sentinel path', () => {
|
|
59
59
|
const result = runGuard([
|
|
60
60
|
'Edit',
|
|
61
|
-
JSON.stringify({ file_path: 'src/infra/lib/
|
|
61
|
+
JSON.stringify({ file_path: 'src/infra/lib/evolution/GuardAnalyzer.ts' }),
|
|
62
62
|
]);
|
|
63
63
|
expect(result.exitCode).toBe(2);
|
|
64
64
|
expect(result.stdout).toContain('block');
|
|
@@ -67,7 +67,7 @@ describe('sentinel-guard', () => {
|
|
|
67
67
|
it('should block Write with backslash path separators', () => {
|
|
68
68
|
const result = runGuard([
|
|
69
69
|
'Write',
|
|
70
|
-
JSON.stringify({ file_path: 'src\\infra\\lib\\
|
|
70
|
+
JSON.stringify({ file_path: 'src\\infra\\lib\\evolution\\file.ts' }),
|
|
71
71
|
]);
|
|
72
72
|
expect(result.exitCode).toBe(2);
|
|
73
73
|
expect(result.stdout).toContain('Sentinel files are protected');
|
|
@@ -76,32 +76,62 @@ describe('sentinel-guard', () => {
|
|
|
76
76
|
it('should block Write with ./ prefix', () => {
|
|
77
77
|
const result = runGuard([
|
|
78
78
|
'Write',
|
|
79
|
-
JSON.stringify({ file_path: './src/infra/lib/
|
|
79
|
+
JSON.stringify({ file_path: './src/infra/lib/evolution/index.ts' }),
|
|
80
80
|
]);
|
|
81
81
|
expect(result.exitCode).toBe(2);
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
// ─── 실제 보호 경로: hooks/scripts/lib/ ───
|
|
86
|
+
describe('Write/Edit to hooks/scripts/lib/ sentinel path', () => {
|
|
87
|
+
it('should block Write to hooks/scripts/lib/', () => {
|
|
88
|
+
const result = runGuard([
|
|
89
|
+
'Write',
|
|
90
|
+
JSON.stringify({ file_path: 'hooks/scripts/lib/dispatcher.js' }),
|
|
91
|
+
]);
|
|
92
|
+
expect(result.exitCode).toBe(2);
|
|
93
|
+
expect(result.stdout).toContain('Sentinel files are protected');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should block Edit to hook-context.js', () => {
|
|
97
|
+
const result = runGuard([
|
|
98
|
+
'Edit',
|
|
99
|
+
JSON.stringify({ file_path: 'hooks/scripts/lib/hook-context.js' }),
|
|
100
|
+
]);
|
|
101
|
+
expect(result.exitCode).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ─── stdin 경로 ───
|
|
85
106
|
describe('Write/Edit to sentinel paths via stdin', () => {
|
|
86
|
-
it('should block Edit via stdin payload', () => {
|
|
107
|
+
it('should block Edit evolution path via stdin payload', () => {
|
|
87
108
|
const result = runGuardWithStdin({
|
|
88
109
|
tool_name: 'Edit',
|
|
89
|
-
tool_input: { file_path: './src/infra/lib/
|
|
110
|
+
tool_input: { file_path: './src/infra/lib/evolution/InsightStore.ts' },
|
|
90
111
|
});
|
|
91
112
|
expect(result.exitCode).toBe(2);
|
|
92
113
|
expect(result.stdout).toContain('block');
|
|
93
114
|
});
|
|
94
115
|
|
|
95
|
-
it('should block Write via stdin payload', () => {
|
|
116
|
+
it('should block Write evolution path via stdin payload', () => {
|
|
96
117
|
const result = runGuardWithStdin({
|
|
97
118
|
tool_name: 'Write',
|
|
98
|
-
tool_input: { file_path: 'src/infra/lib/
|
|
119
|
+
tool_input: { file_path: 'src/infra/lib/evolution/CircuitBreaker.ts' },
|
|
99
120
|
});
|
|
100
121
|
expect(result.exitCode).toBe(2);
|
|
101
122
|
expect(result.stdout).toContain('Sentinel files are protected');
|
|
102
123
|
});
|
|
124
|
+
|
|
125
|
+
it('should block Write hooks/scripts/lib/ path via stdin', () => {
|
|
126
|
+
const result = runGuardWithStdin({
|
|
127
|
+
tool_name: 'Write',
|
|
128
|
+
tool_input: { file_path: 'hooks/scripts/lib/run-ledger.js' },
|
|
129
|
+
});
|
|
130
|
+
expect(result.exitCode).toBe(2);
|
|
131
|
+
});
|
|
103
132
|
});
|
|
104
133
|
|
|
134
|
+
// ─── 허용 경로 ───
|
|
105
135
|
describe('allowed operations', () => {
|
|
106
136
|
it('should allow Write to non-sentinel paths', () => {
|
|
107
137
|
const result = runGuard([
|
|
@@ -114,7 +144,7 @@ describe('sentinel-guard', () => {
|
|
|
114
144
|
it('should allow Read to sentinel paths (read is not blocked)', () => {
|
|
115
145
|
const result = runGuard([
|
|
116
146
|
'Read',
|
|
117
|
-
JSON.stringify({ file_path: 'src/infra/lib/
|
|
147
|
+
JSON.stringify({ file_path: 'src/infra/lib/evolution/GuardAnalyzer.ts' }),
|
|
118
148
|
]);
|
|
119
149
|
expect(result.exitCode).toBe(0);
|
|
120
150
|
});
|
|
@@ -126,23 +156,40 @@ describe('sentinel-guard', () => {
|
|
|
126
156
|
]);
|
|
127
157
|
expect(result.exitCode).toBe(0);
|
|
128
158
|
});
|
|
159
|
+
|
|
160
|
+
it('should allow Write to hooks/scripts/ top level (not lib/ subdir)', () => {
|
|
161
|
+
const result = runGuard([
|
|
162
|
+
'Write',
|
|
163
|
+
JSON.stringify({ file_path: 'hooks/scripts/step-counter.js' }),
|
|
164
|
+
]);
|
|
165
|
+
expect(result.exitCode).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should allow Write to src/infra/lib/ parent (not evolution subdir)', () => {
|
|
169
|
+
const result = runGuard([
|
|
170
|
+
'Write',
|
|
171
|
+
JSON.stringify({ file_path: 'src/infra/lib/constants.ts' }),
|
|
172
|
+
]);
|
|
173
|
+
expect(result.exitCode).toBe(0);
|
|
174
|
+
});
|
|
129
175
|
});
|
|
130
176
|
|
|
177
|
+
// ─── 위험한 bash + sentinel 경로 ───
|
|
131
178
|
describe('dangerous bash commands targeting sentinel paths', () => {
|
|
132
|
-
it('should block rm -rf targeting
|
|
179
|
+
it('should block rm -rf targeting evolution path', () => {
|
|
133
180
|
const result = runGuard([
|
|
134
181
|
'Bash',
|
|
135
|
-
JSON.stringify({ command: 'rm -rf src/infra/lib/
|
|
182
|
+
JSON.stringify({ command: 'rm -rf src/infra/lib/evolution/' }),
|
|
136
183
|
]);
|
|
137
184
|
expect(result.exitCode).toBe(2);
|
|
138
185
|
expect(result.stdout).toContain('block');
|
|
139
186
|
expect(result.stdout).toContain('Dangerous command targeting sentinel path');
|
|
140
187
|
});
|
|
141
188
|
|
|
142
|
-
it('should block
|
|
189
|
+
it('should block rm -rf targeting hooks/scripts/lib/', () => {
|
|
143
190
|
const result = runGuard([
|
|
144
191
|
'Bash',
|
|
145
|
-
JSON.stringify({ command: '
|
|
192
|
+
JSON.stringify({ command: 'rm -rf hooks/scripts/lib/' }),
|
|
146
193
|
]);
|
|
147
194
|
expect(result.exitCode).toBe(2);
|
|
148
195
|
});
|
|
@@ -164,33 +211,41 @@ describe('sentinel-guard', () => {
|
|
|
164
211
|
});
|
|
165
212
|
});
|
|
166
213
|
|
|
214
|
+
// ─── Bash 명령어 문자열이 sentinel 경로로 시작하는 경우 ───
|
|
167
215
|
describe('Bash command containing sentinel path in command string', () => {
|
|
168
|
-
it('should block when command string itself starts with sentinel path', () => {
|
|
216
|
+
it('should block when command string itself starts with evolution sentinel path', () => {
|
|
169
217
|
const result = runGuard([
|
|
170
218
|
'Bash',
|
|
171
|
-
JSON.stringify({ command: 'src/infra/lib/
|
|
219
|
+
JSON.stringify({ command: 'src/infra/lib/evolution/run.sh' }),
|
|
172
220
|
]);
|
|
173
221
|
expect(result.exitCode).toBe(2);
|
|
174
222
|
expect(result.stdout).toContain('Sentinel files are protected');
|
|
175
223
|
});
|
|
176
224
|
|
|
225
|
+
it('should block when command string starts with hooks/scripts/lib/', () => {
|
|
226
|
+
const result = runGuard([
|
|
227
|
+
'Bash',
|
|
228
|
+
JSON.stringify({ command: 'hooks/scripts/lib/dispatcher.js' }),
|
|
229
|
+
]);
|
|
230
|
+
expect(result.exitCode).toBe(2);
|
|
231
|
+
});
|
|
232
|
+
|
|
177
233
|
it('should not block non-dangerous commands referencing sentinel path mid-string', () => {
|
|
178
|
-
// isSentinelPath only checks startsWith, and the DANGEROUS_BASH_RE +
|
|
179
|
-
// includes check requires both a dangerous command and sentinel path
|
|
180
234
|
const result = runGuard([
|
|
181
235
|
'Bash',
|
|
182
|
-
JSON.stringify({ command: 'cat src/infra/lib/
|
|
236
|
+
JSON.stringify({ command: 'cat src/infra/lib/evolution/GuardAnalyzer.ts | wc -l' }),
|
|
183
237
|
]);
|
|
184
238
|
// 'cat' is not a dangerous command, command does not start with sentinel path
|
|
185
239
|
expect(result.exitCode).toBe(0);
|
|
186
240
|
});
|
|
187
241
|
});
|
|
188
242
|
|
|
243
|
+
// ─── stdin vs argv 우선순위 ───
|
|
189
244
|
describe('stdin vs argv priority', () => {
|
|
190
245
|
it('should prefer stdin payload over argv', () => {
|
|
191
246
|
const payload = JSON.stringify({
|
|
192
247
|
tool_name: 'Write',
|
|
193
|
-
tool_input: { file_path: 'src/infra/lib/
|
|
248
|
+
tool_input: { file_path: 'src/infra/lib/evolution/x.ts' },
|
|
194
249
|
});
|
|
195
250
|
try {
|
|
196
251
|
execFileSync('node', [SCRIPT, 'Read', '{}'], {
|
|
@@ -75,27 +75,31 @@ describe('step-counter PostToolUse hook', () => {
|
|
|
75
75
|
expect(data.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
it('연속 호출 시 steps 누적', () => {
|
|
79
|
-
|
|
78
|
+
it('연속 호출 시 steps 누적 (10이벤트 재작성 주기 고려)', () => {
|
|
79
|
+
// 10이벤트마다 재작성 — 10회 호출 후에는 steps=10 이 보장됨
|
|
80
|
+
for (let i = 0; i < 10; i++) {
|
|
80
81
|
runCounter({
|
|
81
82
|
payload: { tool_name: 'Bash', tool_input: { command: 'echo hi' }, tool_response: {} },
|
|
82
83
|
projectDir,
|
|
83
84
|
});
|
|
84
85
|
}
|
|
85
86
|
const data = readJson(runJson);
|
|
86
|
-
expect(data.steps).toBe(
|
|
87
|
+
expect(data.steps).toBe(10);
|
|
87
88
|
});
|
|
88
89
|
|
|
89
|
-
it('손상된 JSON 이 있어도 새로 시작', () => {
|
|
90
|
+
it('손상된 JSON 이 있어도 새로 시작 (10회 후 재작성으로 확인)', () => {
|
|
90
91
|
fs.mkdirSync(path.join(projectDir, '.vibe', 'metrics'), { recursive: true });
|
|
91
92
|
fs.writeFileSync(runJson, '{ broken json');
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
// 10회 호출해야 재작성 주기 도달
|
|
94
|
+
for (let i = 0; i < 10; i++) {
|
|
95
|
+
runCounter({
|
|
96
|
+
payload: { tool_name: 'Bash', tool_input: {}, tool_response: {} },
|
|
97
|
+
projectDir,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
97
100
|
const data = readJson(runJson);
|
|
98
|
-
expect(data.steps).toBe(
|
|
101
|
+
expect(data.steps).toBe(10);
|
|
102
|
+
expect(data.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
99
103
|
});
|
|
100
104
|
});
|
|
101
105
|
|
|
@@ -149,7 +153,8 @@ describe('step-counter PostToolUse hook', () => {
|
|
|
149
153
|
expect(readJson(runJson).steps).toBe(1);
|
|
150
154
|
});
|
|
151
155
|
|
|
152
|
-
it('연속 호출 시 jsonl 누적', () => {
|
|
156
|
+
it('연속 호출 시 jsonl 누적 (Read는 조기 종료 — 기록 안 됨)', () => {
|
|
157
|
+
// Read 는 READ_ONLY_TOOLS 조기 종료 → jsonl 미기록
|
|
153
158
|
runCounter({
|
|
154
159
|
payload: { tool_name: 'Read', tool_input: { file_path: 'a.ts' }, tool_response: {} },
|
|
155
160
|
projectDir,
|
|
@@ -159,8 +164,8 @@ describe('step-counter PostToolUse hook', () => {
|
|
|
159
164
|
projectDir,
|
|
160
165
|
});
|
|
161
166
|
const lines = readJsonl(runJsonl);
|
|
162
|
-
expect(lines).toHaveLength(
|
|
163
|
-
expect(lines.
|
|
167
|
+
expect(lines).toHaveLength(1);
|
|
168
|
+
expect(lines[0].tool).toBe('Edit');
|
|
164
169
|
});
|
|
165
170
|
});
|
|
166
171
|
|
|
@@ -322,10 +327,10 @@ describe('step-counter PostToolUse hook', () => {
|
|
|
322
327
|
// 같은 카테고리 실패 2회
|
|
323
328
|
failBash('a', err, projectDir);
|
|
324
329
|
failBash('b', err, projectDir);
|
|
325
|
-
// 성공 툴콜로 윈도우 채움 (10줄 이상)
|
|
330
|
+
// 성공 툴콜로 윈도우 채움 (10줄 이상) — Bash 를 사용 (Read 는 조기 종료로 jsonl 미기록)
|
|
326
331
|
for (let i = 0; i < 10; i++) {
|
|
327
332
|
runCounter({
|
|
328
|
-
payload: { tool_name: '
|
|
333
|
+
payload: { tool_name: 'Bash', tool_input: { command: `echo ${i}` }, tool_response: {} },
|
|
329
334
|
projectDir,
|
|
330
335
|
});
|
|
331
336
|
}
|
|
@@ -355,4 +360,79 @@ describe('step-counter PostToolUse hook', () => {
|
|
|
355
360
|
expect(r.status).toBe(0);
|
|
356
361
|
});
|
|
357
362
|
});
|
|
363
|
+
|
|
364
|
+
// ───────── 읽기 전용 도구 조기 종료 (defense-in-depth) ─────────
|
|
365
|
+
describe('읽기 전용 도구 조기 종료', () => {
|
|
366
|
+
const READ_ONLY = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'ToolSearch', 'ListMcpResourcesTool'];
|
|
367
|
+
|
|
368
|
+
it.each(READ_ONLY)('%s 호출 시 파일 미생성 + exit 0', (toolName) => {
|
|
369
|
+
const r = runCounter({
|
|
370
|
+
payload: { tool_name: toolName, tool_input: { file_path: 'src/foo.ts' }, tool_response: {} },
|
|
371
|
+
projectDir,
|
|
372
|
+
});
|
|
373
|
+
expect(r.status).toBe(0);
|
|
374
|
+
// jsonl에 기록하지 않아야 함
|
|
375
|
+
const jsonlPath = path.join(projectDir, '.vibe', 'metrics', 'current-run.jsonl');
|
|
376
|
+
expect(fs.existsSync(jsonlPath)).toBe(false);
|
|
377
|
+
// current-run.json 도 생성하지 않아야 함
|
|
378
|
+
const jsonPath = path.join(projectDir, '.vibe', 'metrics', 'current-run.json');
|
|
379
|
+
expect(fs.existsSync(jsonPath)).toBe(false);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ───────── 쓰기 스로틀 ─────────
|
|
384
|
+
describe('쓰기 스로틀: jsonl 항상 append, json 조건부 재작성', () => {
|
|
385
|
+
it('첫 번째 이벤트는 current-run.json 생성 (파일 없음 → 스로틀 없음)', () => {
|
|
386
|
+
runCounter({
|
|
387
|
+
payload: { tool_name: 'Bash', tool_input: { command: 'x' }, tool_response: {} },
|
|
388
|
+
projectDir,
|
|
389
|
+
});
|
|
390
|
+
expect(fs.existsSync(runJson)).toBe(true);
|
|
391
|
+
expect(readJson(runJson).steps).toBe(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('10이벤트마다 강제 재작성: 10회 후 steps=jsonl라인수=10', () => {
|
|
395
|
+
for (let i = 0; i < 10; i++) {
|
|
396
|
+
runCounter({
|
|
397
|
+
payload: { tool_name: 'Edit', tool_input: { file_path: 'a.ts' }, tool_response: {} },
|
|
398
|
+
projectDir,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// 10번째 이벤트에서 강제 재작성 (10%10===0) — steps = jsonl 라인 수 = 10
|
|
402
|
+
const data = readJson(runJson);
|
|
403
|
+
expect(data.steps).toBe(10);
|
|
404
|
+
// jsonl 도 10줄
|
|
405
|
+
const lines = readJsonl(runJsonl);
|
|
406
|
+
expect(lines).toHaveLength(10);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('jsonl 은 스로틀 무관 항상 즉시 append', () => {
|
|
410
|
+
for (let i = 0; i < 5; i++) {
|
|
411
|
+
runCounter({
|
|
412
|
+
payload: { tool_name: 'Bash', tool_input: { command: `cmd-${i}` }, tool_response: {} },
|
|
413
|
+
projectDir,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const lines = readJsonl(runJsonl);
|
|
417
|
+
expect(lines).toHaveLength(5);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('3-fail 감지는 jsonl 기반 — json 스로틀 후에도 동작', () => {
|
|
421
|
+
const err = "TypeError: Cannot read properties of undefined (reading 'x')";
|
|
422
|
+
// 3회 실패 (jsonl에 즉시 기록되므로 감지 가능)
|
|
423
|
+
for (let i = 0; i < 3; i++) {
|
|
424
|
+
runCounter({
|
|
425
|
+
payload: {
|
|
426
|
+
tool_name: 'Bash',
|
|
427
|
+
tool_input: { command: `run-${i}` },
|
|
428
|
+
tool_response: { is_error: true, error: err },
|
|
429
|
+
},
|
|
430
|
+
projectDir,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const apDir = path.join(projectDir, '.vibe', 'anti-patterns');
|
|
434
|
+
const files = fs.existsSync(apDir) ? fs.readdirSync(apDir).filter(f => f.endsWith('.md')) : [];
|
|
435
|
+
expect(files).toHaveLength(1);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
358
438
|
});
|