@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.
Files changed (139) hide show
  1. package/CLAUDE.md +25 -16
  2. package/README.en.md +16 -14
  3. package/README.md +13 -11
  4. package/dist/cli/postinstall/constants.d.ts.map +1 -1
  5. package/dist/cli/postinstall/constants.js +1 -0
  6. package/dist/cli/postinstall/constants.js.map +1 -1
  7. package/dist/cli/postinstall/fs-utils.d.ts +23 -0
  8. package/dist/cli/postinstall/fs-utils.d.ts.map +1 -1
  9. package/dist/cli/postinstall/fs-utils.js +71 -0
  10. package/dist/cli/postinstall/fs-utils.js.map +1 -1
  11. package/dist/cli/postinstall/fs-utils.test.js +69 -1
  12. package/dist/cli/postinstall/fs-utils.test.js.map +1 -1
  13. package/dist/cli/postinstall/main.d.ts.map +1 -1
  14. package/dist/cli/postinstall/main.js +12 -2
  15. package/dist/cli/postinstall/main.js.map +1 -1
  16. package/dist/cli/setup/CodexHooks.test.js +27 -0
  17. package/dist/cli/setup/CodexHooks.test.js.map +1 -1
  18. package/dist/cli/setup/ProjectSetup.d.ts.map +1 -1
  19. package/dist/cli/setup/ProjectSetup.js +6 -5
  20. package/dist/cli/setup/ProjectSetup.js.map +1 -1
  21. package/dist/infra/lib/DecisionTracer.d.ts +4 -0
  22. package/dist/infra/lib/DecisionTracer.d.ts.map +1 -1
  23. package/dist/infra/lib/DecisionTracer.js +4 -0
  24. package/dist/infra/lib/DecisionTracer.js.map +1 -1
  25. package/dist/infra/lib/LoopBreaker.d.ts +4 -0
  26. package/dist/infra/lib/LoopBreaker.d.ts.map +1 -1
  27. package/dist/infra/lib/LoopBreaker.js +4 -0
  28. package/dist/infra/lib/LoopBreaker.js.map +1 -1
  29. package/dist/infra/lib/ReviewRace.d.ts +4 -0
  30. package/dist/infra/lib/ReviewRace.d.ts.map +1 -1
  31. package/dist/infra/lib/ReviewRace.js +4 -0
  32. package/dist/infra/lib/ReviewRace.js.map +1 -1
  33. package/dist/infra/lib/SkillQualityGate.d.ts +4 -0
  34. package/dist/infra/lib/SkillQualityGate.d.ts.map +1 -1
  35. package/dist/infra/lib/SkillQualityGate.js +4 -0
  36. package/dist/infra/lib/SkillQualityGate.js.map +1 -1
  37. package/dist/infra/lib/UltraQA.d.ts +4 -0
  38. package/dist/infra/lib/UltraQA.d.ts.map +1 -1
  39. package/dist/infra/lib/UltraQA.js +4 -0
  40. package/dist/infra/lib/UltraQA.js.map +1 -1
  41. package/dist/infra/lib/VerificationLoop.d.ts +4 -0
  42. package/dist/infra/lib/VerificationLoop.d.ts.map +1 -1
  43. package/dist/infra/lib/VerificationLoop.js +4 -0
  44. package/dist/infra/lib/VerificationLoop.js.map +1 -1
  45. package/dist/infra/orchestrator/index.d.ts.map +1 -1
  46. package/dist/infra/orchestrator/index.js +1 -3
  47. package/dist/infra/orchestrator/index.js.map +1 -1
  48. package/dist/infra/orchestrator/parallelResearch.d.ts.map +1 -1
  49. package/dist/infra/orchestrator/parallelResearch.js +1 -4
  50. package/dist/infra/orchestrator/parallelResearch.js.map +1 -1
  51. package/dist/tools/convention/validateCodeQuality.d.ts.map +1 -1
  52. package/dist/tools/convention/validateCodeQuality.js +5 -4
  53. package/dist/tools/convention/validateCodeQuality.js.map +1 -1
  54. package/dist/tools/index.d.ts +2 -0
  55. package/dist/tools/index.d.ts.map +1 -1
  56. package/dist/tools/index.js +2 -0
  57. package/dist/tools/index.js.map +1 -1
  58. package/dist/tools/loop/index.d.ts +6 -0
  59. package/dist/tools/loop/index.d.ts.map +1 -0
  60. package/dist/tools/loop/index.js +5 -0
  61. package/dist/tools/loop/index.js.map +1 -0
  62. package/dist/tools/loop/validateLoopDefinition.d.ts +38 -0
  63. package/dist/tools/loop/validateLoopDefinition.d.ts.map +1 -0
  64. package/dist/tools/loop/validateLoopDefinition.js +224 -0
  65. package/dist/tools/loop/validateLoopDefinition.js.map +1 -0
  66. package/dist/tools/loop/validateLoopDefinition.test.d.ts +14 -0
  67. package/dist/tools/loop/validateLoopDefinition.test.d.ts.map +1 -0
  68. package/dist/tools/loop/validateLoopDefinition.test.js +229 -0
  69. package/dist/tools/loop/validateLoopDefinition.test.js.map +1 -0
  70. package/dist/tools/spec/traceabilityMatrix.d.ts +2 -0
  71. package/dist/tools/spec/traceabilityMatrix.d.ts.map +1 -1
  72. package/dist/tools/spec/traceabilityMatrix.js +50 -1
  73. package/dist/tools/spec/traceabilityMatrix.js.map +1 -1
  74. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts +10 -0
  75. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.d.ts.map +1 -0
  76. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js +89 -0
  77. package/dist/tools/spec/traceabilityMatrix.path-resolution.test.js.map +1 -0
  78. package/dist/tools/spec/traceabilityMatrix.test.js +19 -0
  79. package/dist/tools/spec/traceabilityMatrix.test.js.map +1 -1
  80. package/hooks/hooks.json +1 -0
  81. package/hooks/scripts/__tests__/.vibe/command-log.txt +60 -0
  82. package/hooks/scripts/__tests__/.vibe/memories/memories.db +0 -0
  83. package/hooks/scripts/__tests__/.vibe/memories/memories.db-shm +0 -0
  84. package/hooks/scripts/__tests__/.vibe/memories/memories.db-wal +0 -0
  85. package/hooks/scripts/__tests__/auto-test-debounce.test.js +145 -0
  86. package/hooks/scripts/__tests__/code-check-detectors.test.js +155 -0
  87. package/hooks/scripts/__tests__/dispatcher-inprocess.test.js +99 -0
  88. package/hooks/scripts/__tests__/keyword-detector.test.js +26 -18
  89. package/hooks/scripts/__tests__/loop-ledger.test.js +321 -0
  90. package/hooks/scripts/__tests__/post-edit-dispatcher.test.js +139 -0
  91. package/hooks/scripts/__tests__/pre-tool-guard.test.js +115 -1
  92. package/hooks/scripts/__tests__/run-ledger-verify-required.test.js +146 -0
  93. package/hooks/scripts/__tests__/run-ledger.test.js +330 -0
  94. package/hooks/scripts/__tests__/scope-from-spec.test.js +215 -0
  95. package/hooks/scripts/__tests__/sentinel-guard.test.js +79 -24
  96. package/hooks/scripts/__tests__/step-counter.test.js +95 -15
  97. package/hooks/scripts/__tests__/utils-npm-root.test.js +98 -0
  98. package/hooks/scripts/auto-commit.js +27 -1
  99. package/hooks/scripts/auto-format.js +85 -20
  100. package/hooks/scripts/auto-test.js +187 -37
  101. package/hooks/scripts/code-check.js +286 -90
  102. package/hooks/scripts/codex-hook-adapter.js +12 -1
  103. package/hooks/scripts/command-log.js +26 -16
  104. package/hooks/scripts/keyword-detector.js +22 -22
  105. package/hooks/scripts/lib/dispatcher.js +38 -0
  106. package/hooks/scripts/lib/hook-context.js +130 -0
  107. package/hooks/scripts/lib/loop-ledger.js +118 -0
  108. package/hooks/scripts/lib/pr-gate-runner.js +62 -0
  109. package/hooks/scripts/lib/run-ledger.js +169 -0
  110. package/hooks/scripts/lib/scope-from-spec.js +40 -7
  111. package/hooks/scripts/loop-ledger.js +56 -0
  112. package/hooks/scripts/post-edit-dispatcher.js +93 -20
  113. package/hooks/scripts/post-edit.js +40 -19
  114. package/hooks/scripts/pr-test-gate.js +8 -37
  115. package/hooks/scripts/pre-tool-dispatcher.js +18 -16
  116. package/hooks/scripts/pre-tool-guard.js +55 -52
  117. package/hooks/scripts/prompt-dispatcher.js +10 -0
  118. package/hooks/scripts/scope-guard.js +40 -39
  119. package/hooks/scripts/sentinel-guard.js +41 -41
  120. package/hooks/scripts/session-start.js +13 -1
  121. package/hooks/scripts/step-counter.js +100 -7
  122. package/hooks/scripts/stop-dispatcher.js +26 -0
  123. package/hooks/scripts/utils.js +63 -21
  124. package/hooks/scripts/verify-ledger.js +22 -0
  125. package/package.json +2 -2
  126. package/skills/spec/references/templates.md +11 -6
  127. package/skills/vibe/SKILL.md +40 -23
  128. package/skills/vibe.loop/SKILL.md +116 -0
  129. package/skills/vibe.run/SKILL.md +153 -1686
  130. package/skills/vibe.run/references/brand-assets.md +59 -0
  131. package/skills/vibe.run/references/parallel-agents.md +326 -0
  132. package/skills/vibe.run/references/race-review.md +272 -0
  133. package/skills/vibe.run/references/ralph-loop.md +173 -0
  134. package/skills/vibe.run/references/ultrawork-mode.md +151 -0
  135. package/skills/vibe.trace/SKILL.md +25 -38
  136. package/skills/vibe.verify/SKILL.md +15 -0
  137. package/vibe/rules/loop-contract.md +54 -0
  138. package/vibe/templates/loop-template.md +69 -0
  139. package/hooks/scripts/figma-guard.js +0 -219
@@ -0,0 +1,146 @@
1
+ /**
2
+ * run-ledger verifyRequired 확장 테스트
3
+ *
4
+ * 검증 대상:
5
+ * - recordVerifyRequired: verifyRequired=true, reason 설정
6
+ * - recordVerify(pass) → verifyRequired 클리어
7
+ * - recordVerify(fail) → verifyRequired 유지
8
+ * - auto-commit 게이트: verifyRequired=true 시 skip
9
+ */
10
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
11
+ import { fileURLToPath } from 'url';
12
+ import path from 'path';
13
+ import fs from 'fs';
14
+ import os from 'os';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ let tmpDir;
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-verify-required-'));
21
+ });
22
+ afterEach(() => {
23
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
24
+ });
25
+
26
+ async function importLedger() {
27
+ const ledgerPath = path.resolve(__dirname, '..', 'lib', 'run-ledger.js');
28
+ return import(ledgerPath);
29
+ }
30
+
31
+ describe('run-ledger: verifyRequired', () => {
32
+ it('초기 상태: verifyRequired 없음', async () => {
33
+ const { recordRunStart, readLedger } = await importLedger();
34
+ recordRunStart(tmpDir, 'feat');
35
+ const ledger = readLedger(tmpDir);
36
+ // verifyRequired는 undefined 또는 false여야 함
37
+ expect(ledger.verifyRequired).toBeFalsy();
38
+ });
39
+
40
+ it('recordVerifyRequired → verifyRequired=true, reason 설정', async () => {
41
+ const { recordRunStart, recordVerifyRequired, readLedger } = await importLedger();
42
+ recordRunStart(tmpDir, 'feat');
43
+ recordVerifyRequired(tmpDir, 'P1 any-type line 5');
44
+ const ledger = readLedger(tmpDir);
45
+ expect(ledger.verifyRequired).toBe(true);
46
+ expect(ledger.verifyRequiredReason).toBe('P1 any-type line 5');
47
+ });
48
+
49
+ it('recordVerify(pass) → verifyRequired 클리어', async () => {
50
+ const { recordRunStart, recordVerifyRequired, recordVerify, readLedger } = await importLedger();
51
+ recordRunStart(tmpDir, 'feat');
52
+ recordVerifyRequired(tmpDir, 'P1 console.log found');
53
+ recordVerify(tmpDir, true);
54
+ const ledger = readLedger(tmpDir);
55
+ expect(ledger.verifyRequired).toBe(false);
56
+ expect(ledger.verifyRequiredReason).toBeNull();
57
+ expect(ledger.verifyPassed).toBe(true);
58
+ });
59
+
60
+ it('recordVerify(fail) → verifyRequired 유지됨', async () => {
61
+ const { recordRunStart, recordVerifyRequired, recordVerify, readLedger } = await importLedger();
62
+ recordRunStart(tmpDir, 'feat');
63
+ recordVerifyRequired(tmpDir, 'P1 issue');
64
+ recordVerify(tmpDir, false);
65
+ const ledger = readLedger(tmpDir);
66
+ // fail 시 verifyRequired는 유지되어야 함
67
+ expect(ledger.verifyRequired).toBe(true);
68
+ expect(ledger.verifyPassed).toBe(false);
69
+ });
70
+
71
+ it('reason 없이 recordVerifyRequired → 기본 reason 설정', async () => {
72
+ const { recordRunStart, recordVerifyRequired, readLedger } = await importLedger();
73
+ recordRunStart(tmpDir, 'feat');
74
+ recordVerifyRequired(tmpDir, '');
75
+ const ledger = readLedger(tmpDir);
76
+ expect(ledger.verifyRequired).toBe(true);
77
+ expect(typeof ledger.verifyRequiredReason).toBe('string');
78
+ });
79
+
80
+ it('recordVerifyRequired fail-open (레저 없이도 작동)', async () => {
81
+ const { recordVerifyRequired, readLedger } = await importLedger();
82
+ // runStart 없이 호출
83
+ recordVerifyRequired(tmpDir, 'standalone P1');
84
+ const ledger = readLedger(tmpDir);
85
+ expect(ledger.verifyRequired).toBe(true);
86
+ });
87
+ });
88
+
89
+ describe('auto-commit: verifyRequired 게이트 로직', () => {
90
+ function autoCommitGatePass(ledger) {
91
+ if (!ledger) return true;
92
+
93
+ // verify 게이트 (기존)
94
+ if (ledger.runStarted) {
95
+ const verifyOk = ledger.verifyPassed === true
96
+ && ledger.verifyAt
97
+ && ledger.verifyAt > ledger.runStarted;
98
+ if (!verifyOk) return false;
99
+ }
100
+
101
+ // verifyRequired 게이트 (신규)
102
+ if (ledger.verifyRequired === true) return false;
103
+
104
+ return true;
105
+ }
106
+
107
+ it('verifyRequired=true → 차단', () => {
108
+ expect(autoCommitGatePass({
109
+ runStarted: null,
110
+ verifyRequired: true,
111
+ verifyRequiredReason: 'P1 any-type',
112
+ })).toBe(false);
113
+ });
114
+
115
+ it('verifyRequired=false → 통과 (verifyRequired만 확인 시)', () => {
116
+ expect(autoCommitGatePass({
117
+ runStarted: null,
118
+ verifyRequired: false,
119
+ })).toBe(true);
120
+ });
121
+
122
+ it('verifyRequired=undefined → 통과 (기존 레저 호환)', () => {
123
+ expect(autoCommitGatePass({
124
+ runStarted: null,
125
+ verifyRequired: undefined,
126
+ })).toBe(true);
127
+ });
128
+
129
+ it('runStarted + verifyPassed + verifyRequired=true → 차단 (verifyRequired 우선)', () => {
130
+ expect(autoCommitGatePass({
131
+ runStarted: '2026-01-01T10:00:00.000Z',
132
+ verifyPassed: true,
133
+ verifyAt: '2026-01-01T10:05:00.000Z',
134
+ verifyRequired: true,
135
+ })).toBe(false);
136
+ });
137
+
138
+ it('runStarted + verifyPassed + verifyRequired=false → 통과', () => {
139
+ expect(autoCommitGatePass({
140
+ runStarted: '2026-01-01T10:00:00.000Z',
141
+ verifyPassed: true,
142
+ verifyAt: '2026-01-01T10:05:00.000Z',
143
+ verifyRequired: false,
144
+ })).toBe(true);
145
+ });
146
+ });
@@ -0,0 +1,330 @@
1
+ /**
2
+ * run-ledger 라이브러리 테스트
3
+ *
4
+ * REQ-harness-remediation-005, 006, 007, 008 커버리지:
5
+ * - round-trip 읽기/쓰기
6
+ * - verifyAt > runStarted 로직
7
+ * - extractRunFeature / isVibeRunPrompt 헬퍼
8
+ * - recordRunStart → verifyPassed 리셋
9
+ * - recordVerify pass/fail
10
+ * - prompt-dispatcher 에서의 vibe.run 감지 (CLI 실행)
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import os from 'os';
17
+ import { execFileSync, spawnSync } from 'child_process';
18
+ import { fileURLToPath } from 'url';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+
22
+ // 테스트 격리용 임시 디렉토리
23
+ let tmpDir;
24
+ beforeEach(() => {
25
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-ledger-test-'));
26
+ });
27
+ afterEach(() => {
28
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
29
+ });
30
+
31
+ // 모듈 임포트 (ESM 동적)
32
+ async function importLedger() {
33
+ const ledgerPath = path.resolve(__dirname, '..', 'lib', 'run-ledger.js');
34
+ // 캐시 버스팅 불필요 — 순수 함수, 상태 없음
35
+ return import(ledgerPath);
36
+ }
37
+
38
+ // ──────────────────────────────────────────────────────────────────
39
+ // readLedger / recordRunStart / recordVerify round-trip
40
+ // ──────────────────────────────────────────────────────────────────
41
+ describe('run-ledger: round-trip', () => {
42
+ it('존재하지 않는 레저 → null 반환', async () => {
43
+ const { readLedger } = await importLedger();
44
+ const result = readLedger(tmpDir);
45
+ expect(result).toBeNull();
46
+ });
47
+
48
+ it('recordRunStart 후 읽으면 runStarted가 ISO 문자열', async () => {
49
+ const { recordRunStart, readLedger } = await importLedger();
50
+ const before = new Date().toISOString();
51
+ recordRunStart(tmpDir, 'my-feature');
52
+ const ledger = readLedger(tmpDir);
53
+ expect(ledger).not.toBeNull();
54
+ expect(ledger.runStarted).toBeDefined();
55
+ expect(ledger.runStarted >= before).toBe(true);
56
+ expect(ledger.runFeature).toBe('my-feature');
57
+ });
58
+
59
+ it('recordRunStart는 verifyPassed를 false로 리셋', async () => {
60
+ const { recordRunStart, recordVerify, readLedger } = await importLedger();
61
+ recordRunStart(tmpDir, 'f1');
62
+ recordVerify(tmpDir, true);
63
+ // 두 번째 run — 리셋
64
+ recordRunStart(tmpDir, 'f2');
65
+ const ledger = readLedger(tmpDir);
66
+ expect(ledger.verifyPassed).toBe(false);
67
+ expect(ledger.verifyAt).toBeNull();
68
+ expect(ledger.stopWarned).toBe(false);
69
+ });
70
+
71
+ it('recordVerify(pass) → verifyPassed=true, verifyAt 설정', async () => {
72
+ const { recordRunStart, recordVerify, readLedger } = await importLedger();
73
+ recordRunStart(tmpDir, 'feat');
74
+ const before = new Date().toISOString();
75
+ recordVerify(tmpDir, true);
76
+ const ledger = readLedger(tmpDir);
77
+ expect(ledger.verifyPassed).toBe(true);
78
+ expect(ledger.verifyAt).toBeDefined();
79
+ expect(ledger.verifyAt >= before).toBe(true);
80
+ });
81
+
82
+ it('recordVerify(fail) → verifyPassed=false', async () => {
83
+ const { recordRunStart, recordVerify, readLedger } = await importLedger();
84
+ recordRunStart(tmpDir, 'feat');
85
+ recordVerify(tmpDir, false);
86
+ const ledger = readLedger(tmpDir);
87
+ expect(ledger.verifyPassed).toBe(false);
88
+ });
89
+ });
90
+
91
+ // ──────────────────────────────────────────────────────────────────
92
+ // verifyAt > runStarted 관계 검증
93
+ // ──────────────────────────────────────────────────────────────────
94
+ describe('run-ledger: verifyAt > runStarted', () => {
95
+ it('recordVerify 후 verifyAt > runStarted', async () => {
96
+ const { recordRunStart, recordVerify, readLedger } = await importLedger();
97
+ recordRunStart(tmpDir, 'f');
98
+ // 동일 밀리초 내 실행 가능성을 피하기 위해 runStarted를 과거로 조작
99
+ const ledger = readLedger(tmpDir);
100
+ const p = path.join(tmpDir, '.vibe', 'metrics', 'run-ledger.json');
101
+ const patched = { ...ledger, runStarted: new Date(Date.now() - 1000).toISOString() };
102
+ fs.writeFileSync(p, JSON.stringify(patched), 'utf-8');
103
+
104
+ recordVerify(tmpDir, true);
105
+ const after = readLedger(tmpDir);
106
+ expect(after.verifyAt > after.runStarted).toBe(true);
107
+ expect(after.verifyPassed).toBe(true);
108
+ });
109
+
110
+ it('verifyAt이 runStarted보다 이르면 gate 불충족 (simulate)', async () => {
111
+ const { readLedger } = await importLedger();
112
+ const p = path.join(tmpDir, '.vibe', 'metrics', 'run-ledger.json');
113
+ fs.mkdirSync(path.dirname(p), { recursive: true });
114
+ const now = new Date();
115
+ // runStarted가 verifyAt보다 나중
116
+ const data = {
117
+ runStarted: new Date(now.getTime() + 5000).toISOString(),
118
+ runFeature: 'f',
119
+ verifyPassed: true,
120
+ verifyAt: now.toISOString(),
121
+ stopWarned: false,
122
+ };
123
+ fs.writeFileSync(p, JSON.stringify(data), 'utf-8');
124
+ const ledger = readLedger(tmpDir);
125
+ // auto-commit 게이트 로직: verifyPassed=true 이지만 verifyAt < runStarted
126
+ const gatePass = ledger.verifyPassed === true
127
+ && ledger.verifyAt
128
+ && ledger.verifyAt > ledger.runStarted;
129
+ expect(gatePass).toBe(false);
130
+ });
131
+ });
132
+
133
+ // ──────────────────────────────────────────────────────────────────
134
+ // isVibeRunPrompt / extractRunFeature
135
+ // ──────────────────────────────────────────────────────────────────
136
+ describe('run-ledger: vibe.run 감지 헬퍼', () => {
137
+ it('/vibe.run 감지', async () => {
138
+ const { isVibeRunPrompt } = await importLedger();
139
+ expect(isVibeRunPrompt('/vibe.run my-feature')).toBe(true);
140
+ });
141
+
142
+ it('$vibe.run 감지', async () => {
143
+ const { isVibeRunPrompt } = await importLedger();
144
+ expect(isVibeRunPrompt('$vibe.run my-feature')).toBe(true);
145
+ });
146
+
147
+ it('대소문자 무관', async () => {
148
+ const { isVibeRunPrompt } = await importLedger();
149
+ expect(isVibeRunPrompt('/VIBE.RUN feature')).toBe(true);
150
+ });
151
+
152
+ it('부분 매칭 방지 — vibe.runs 는 미감지', async () => {
153
+ const { isVibeRunPrompt } = await importLedger();
154
+ // "vibe.runs"는 \b로 경계 처리됨
155
+ expect(isVibeRunPrompt('vibe.runs everything')).toBe(false);
156
+ });
157
+
158
+ it('단어 내 포함은 미감지', async () => {
159
+ const { isVibeRunPrompt } = await importLedger();
160
+ expect(isVibeRunPrompt('I was talking about vibe.runner')).toBe(false);
161
+ });
162
+
163
+ it('extractRunFeature: 기능명 추출', async () => {
164
+ const { extractRunFeature } = await importLedger();
165
+ expect(extractRunFeature('/vibe.run my-feature')).toBe('my-feature');
166
+ });
167
+
168
+ it('extractRunFeature: $vibe.run에서 추출', async () => {
169
+ const { extractRunFeature } = await importLedger();
170
+ expect(extractRunFeature('$vibe.run auth-login ultrawork')).toBe('auth-login');
171
+ });
172
+
173
+ it('extractRunFeature: 기능명 없으면 null', async () => {
174
+ const { extractRunFeature } = await importLedger();
175
+ expect(extractRunFeature('/vibe.run --phase 1')).toBeNull();
176
+ });
177
+
178
+ it('extractRunFeature: /vibe.run만 있으면 null', async () => {
179
+ const { extractRunFeature } = await importLedger();
180
+ expect(extractRunFeature('/vibe.run')).toBeNull();
181
+ });
182
+ });
183
+
184
+ // ──────────────────────────────────────────────────────────────────
185
+ // markStopWarned
186
+ // ──────────────────────────────────────────────────────────────────
187
+ describe('run-ledger: stopWarned', () => {
188
+ it('markStopWarned → stopWarned=true', async () => {
189
+ const { recordRunStart, markStopWarned, readLedger } = await importLedger();
190
+ recordRunStart(tmpDir, 'f');
191
+ markStopWarned(tmpDir);
192
+ const ledger = readLedger(tmpDir);
193
+ expect(ledger.stopWarned).toBe(true);
194
+ });
195
+ });
196
+
197
+ // ──────────────────────────────────────────────────────────────────
198
+ // verify-ledger.js CLI
199
+ // ──────────────────────────────────────────────────────────────────
200
+ describe('verify-ledger CLI', () => {
201
+ const CLI = path.resolve(__dirname, '..', 'verify-ledger.js');
202
+
203
+ it('pass 인자 → verifyPassed=true, exit 0', async () => {
204
+ const { recordRunStart } = await importLedger();
205
+ recordRunStart(tmpDir, 'feat');
206
+
207
+ const result = spawnSync('node', [CLI, 'pass'], {
208
+ encoding: 'utf-8',
209
+ timeout: 5000,
210
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
211
+ });
212
+ expect(result.status).toBe(0);
213
+ expect(result.stdout).toContain('verifyPassed=true');
214
+
215
+ const { readLedger } = await importLedger();
216
+ const ledger = readLedger(tmpDir);
217
+ expect(ledger.verifyPassed).toBe(true);
218
+ });
219
+
220
+ it('fail 인자 → verifyPassed=false, exit 0', async () => {
221
+ const { recordRunStart } = await importLedger();
222
+ recordRunStart(tmpDir, 'feat');
223
+
224
+ const result = spawnSync('node', [CLI, 'fail'], {
225
+ encoding: 'utf-8',
226
+ timeout: 5000,
227
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
228
+ });
229
+ expect(result.status).toBe(0);
230
+ expect(result.stdout).toContain('verifyPassed=false');
231
+
232
+ const { readLedger } = await importLedger();
233
+ const ledger = readLedger(tmpDir);
234
+ expect(ledger.verifyPassed).toBe(false);
235
+ });
236
+
237
+ it('인자 없이도 exit 0 (fail-open)', () => {
238
+ const result = spawnSync('node', [CLI], {
239
+ encoding: 'utf-8',
240
+ timeout: 5000,
241
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
242
+ });
243
+ expect(result.status).toBe(0);
244
+ });
245
+ });
246
+
247
+ // ──────────────────────────────────────────────────────────────────
248
+ // prompt-dispatcher vibe.run 감지 (sanity — ledger 파일 생성 확인)
249
+ // ──────────────────────────────────────────────────────────────────
250
+ describe('prompt-dispatcher: vibe.run 감지 → ledger 파일 생성', () => {
251
+ const DISPATCHER = path.resolve(__dirname, '..', 'prompt-dispatcher.js');
252
+
253
+ it('/vibe.run 프롬프트 → run-ledger.json 생성', () => {
254
+ const payload = JSON.stringify({ prompt: '/vibe.run test-feature' });
255
+ const result = spawnSync('node', [DISPATCHER], {
256
+ input: payload,
257
+ encoding: 'utf-8',
258
+ timeout: 10000,
259
+ env: {
260
+ ...process.env,
261
+ CLAUDE_PROJECT_DIR: tmpDir,
262
+ VIBE_HOOK_DEPTH: undefined,
263
+ // keyword-detector 등 자식 프로세스 타임아웃 내 완료
264
+ },
265
+ });
266
+ expect(result.status).toBe(0);
267
+ const ledgerFile = path.join(tmpDir, '.vibe', 'metrics', 'run-ledger.json');
268
+ expect(fs.existsSync(ledgerFile)).toBe(true);
269
+ const ledger = JSON.parse(fs.readFileSync(ledgerFile, 'utf-8'));
270
+ expect(ledger.runStarted).toBeDefined();
271
+ expect(ledger.runFeature).toBe('test-feature');
272
+ expect(ledger.verifyPassed).toBe(false);
273
+ });
274
+
275
+ it('일반 프롬프트 → ledger 파일 미생성', () => {
276
+ const payload = JSON.stringify({ prompt: 'implement the login form' });
277
+ spawnSync('node', [DISPATCHER], {
278
+ input: payload,
279
+ encoding: 'utf-8',
280
+ timeout: 10000,
281
+ env: { ...process.env, CLAUDE_PROJECT_DIR: tmpDir },
282
+ });
283
+ const ledgerFile = path.join(tmpDir, '.vibe', 'metrics', 'run-ledger.json');
284
+ expect(fs.existsSync(ledgerFile)).toBe(false);
285
+ });
286
+ });
287
+
288
+ // ──────────────────────────────────────────────────────────────────
289
+ // auto-commit 게이트 로직 (단위 수준 — 레저 상태별 gate 검증)
290
+ // ──────────────────────────────────────────────────────────────────
291
+ describe('auto-commit: verify gate 로직 (레저 상태 시뮬레이션)', () => {
292
+ function gatePass(ledger) {
293
+ if (!ledger || !ledger.runStarted) return true; // 레저 없으면 통과 (기존 동작)
294
+ return ledger.verifyPassed === true
295
+ && ledger.verifyAt
296
+ && ledger.verifyAt > ledger.runStarted;
297
+ }
298
+
299
+ it('레저 없음 → 통과 (기존 동작 유지)', () => {
300
+ expect(gatePass(null)).toBe(true);
301
+ });
302
+
303
+ it('runStarted 없음 → 통과', () => {
304
+ expect(gatePass({ verifyPassed: false, verifyAt: null })).toBe(true);
305
+ });
306
+
307
+ it('verifyPassed=false → 차단', () => {
308
+ expect(gatePass({
309
+ runStarted: '2026-01-01T10:00:00.000Z',
310
+ verifyPassed: false,
311
+ verifyAt: null,
312
+ })).toBe(false);
313
+ });
314
+
315
+ it('verifyPassed=true + verifyAt > runStarted → 통과', () => {
316
+ expect(gatePass({
317
+ runStarted: '2026-01-01T10:00:00.000Z',
318
+ verifyPassed: true,
319
+ verifyAt: '2026-01-01T10:05:00.000Z',
320
+ })).toBe(true);
321
+ });
322
+
323
+ it('verifyPassed=true 이지만 verifyAt < runStarted → 차단', () => {
324
+ expect(gatePass({
325
+ runStarted: '2026-01-01T10:05:00.000Z',
326
+ verifyPassed: true,
327
+ verifyAt: '2026-01-01T10:00:00.000Z',
328
+ })).toBe(false);
329
+ });
330
+ });
@@ -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
+ });