@su-record/vibe 2.12.5 → 2.14.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 +25 -16
- package/README.en.md +16 -14
- package/README.md +13 -11
- package/dist/cli/postinstall/constants.d.ts.map +1 -1
- package/dist/cli/postinstall/constants.js +1 -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/main.d.ts.map +1 -1
- package/dist/cli/postinstall/main.js +12 -2
- package/dist/cli/postinstall/main.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.d.ts.map +1 -1
- package/dist/cli/setup/ProjectSetup.js +6 -5
- package/dist/cli/setup/ProjectSetup.js.map +1 -1
- 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/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/index.d.ts +2 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/loop/index.d.ts +6 -0
- package/dist/tools/loop/index.d.ts.map +1 -0
- package/dist/tools/loop/index.js +5 -0
- package/dist/tools/loop/index.js.map +1 -0
- package/dist/tools/loop/validateLoopDefinition.d.ts +38 -0
- package/dist/tools/loop/validateLoopDefinition.d.ts.map +1 -0
- package/dist/tools/loop/validateLoopDefinition.js +224 -0
- package/dist/tools/loop/validateLoopDefinition.js.map +1 -0
- package/dist/tools/loop/validateLoopDefinition.test.d.ts +14 -0
- package/dist/tools/loop/validateLoopDefinition.test.d.ts.map +1 -0
- package/dist/tools/loop/validateLoopDefinition.test.js +229 -0
- package/dist/tools/loop/validateLoopDefinition.test.js.map +1 -0
- 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 +1 -0
- package/hooks/scripts/__tests__/.vibe/command-log.txt +60 -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__/keyword-detector.test.js +26 -18
- package/hooks/scripts/__tests__/loop-ledger.test.js +321 -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 +27 -1
- package/hooks/scripts/auto-format.js +85 -20
- package/hooks/scripts/auto-test.js +187 -37
- package/hooks/scripts/code-check.js +286 -90
- package/hooks/scripts/codex-hook-adapter.js +12 -1
- package/hooks/scripts/command-log.js +26 -16
- package/hooks/scripts/keyword-detector.js +22 -22
- package/hooks/scripts/lib/dispatcher.js +38 -0
- package/hooks/scripts/lib/hook-context.js +130 -0
- package/hooks/scripts/lib/loop-ledger.js +118 -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/loop-ledger.js +56 -0
- 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 +13 -1
- package/hooks/scripts/step-counter.js +100 -7
- package/hooks/scripts/stop-dispatcher.js +26 -0
- package/hooks/scripts/utils.js +63 -21
- package/hooks/scripts/verify-ledger.js +22 -0
- package/package.json +2 -2
- package/skills/spec/references/templates.md +11 -6
- package/skills/vibe/SKILL.md +40 -23
- package/skills/vibe.loop/SKILL.md +116 -0
- package/skills/vibe.run/SKILL.md +153 -1686
- 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 +173 -0
- package/skills/vibe.run/references/ultrawork-mode.md +151 -0
- package/skills/vibe.trace/SKILL.md +25 -38
- package/skills/vibe.verify/SKILL.md +15 -0
- package/vibe/rules/loop-contract.md +54 -0
- package/vibe/templates/loop-template.md +69 -0
- package/hooks/scripts/figma-guard.js +0 -219
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loop-ledger 라이브러리 + CLI 테스트
|
|
3
|
+
*
|
|
4
|
+
* 커버리지:
|
|
5
|
+
* - appendLoopEvent round-trip
|
|
6
|
+
* - isStuck: 2회 연속 동일 hash → true
|
|
7
|
+
* - isStuck: 다른 hash → false
|
|
8
|
+
* - isStuck: 이력 없음 → false
|
|
9
|
+
* - fail-open: 손상된 jsonl 줄 무시
|
|
10
|
+
* - hashDiscoverOutput: 공백 정규화
|
|
11
|
+
* - CLI: start / end / check-stuck 서브커맨드
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import { spawnSync } from 'child_process';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
let tmpDir;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-loop-ledger-test-'));
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function importLedger() {
|
|
32
|
+
const p = path.resolve(__dirname, '..', 'lib', 'loop-ledger.js');
|
|
33
|
+
return import(p);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CLI = path.resolve(__dirname, '..', 'loop-ledger.js');
|
|
37
|
+
|
|
38
|
+
function historyPath(dir) {
|
|
39
|
+
return path.join(dir, '.vibe', 'metrics', 'loop-history.jsonl');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── appendLoopEvent round-trip ───────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
describe('loop-ledger: appendLoopEvent round-trip', () => {
|
|
45
|
+
it('start 이벤트를 append 하면 jsonl 파일에 기록된다', async () => {
|
|
46
|
+
const { appendLoopEvent } = await importLedger();
|
|
47
|
+
appendLoopEvent(tmpDir, { loop: 'nightly', event: 'start' });
|
|
48
|
+
|
|
49
|
+
const p = historyPath(tmpDir);
|
|
50
|
+
expect(fs.existsSync(p)).toBe(true);
|
|
51
|
+
const line = fs.readFileSync(p, 'utf-8').trim();
|
|
52
|
+
const obj = JSON.parse(line);
|
|
53
|
+
expect(obj.loop).toBe('nightly');
|
|
54
|
+
expect(obj.event).toBe('start');
|
|
55
|
+
expect(obj.ts).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('end 이벤트에 result, summary, discoverHash가 포함된다', async () => {
|
|
59
|
+
const { appendLoopEvent } = await importLedger();
|
|
60
|
+
appendLoopEvent(tmpDir, {
|
|
61
|
+
loop: 'nightly',
|
|
62
|
+
event: 'end',
|
|
63
|
+
result: 'ok',
|
|
64
|
+
summary: '3 items processed',
|
|
65
|
+
discoverHash: 'abc123',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const p = historyPath(tmpDir);
|
|
69
|
+
const line = fs.readFileSync(p, 'utf-8').trim();
|
|
70
|
+
const obj = JSON.parse(line);
|
|
71
|
+
expect(obj.event).toBe('end');
|
|
72
|
+
expect(obj.result).toBe('ok');
|
|
73
|
+
expect(obj.summary).toBe('3 items processed');
|
|
74
|
+
expect(obj.discoverHash).toBe('abc123');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('여러 이벤트가 순서대로 append 된다', async () => {
|
|
78
|
+
const { appendLoopEvent } = await importLedger();
|
|
79
|
+
appendLoopEvent(tmpDir, { loop: 'loop-a', event: 'start' });
|
|
80
|
+
appendLoopEvent(tmpDir, { loop: 'loop-a', event: 'end', result: 'ok' });
|
|
81
|
+
appendLoopEvent(tmpDir, { loop: 'loop-b', event: 'start' });
|
|
82
|
+
|
|
83
|
+
const p = historyPath(tmpDir);
|
|
84
|
+
const lines = fs.readFileSync(p, 'utf-8').trim().split('\n');
|
|
85
|
+
expect(lines).toHaveLength(3);
|
|
86
|
+
expect(JSON.parse(lines[0]).loop).toBe('loop-a');
|
|
87
|
+
expect(JSON.parse(lines[1]).event).toBe('end');
|
|
88
|
+
expect(JSON.parse(lines[2]).loop).toBe('loop-b');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('appendLoopEvent는 true를 반환한다', async () => {
|
|
92
|
+
const { appendLoopEvent } = await importLedger();
|
|
93
|
+
const result = appendLoopEvent(tmpDir, { loop: 'x', event: 'start' });
|
|
94
|
+
expect(result).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ─── isStuck ──────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe('loop-ledger: isStuck', () => {
|
|
101
|
+
it('이력 없으면 false', async () => {
|
|
102
|
+
const { isStuck } = await importLedger();
|
|
103
|
+
expect(isStuck(tmpDir, 'nightly', 'hash-abc')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('직전 discover 이벤트와 동일 hash → true (2회 연속 성립)', async () => {
|
|
107
|
+
const { appendLoopEvent, isStuck } = await importLedger();
|
|
108
|
+
// 이전 반복의 discover 이벤트 (같은 hash)
|
|
109
|
+
appendLoopEvent(tmpDir, {
|
|
110
|
+
loop: 'nightly',
|
|
111
|
+
event: 'discover',
|
|
112
|
+
discoverHash: 'hash-same',
|
|
113
|
+
});
|
|
114
|
+
// 신규 반복이 같은 hash를 갖고 오면 stuck
|
|
115
|
+
expect(isStuck(tmpDir, 'nightly', 'hash-same')).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('직전 discover 이벤트와 다른 hash → false', async () => {
|
|
119
|
+
const { appendLoopEvent, isStuck } = await importLedger();
|
|
120
|
+
appendLoopEvent(tmpDir, {
|
|
121
|
+
loop: 'nightly',
|
|
122
|
+
event: 'discover',
|
|
123
|
+
discoverHash: 'hash-old',
|
|
124
|
+
});
|
|
125
|
+
expect(isStuck(tmpDir, 'nightly', 'hash-new')).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('end 이벤트의 hash는 비교 대상이 아님 (discover만 본다)', async () => {
|
|
129
|
+
const { appendLoopEvent, isStuck } = await importLedger();
|
|
130
|
+
appendLoopEvent(tmpDir, {
|
|
131
|
+
loop: 'nightly',
|
|
132
|
+
event: 'end',
|
|
133
|
+
result: 'ok',
|
|
134
|
+
discoverHash: 'hash-same',
|
|
135
|
+
});
|
|
136
|
+
expect(isStuck(tmpDir, 'nightly', 'hash-same')).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('다른 루프 이름의 이벤트는 영향 안 줌', async () => {
|
|
140
|
+
const { appendLoopEvent, isStuck } = await importLedger();
|
|
141
|
+
appendLoopEvent(tmpDir, {
|
|
142
|
+
loop: 'other-loop',
|
|
143
|
+
event: 'discover',
|
|
144
|
+
discoverHash: 'hash-same',
|
|
145
|
+
});
|
|
146
|
+
// 'nightly' 에 대한 이력 없음 → false
|
|
147
|
+
expect(isStuck(tmpDir, 'nightly', 'hash-same')).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('start 이벤트만 있고 discover 없으면 false', async () => {
|
|
151
|
+
const { appendLoopEvent, isStuck } = await importLedger();
|
|
152
|
+
appendLoopEvent(tmpDir, { loop: 'nightly', event: 'start' });
|
|
153
|
+
expect(isStuck(tmpDir, 'nightly', 'hash-abc')).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('discoverHash 가 빈 문자열이면 false', async () => {
|
|
157
|
+
const { appendLoopEvent, isStuck } = await importLedger();
|
|
158
|
+
appendLoopEvent(tmpDir, {
|
|
159
|
+
loop: 'nightly',
|
|
160
|
+
event: 'discover',
|
|
161
|
+
discoverHash: '',
|
|
162
|
+
});
|
|
163
|
+
expect(isStuck(tmpDir, 'nightly', '')).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ─── fail-open: 손상된 jsonl 줄 ──────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
describe('loop-ledger: fail-open on corrupt jsonl', () => {
|
|
170
|
+
it('손상된 줄이 있어도 isStuck이 충돌하지 않고 false 반환', async () => {
|
|
171
|
+
const { appendLoopEvent, isStuck } = await importLedger();
|
|
172
|
+
|
|
173
|
+
// 정상 줄 + 손상 줄 + 정상 줄 혼합
|
|
174
|
+
const p = historyPath(tmpDir);
|
|
175
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
176
|
+
fs.writeFileSync(
|
|
177
|
+
p,
|
|
178
|
+
[
|
|
179
|
+
JSON.stringify({ ts: '2026-01-01T00:00:00.000Z', loop: 'nightly', event: 'discover', discoverHash: 'abc' }),
|
|
180
|
+
'CORRUPTED LINE {{{',
|
|
181
|
+
JSON.stringify({ ts: '2026-01-01T01:00:00.000Z', loop: 'nightly', event: 'discover', discoverHash: 'xyz' }),
|
|
182
|
+
].join('\n') + '\n',
|
|
183
|
+
'utf-8'
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// 손상 줄은 건너뛰고, 가장 최근 discover 이벤트(최신)의 hash는 'xyz'
|
|
187
|
+
expect(isStuck(tmpDir, 'nightly', 'xyz')).toBe(true);
|
|
188
|
+
expect(isStuck(tmpDir, 'nightly', 'abc')).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('appendLoopEvent: 전체 파일이 corrupt 여도 추가 후 정상 동작', async () => {
|
|
192
|
+
const { appendLoopEvent } = await importLedger();
|
|
193
|
+
const p = historyPath(tmpDir);
|
|
194
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
195
|
+
fs.writeFileSync(p, 'NOT JSON\n', 'utf-8');
|
|
196
|
+
|
|
197
|
+
const ok = appendLoopEvent(tmpDir, { loop: 'x', event: 'start' });
|
|
198
|
+
expect(ok).toBe(true);
|
|
199
|
+
const lines = fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean);
|
|
200
|
+
// 손상 줄 + 새로 추가된 정상 줄
|
|
201
|
+
expect(lines.length).toBeGreaterThanOrEqual(2);
|
|
202
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
203
|
+
expect(last.event).toBe('start');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ─── hashDiscoverOutput ───────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe('loop-ledger: hashDiscoverOutput', () => {
|
|
210
|
+
it('동일 텍스트는 동일 해시를 반환한다', async () => {
|
|
211
|
+
const { hashDiscoverOutput } = await importLedger();
|
|
212
|
+
const h1 = hashDiscoverOutput('hello world');
|
|
213
|
+
const h2 = hashDiscoverOutput('hello world');
|
|
214
|
+
expect(h1).toBe(h2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('공백 정규화: 여분 공백/줄바꿈이 달라도 동일 해시', async () => {
|
|
218
|
+
const { hashDiscoverOutput } = await importLedger();
|
|
219
|
+
const h1 = hashDiscoverOutput('item a\nitem b');
|
|
220
|
+
const h2 = hashDiscoverOutput('item a item b');
|
|
221
|
+
const h3 = hashDiscoverOutput(' item a item b ');
|
|
222
|
+
expect(h1).toBe(h2);
|
|
223
|
+
expect(h2).toBe(h3);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('다른 텍스트는 다른 해시', async () => {
|
|
227
|
+
const { hashDiscoverOutput } = await importLedger();
|
|
228
|
+
expect(hashDiscoverOutput('abc')).not.toBe(hashDiscoverOutput('xyz'));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('sha256 hex 64자 반환', async () => {
|
|
232
|
+
const { hashDiscoverOutput } = await importLedger();
|
|
233
|
+
expect(hashDiscoverOutput('test')).toMatch(/^[0-9a-f]{64}$/);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ─── CLI: start / end / check-stuck ─────────────────────────────────
|
|
238
|
+
|
|
239
|
+
describe('loop-ledger CLI', () => {
|
|
240
|
+
it('start → exit 0, 확인 메시지 출력, jsonl 생성', () => {
|
|
241
|
+
const result = spawnSync('node', [CLI, 'start', 'nightly'], {
|
|
242
|
+
encoding: 'utf-8',
|
|
243
|
+
timeout: 5000,
|
|
244
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
|
|
245
|
+
});
|
|
246
|
+
expect(result.status).toBe(0);
|
|
247
|
+
expect(result.stdout).toContain('start recorded');
|
|
248
|
+
expect(result.stdout).toContain('nightly');
|
|
249
|
+
expect(fs.existsSync(historyPath(tmpDir))).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('end → exit 0, jsonl에 end 이벤트 기록', () => {
|
|
253
|
+
const result = spawnSync('node', [CLI, 'end', 'nightly', 'ok', 'summary text'], {
|
|
254
|
+
encoding: 'utf-8',
|
|
255
|
+
timeout: 5000,
|
|
256
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
|
|
257
|
+
});
|
|
258
|
+
expect(result.status).toBe(0);
|
|
259
|
+
expect(result.stdout).toContain('end recorded');
|
|
260
|
+
|
|
261
|
+
const line = fs.readFileSync(historyPath(tmpDir), 'utf-8').trim();
|
|
262
|
+
const obj = JSON.parse(line);
|
|
263
|
+
expect(obj.event).toBe('end');
|
|
264
|
+
expect(obj.result).toBe('ok');
|
|
265
|
+
expect(obj.summary).toBe('summary text');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('check-stuck: 직전 discover hash와 동일 → stdout "stuck"', async () => {
|
|
269
|
+
const { appendLoopEvent } = await importLedger();
|
|
270
|
+
appendLoopEvent(tmpDir, {
|
|
271
|
+
loop: 'nightly',
|
|
272
|
+
event: 'discover',
|
|
273
|
+
discoverHash: 'deadbeef',
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const result = spawnSync('node', [CLI, 'check-stuck', 'nightly', 'deadbeef'], {
|
|
277
|
+
encoding: 'utf-8',
|
|
278
|
+
timeout: 5000,
|
|
279
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
|
|
280
|
+
});
|
|
281
|
+
expect(result.status).toBe(0);
|
|
282
|
+
expect(result.stdout.trim()).toBe('stuck');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('check-stuck: 새 hash → stdout "ok"', async () => {
|
|
286
|
+
const { appendLoopEvent } = await importLedger();
|
|
287
|
+
appendLoopEvent(tmpDir, {
|
|
288
|
+
loop: 'nightly',
|
|
289
|
+
event: 'end',
|
|
290
|
+
result: 'ok',
|
|
291
|
+
discoverHash: 'old-hash',
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const result = spawnSync('node', [CLI, 'check-stuck', 'nightly', 'new-hash'], {
|
|
295
|
+
encoding: 'utf-8',
|
|
296
|
+
timeout: 5000,
|
|
297
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
|
|
298
|
+
});
|
|
299
|
+
expect(result.status).toBe(0);
|
|
300
|
+
expect(result.stdout.trim()).toBe('ok');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('check-stuck: 이력 없으면 "ok"', () => {
|
|
304
|
+
const result = spawnSync('node', [CLI, 'check-stuck', 'nightly', 'anyhash'], {
|
|
305
|
+
encoding: 'utf-8',
|
|
306
|
+
timeout: 5000,
|
|
307
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
|
|
308
|
+
});
|
|
309
|
+
expect(result.status).toBe(0);
|
|
310
|
+
expect(result.stdout.trim()).toBe('ok');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('인수 없이 실행해도 exit 0 (fail-open)', () => {
|
|
314
|
+
const result = spawnSync('node', [CLI], {
|
|
315
|
+
encoding: 'utf-8',
|
|
316
|
+
timeout: 5000,
|
|
317
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
|
|
318
|
+
});
|
|
319
|
+
expect(result.status).toBe(0);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* post-edit-dispatcher 계약 테스트
|
|
3
|
+
*
|
|
4
|
+
* 검증 대상:
|
|
5
|
+
* - findings 있음 → stdout에 JSON hookSpecificOutput 출력, exit 0
|
|
6
|
+
* - findings 없음 → stdout 없음, exit 0
|
|
7
|
+
* - 손상된 stdin에도 exit 0 (fail-open)
|
|
8
|
+
* - additionalContext 형식 정확성
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import { spawnSync } from 'child_process';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const DISPATCHER = path.resolve(__dirname, '..', 'post-edit-dispatcher.js');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 디스패처를 stdin 페이로드와 함께 실행.
|
|
22
|
+
* @param {object|string} payload
|
|
23
|
+
* @param {string} [projectDir]
|
|
24
|
+
* @returns {{ stdout: string, stderr: string, exitCode: number }}
|
|
25
|
+
*/
|
|
26
|
+
function runDispatcher(payload, projectDir) {
|
|
27
|
+
const input = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
28
|
+
const env = {
|
|
29
|
+
...process.env,
|
|
30
|
+
CLAUDE_PROJECT_DIR: projectDir || __dirname,
|
|
31
|
+
};
|
|
32
|
+
const result = spawnSync('node', [DISPATCHER], {
|
|
33
|
+
input,
|
|
34
|
+
encoding: 'utf-8',
|
|
35
|
+
timeout: 30000,
|
|
36
|
+
env,
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
stdout: (result.stdout || '').trim(),
|
|
40
|
+
stderr: (result.stderr || '').trim(),
|
|
41
|
+
exitCode: result.status ?? 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('post-edit-dispatcher: 출력 형식', () => {
|
|
46
|
+
it('빈 페이로드 → exit 0, stdout 없음 (findings 없음)', () => {
|
|
47
|
+
const { stdout, exitCode } = runDispatcher({});
|
|
48
|
+
expect(exitCode).toBe(0);
|
|
49
|
+
expect(stdout).toBe('');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('손상된 stdin(비JSON) → exit 0 (fail-open)', () => {
|
|
53
|
+
const { exitCode } = runDispatcher('not-json');
|
|
54
|
+
expect(exitCode).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('존재하지 않는 파일 경로 → exit 0', () => {
|
|
58
|
+
const { exitCode } = runDispatcher({
|
|
59
|
+
tool_name: 'Edit',
|
|
60
|
+
tool_input: { file_path: '/nonexistent/__vibe_test__.xyz' },
|
|
61
|
+
});
|
|
62
|
+
expect(exitCode).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('post-edit-dispatcher: JSON hookSpecificOutput 형식', () => {
|
|
67
|
+
let tmpDir;
|
|
68
|
+
let tsFile;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-dispatcher-test-'));
|
|
72
|
+
tsFile = path.join(tmpDir, 'test-file.ts');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('P1 이슈가 있는 TS 파일 → JSON hookSpecificOutput 출력', () => {
|
|
80
|
+
// any 타입이 포함된 TS 파일 생성
|
|
81
|
+
fs.writeFileSync(tsFile, 'function foo(x: any): any { return x; }\n', 'utf-8');
|
|
82
|
+
|
|
83
|
+
const { stdout, exitCode } = runDispatcher({
|
|
84
|
+
tool_name: 'Edit',
|
|
85
|
+
tool_input: { file_path: tsFile },
|
|
86
|
+
}, tmpDir);
|
|
87
|
+
|
|
88
|
+
expect(exitCode).toBe(0);
|
|
89
|
+
if (stdout) {
|
|
90
|
+
// stdout이 있으면 JSON hookSpecificOutput 형식이어야 함
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(stdout);
|
|
94
|
+
} catch {
|
|
95
|
+
// plain text일 수도 있음 (code-check 모듈 로드 실패 시)
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (parsed.hookSpecificOutput) {
|
|
99
|
+
expect(parsed.hookSpecificOutput.hookEventName).toBe('PostToolUse');
|
|
100
|
+
expect(typeof parsed.hookSpecificOutput.additionalContext).toBe('string');
|
|
101
|
+
expect(parsed.hookSpecificOutput.additionalContext.length).toBeGreaterThan(0);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('findings 있으면 additionalContext에 요약 포함', () => {
|
|
107
|
+
fs.writeFileSync(tsFile, 'const x: any = 1;\n', 'utf-8');
|
|
108
|
+
|
|
109
|
+
const { stdout, exitCode } = runDispatcher({
|
|
110
|
+
tool_name: 'Edit',
|
|
111
|
+
tool_input: { file_path: tsFile },
|
|
112
|
+
}, tmpDir);
|
|
113
|
+
|
|
114
|
+
expect(exitCode).toBe(0);
|
|
115
|
+
if (stdout) {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(stdout);
|
|
118
|
+
if (parsed.hookSpecificOutput?.additionalContext) {
|
|
119
|
+
// any 탐지 관련 내용이 포함되어야 함
|
|
120
|
+
expect(parsed.hookSpecificOutput.additionalContext).toContain('any');
|
|
121
|
+
}
|
|
122
|
+
} catch { /* JSON 아닐 수 있음 */ }
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('post-edit-dispatcher: additionalContext JSON 계약', () => {
|
|
128
|
+
it('JSON 구조가 올바른 hookSpecificOutput 스키마를 따름', () => {
|
|
129
|
+
// 직접 JSON 생성해 스키마 검증
|
|
130
|
+
const output = {
|
|
131
|
+
hookSpecificOutput: {
|
|
132
|
+
hookEventName: 'PostToolUse',
|
|
133
|
+
additionalContext: 'P1 any-type line 1: x: any',
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
expect(output.hookSpecificOutput.hookEventName).toBe('PostToolUse');
|
|
137
|
+
expect(typeof output.hookSpecificOutput.additionalContext).toBe('string');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { spawnSync } from 'child_process';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
@@ -406,4 +406,118 @@ describe('pre-tool-guard', () => {
|
|
|
406
406
|
expect(result.stdout).toContain('Editing sensitive file');
|
|
407
407
|
});
|
|
408
408
|
});
|
|
409
|
+
|
|
410
|
+
// ══════════════════════════════════════════════════
|
|
411
|
+
// REQ-012: 64KB 초과 대용량 페이로드 파싱
|
|
412
|
+
// hook-context.js readStdinSync가 EOF까지 읽는지 검증
|
|
413
|
+
// ══════════════════════════════════════════════════
|
|
414
|
+
describe('large stdin payload (REQ-012: >64KB)', () => {
|
|
415
|
+
it('should parse tool_name from payload larger than 64KB', () => {
|
|
416
|
+
// 80KB content field로 페이로드 크기를 64KB 이상으로 만든다
|
|
417
|
+
const largeContent = 'x'.repeat(80 * 1024);
|
|
418
|
+
const result = runGuardWithStdin({
|
|
419
|
+
tool_name: 'Write',
|
|
420
|
+
tool_input: {
|
|
421
|
+
file_path: 'src/output.ts',
|
|
422
|
+
content: largeContent,
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
// Write to safe path: no blocking, tool_name parsed correctly (exit 0)
|
|
426
|
+
expect(result.exitCode).toBe(0);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should correctly detect dangerous command in large Bash payload', () => {
|
|
430
|
+
const padding = 'a'.repeat(80 * 1024);
|
|
431
|
+
const result = runGuardWithStdin({
|
|
432
|
+
tool_name: 'Bash',
|
|
433
|
+
tool_input: { command: 'rm -rf /', _padding: padding },
|
|
434
|
+
});
|
|
435
|
+
expect(result.exitCode).toBe(2);
|
|
436
|
+
expect(result.stdout).toContain('BLOCKED');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should allow safe command in large Bash payload', () => {
|
|
440
|
+
const padding = 'b'.repeat(80 * 1024);
|
|
441
|
+
const result = runGuardWithStdin({
|
|
442
|
+
tool_name: 'Bash',
|
|
443
|
+
tool_input: { command: 'ls -la', _padding: padding },
|
|
444
|
+
});
|
|
445
|
+
expect(result.exitCode).toBe(0);
|
|
446
|
+
expect(result.stdout).toBe('');
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ══════════════════════════════════════════════════
|
|
451
|
+
// REQ-013: gh pr create Bash 경로 테스트 게이트
|
|
452
|
+
// pr-gate-runner 헬퍼를 mock하여 테스트러너 실행 없이 검증
|
|
453
|
+
// ══════════════════════════════════════════════════
|
|
454
|
+
describe('gh pr create gate (REQ-013)', () => {
|
|
455
|
+
it('should allow gh pr create when tests pass (argv mode)', () => {
|
|
456
|
+
// 테스트 환경에서는 package.json test 스크립트가 존재하지만
|
|
457
|
+
// 여기서는 실제 테스트를 실행하지 않도록 PROJECT_DIR을 빈 디렉토리로 설정
|
|
458
|
+
const tmpDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '.vibe');
|
|
459
|
+
const result = spawnSync(
|
|
460
|
+
'node',
|
|
461
|
+
[SCRIPT, 'Bash', 'gh pr create --title "feat" --body "desc"'],
|
|
462
|
+
{
|
|
463
|
+
encoding: 'utf-8',
|
|
464
|
+
timeout: 10000,
|
|
465
|
+
// 빈 디렉토리 → detectTestCommand가 null 반환 → 통과
|
|
466
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
|
|
467
|
+
}
|
|
468
|
+
);
|
|
469
|
+
const combined = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
470
|
+
// 테스트 커맨드 없음 → PR 허용 (exit 0)
|
|
471
|
+
expect(result.status).toBe(0);
|
|
472
|
+
expect(combined).not.toContain('PR-GATE');
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should detect gh pr create in stdin payload', () => {
|
|
476
|
+
const tmpDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '.vibe');
|
|
477
|
+
const json = JSON.stringify({
|
|
478
|
+
tool_name: 'Bash',
|
|
479
|
+
tool_input: { command: 'gh pr create --title "feat" --body "body"' },
|
|
480
|
+
});
|
|
481
|
+
const result = spawnSync(
|
|
482
|
+
'node',
|
|
483
|
+
[SCRIPT],
|
|
484
|
+
{
|
|
485
|
+
input: json,
|
|
486
|
+
encoding: 'utf-8',
|
|
487
|
+
timeout: 10000,
|
|
488
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
|
|
489
|
+
}
|
|
490
|
+
);
|
|
491
|
+
// 테스트 커맨드 없음 → exit 0
|
|
492
|
+
expect(result.status).toBe(0);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('should NOT trigger gate for unrelated gh commands', () => {
|
|
496
|
+
const result = runGuard({ args: ['Bash', 'gh issue list'] });
|
|
497
|
+
expect(result.exitCode).toBe(0);
|
|
498
|
+
expect(result.stdout).toBe('');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should NOT trigger gate for gh pr view', () => {
|
|
502
|
+
const result = runGuard({ args: ['Bash', 'gh pr view 123'] });
|
|
503
|
+
expect(result.exitCode).toBe(0);
|
|
504
|
+
expect(result.stdout).toBe('');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('should match gh pr create with flags before/after', () => {
|
|
508
|
+
// 플래그 포함 명령에도 정규식 매칭이 되어야 함
|
|
509
|
+
// (테스트 없는 PROJECT_DIR이므로 exit 0)
|
|
510
|
+
const tmpDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '.vibe');
|
|
511
|
+
const result = spawnSync(
|
|
512
|
+
'node',
|
|
513
|
+
[SCRIPT, 'Bash', 'gh pr create -t "title" -b "body" --draft'],
|
|
514
|
+
{
|
|
515
|
+
encoding: 'utf-8',
|
|
516
|
+
timeout: 10000,
|
|
517
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
|
|
518
|
+
}
|
|
519
|
+
);
|
|
520
|
+
expect(result.status).toBe(0);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
409
523
|
});
|