@su-record/vibe 2.9.38 → 2.9.40
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 +1 -1
- package/commands/vibe.verify.md +6 -0
- package/dist/cli/commands/init.js +2 -2
- package/dist/cli/commands/init.js.map +1 -1
- package/hooks/scripts/__tests__/curation-index.test.js +157 -0
- package/hooks/scripts/__tests__/recipe-extractor.test.js +244 -0
- package/hooks/scripts/__tests__/step-counter.test.js +358 -0
- package/hooks/scripts/lib/curation-index.js +101 -0
- package/hooks/scripts/recipe-extractor.js +249 -0
- package/hooks/scripts/session-start.js +19 -0
- package/hooks/scripts/step-counter.js +230 -21
- package/package.json +1 -1
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* step-counter.js — Phase 1 + Phase 2 테스트
|
|
3
|
+
*
|
|
4
|
+
* 검증 범위:
|
|
5
|
+
* - 책임 1) current-run.json steps 증가 (회귀 테스트)
|
|
6
|
+
* - 책임 2) current-run.jsonl append (Phase 1)
|
|
7
|
+
* - 책임 3) error_category 분류기 + 3-fail detector → anti-pattern md (Phase 2)
|
|
8
|
+
* - 책임 간 독립성, hot-path 안정성
|
|
9
|
+
*
|
|
10
|
+
* 패턴: keyword-detector.test.js 와 동일한 execFileSync 방식.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
13
|
+
import { spawnSync } from 'child_process';
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const SCRIPT = path.resolve(__dirname, '..', 'step-counter.js');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 격리된 임시 프로젝트에서 step-counter 를 1회 실행.
|
|
24
|
+
* stdin 으로 PostToolUse payload 전달.
|
|
25
|
+
*/
|
|
26
|
+
function runCounter({ payload, projectDir }) {
|
|
27
|
+
const result = spawnSync('node', [SCRIPT], {
|
|
28
|
+
input: payload ? JSON.stringify(payload) : '',
|
|
29
|
+
encoding: 'utf-8',
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
env: {
|
|
32
|
+
...process.env,
|
|
33
|
+
CLAUDE_PROJECT_DIR: projectDir,
|
|
34
|
+
// VIBE_HOOK_DEPTH 미설정 — 재귀 가드 영향 없음
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeTmpProject() {
|
|
41
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-step-counter-'));
|
|
42
|
+
// .vibe/metrics/ 는 step-counter 가 직접 생성하도록 미리 만들지 않음
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJson(p) {
|
|
47
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJsonl(p) {
|
|
51
|
+
return fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('step-counter PostToolUse hook', () => {
|
|
55
|
+
let projectDir;
|
|
56
|
+
let runJson;
|
|
57
|
+
let runJsonl;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
projectDir = makeTmpProject();
|
|
61
|
+
runJson = path.join(projectDir, '.vibe', 'metrics', 'current-run.json');
|
|
62
|
+
runJsonl = path.join(projectDir, '.vibe', 'metrics', 'current-run.jsonl');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ───────── 책임 1 회귀 ─────────
|
|
66
|
+
describe('책임 1: current-run.json 카운터', () => {
|
|
67
|
+
it('첫 호출 시 steps=1, startedAt 채움', () => {
|
|
68
|
+
const r = runCounter({
|
|
69
|
+
payload: { tool_name: 'Bash', tool_input: { command: 'ls' }, tool_response: { is_error: false } },
|
|
70
|
+
projectDir,
|
|
71
|
+
});
|
|
72
|
+
expect(r.status).toBe(0);
|
|
73
|
+
const data = readJson(runJson);
|
|
74
|
+
expect(data.steps).toBe(1);
|
|
75
|
+
expect(data.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('연속 호출 시 steps 누적', () => {
|
|
79
|
+
for (let i = 0; i < 3; i++) {
|
|
80
|
+
runCounter({
|
|
81
|
+
payload: { tool_name: 'Bash', tool_input: { command: 'echo hi' }, tool_response: {} },
|
|
82
|
+
projectDir,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const data = readJson(runJson);
|
|
86
|
+
expect(data.steps).toBe(3);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('손상된 JSON 이 있어도 새로 시작', () => {
|
|
90
|
+
fs.mkdirSync(path.join(projectDir, '.vibe', 'metrics'), { recursive: true });
|
|
91
|
+
fs.writeFileSync(runJson, '{ broken json');
|
|
92
|
+
const r = runCounter({
|
|
93
|
+
payload: { tool_name: 'Bash', tool_input: {}, tool_response: {} },
|
|
94
|
+
projectDir,
|
|
95
|
+
});
|
|
96
|
+
expect(r.status).toBe(0);
|
|
97
|
+
const data = readJson(runJson);
|
|
98
|
+
expect(data.steps).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ───────── 책임 2 신규 ─────────
|
|
103
|
+
describe('책임 2: current-run.jsonl 로깅', () => {
|
|
104
|
+
it('툴콜 1회 = jsonl 1라인', () => {
|
|
105
|
+
runCounter({
|
|
106
|
+
payload: { tool_name: 'Edit', tool_input: { file_path: 'src/foo.ts' }, tool_response: {} },
|
|
107
|
+
projectDir,
|
|
108
|
+
});
|
|
109
|
+
const lines = readJsonl(runJsonl);
|
|
110
|
+
expect(lines).toHaveLength(1);
|
|
111
|
+
expect(lines[0].tool).toBe('Edit');
|
|
112
|
+
expect(lines[0].ok).toBe(true);
|
|
113
|
+
expect(lines[0].target_file).toBe('src/foo.ts');
|
|
114
|
+
expect(lines[0].error_category).toBeNull();
|
|
115
|
+
expect(lines[0].ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('tool_response.is_error=true 면 ok=false', () => {
|
|
119
|
+
runCounter({
|
|
120
|
+
payload: { tool_name: 'Bash', tool_input: { command: 'false' }, tool_response: { is_error: true } },
|
|
121
|
+
projectDir,
|
|
122
|
+
});
|
|
123
|
+
const [line] = readJsonl(runJsonl);
|
|
124
|
+
expect(line.ok).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('절대 경로 file_path 를 프로젝트 상대 경로로 정규화', () => {
|
|
128
|
+
const abs = path.join(projectDir, 'src', 'bar.ts');
|
|
129
|
+
runCounter({
|
|
130
|
+
payload: { tool_name: 'Write', tool_input: { file_path: abs }, tool_response: {} },
|
|
131
|
+
projectDir,
|
|
132
|
+
});
|
|
133
|
+
const [line] = readJsonl(runJsonl);
|
|
134
|
+
expect(line.target_file).toBe('src/bar.ts');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('file_path 없는 툴콜은 target_file=null', () => {
|
|
138
|
+
runCounter({
|
|
139
|
+
payload: { tool_name: 'Bash', tool_input: { command: 'pwd' }, tool_response: {} },
|
|
140
|
+
projectDir,
|
|
141
|
+
});
|
|
142
|
+
const [line] = readJsonl(runJsonl);
|
|
143
|
+
expect(line.target_file).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('tool_name 없으면 jsonl 라인 안 씀 (steps 는 증가)', () => {
|
|
147
|
+
runCounter({ payload: {}, projectDir });
|
|
148
|
+
expect(fs.existsSync(runJsonl)).toBe(false);
|
|
149
|
+
expect(readJson(runJson).steps).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('연속 호출 시 jsonl 누적', () => {
|
|
153
|
+
runCounter({
|
|
154
|
+
payload: { tool_name: 'Read', tool_input: { file_path: 'a.ts' }, tool_response: {} },
|
|
155
|
+
projectDir,
|
|
156
|
+
});
|
|
157
|
+
runCounter({
|
|
158
|
+
payload: { tool_name: 'Edit', tool_input: { file_path: 'a.ts' }, tool_response: {} },
|
|
159
|
+
projectDir,
|
|
160
|
+
});
|
|
161
|
+
const lines = readJsonl(runJsonl);
|
|
162
|
+
expect(lines).toHaveLength(2);
|
|
163
|
+
expect(lines.map((l) => l.tool)).toEqual(['Read', 'Edit']);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ───────── 독립성 ─────────
|
|
168
|
+
describe('두 책임의 독립성', () => {
|
|
169
|
+
it('payload 가 비어 stdin 무효여도 카운터는 증가', () => {
|
|
170
|
+
const r = runCounter({ payload: null, projectDir });
|
|
171
|
+
expect(r.status).toBe(0);
|
|
172
|
+
expect(readJson(runJson).steps).toBe(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ───────── Phase 2: error_category 분류 ─────────
|
|
177
|
+
describe('책임 3a: error_category 분류기', () => {
|
|
178
|
+
it('TypeError of undefined → nullability', () => {
|
|
179
|
+
runCounter({
|
|
180
|
+
payload: {
|
|
181
|
+
tool_name: 'Bash',
|
|
182
|
+
tool_input: { command: 'node x.js' },
|
|
183
|
+
tool_response: { is_error: true, error: "TypeError: Cannot read properties of undefined (reading 'foo')" },
|
|
184
|
+
},
|
|
185
|
+
projectDir,
|
|
186
|
+
});
|
|
187
|
+
const [line] = readJsonl(runJsonl);
|
|
188
|
+
expect(line.error_category).toBe('nullability');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('TS2345 → type-narrow', () => {
|
|
192
|
+
runCounter({
|
|
193
|
+
payload: {
|
|
194
|
+
tool_name: 'Bash',
|
|
195
|
+
tool_input: { command: 'tsc' },
|
|
196
|
+
tool_response: { is_error: true, error: "src/x.ts(3,4): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'." },
|
|
197
|
+
},
|
|
198
|
+
projectDir,
|
|
199
|
+
});
|
|
200
|
+
const [line] = readJsonl(runJsonl);
|
|
201
|
+
expect(line.error_category).toBe('type-narrow');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('ECONNREFUSED → integration', () => {
|
|
205
|
+
runCounter({
|
|
206
|
+
payload: {
|
|
207
|
+
tool_name: 'Bash',
|
|
208
|
+
tool_input: { command: 'curl' },
|
|
209
|
+
tool_response: { is_error: true, error: 'connect ECONNREFUSED 127.0.0.1:5432' },
|
|
210
|
+
},
|
|
211
|
+
projectDir,
|
|
212
|
+
});
|
|
213
|
+
const [line] = readJsonl(runJsonl);
|
|
214
|
+
expect(line.error_category).toBe('integration');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('알 수 없는 에러 → other', () => {
|
|
218
|
+
runCounter({
|
|
219
|
+
payload: {
|
|
220
|
+
tool_name: 'Bash',
|
|
221
|
+
tool_input: { command: 'x' },
|
|
222
|
+
tool_response: { is_error: true, error: 'some unrecognized failure mode 12345' },
|
|
223
|
+
},
|
|
224
|
+
projectDir,
|
|
225
|
+
});
|
|
226
|
+
const [line] = readJsonl(runJsonl);
|
|
227
|
+
expect(line.error_category).toBe('other');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('성공 툴콜은 error_category=null', () => {
|
|
231
|
+
runCounter({
|
|
232
|
+
payload: { tool_name: 'Bash', tool_input: { command: 'ls' }, tool_response: { is_error: false } },
|
|
233
|
+
projectDir,
|
|
234
|
+
});
|
|
235
|
+
const [line] = readJsonl(runJsonl);
|
|
236
|
+
expect(line.ok).toBe(true);
|
|
237
|
+
expect(line.error_category).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ───────── Phase 2: 3-fail detector ─────────
|
|
242
|
+
describe('책임 3b: 3-fail detector → anti-pattern md', () => {
|
|
243
|
+
function failBash(command, errorText, projectDir) {
|
|
244
|
+
runCounter({
|
|
245
|
+
payload: {
|
|
246
|
+
tool_name: 'Bash',
|
|
247
|
+
tool_input: { command },
|
|
248
|
+
tool_response: { is_error: true, error: errorText },
|
|
249
|
+
},
|
|
250
|
+
projectDir,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function listAntiPatterns(projectDir) {
|
|
255
|
+
const dir = path.join(projectDir, '.vibe', 'anti-patterns');
|
|
256
|
+
if (!fs.existsSync(dir)) return [];
|
|
257
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
it('같은 (file, category) 3회 누적 시 anti-pattern md 생성', () => {
|
|
261
|
+
const err = "TypeError: Cannot read properties of undefined (reading 'x')";
|
|
262
|
+
// file_path 없는 Bash 실패 3회 → target_file=null, category=nullability
|
|
263
|
+
for (let i = 0; i < 3; i++) failBash(`run-${i}`, err, projectDir);
|
|
264
|
+
const files = listAntiPatterns(projectDir);
|
|
265
|
+
expect(files).toHaveLength(1);
|
|
266
|
+
expect(files[0]).toMatch(/^nullability__global__\d{8}\.md$/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('2회만 누적되면 생성 안 함', () => {
|
|
270
|
+
const err = 'connect ECONNREFUSED foo';
|
|
271
|
+
for (let i = 0; i < 2; i++) failBash(`x-${i}`, err, projectDir);
|
|
272
|
+
expect(listAntiPatterns(projectDir)).toHaveLength(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('3회지만 카테고리 다르면 생성 안 함', () => {
|
|
276
|
+
failBash('a', "Cannot read properties of undefined", projectDir); // nullability
|
|
277
|
+
failBash('b', "ECONNREFUSED", projectDir); // integration
|
|
278
|
+
failBash('c', "TS2345 not assignable", projectDir); // type-narrow
|
|
279
|
+
expect(listAntiPatterns(projectDir)).toHaveLength(0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('동일 (file, category) 4회 — md 1개만 (dedup)', () => {
|
|
283
|
+
const err = "TypeError: Cannot read properties of undefined";
|
|
284
|
+
for (let i = 0; i < 4; i++) failBash(`r-${i}`, err, projectDir);
|
|
285
|
+
expect(listAntiPatterns(projectDir)).toHaveLength(1);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('frontmatter 가 schema 충족', () => {
|
|
289
|
+
const err = "TypeError: Cannot read properties of null";
|
|
290
|
+
for (let i = 0; i < 3; i++) failBash(`r-${i}`, err, projectDir);
|
|
291
|
+
const [filename] = listAntiPatterns(projectDir);
|
|
292
|
+
const content = fs.readFileSync(path.join(projectDir, '.vibe', 'anti-patterns', filename), 'utf-8');
|
|
293
|
+
expect(content).toMatch(/^---\n/);
|
|
294
|
+
expect(content).toMatch(/^slug: nullability__global__\d{8}$/m);
|
|
295
|
+
expect(content).toMatch(/^type: anti-pattern$/m);
|
|
296
|
+
expect(content).toMatch(/^root-cause-tag: nullability$/m);
|
|
297
|
+
expect(content).toMatch(/^trigger-signature: "/m);
|
|
298
|
+
expect(content).toMatch(/^fail-count: 3$/m);
|
|
299
|
+
expect(content).toMatch(/^suggested-stop: "/m);
|
|
300
|
+
expect(content).toMatch(/^created: \d{4}-\d{2}-\d{2}$/m);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('파일 경로 있는 실패는 file slug 사용', () => {
|
|
304
|
+
const filePath = 'src/cli/foo.ts';
|
|
305
|
+
const err = "TypeError: Cannot read properties of undefined";
|
|
306
|
+
for (let i = 0; i < 3; i++) {
|
|
307
|
+
runCounter({
|
|
308
|
+
payload: {
|
|
309
|
+
tool_name: 'Edit',
|
|
310
|
+
tool_input: { file_path: filePath },
|
|
311
|
+
tool_response: { is_error: true, error: err },
|
|
312
|
+
},
|
|
313
|
+
projectDir,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const [filename] = listAntiPatterns(projectDir);
|
|
317
|
+
expect(filename).toMatch(/^nullability__src-cli-foo-ts__\d{8}\.md$/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('윈도우 외 실패는 카운트 안 됨', () => {
|
|
321
|
+
const err = "TypeError: Cannot read properties of undefined";
|
|
322
|
+
// 같은 카테고리 실패 2회
|
|
323
|
+
failBash('a', err, projectDir);
|
|
324
|
+
failBash('b', err, projectDir);
|
|
325
|
+
// 성공 툴콜로 윈도우 채움 (10줄 이상)
|
|
326
|
+
for (let i = 0; i < 10; i++) {
|
|
327
|
+
runCounter({
|
|
328
|
+
payload: { tool_name: 'Read', tool_input: { file_path: `f${i}.ts` }, tool_response: {} },
|
|
329
|
+
projectDir,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// 마지막 실패 1회 — 윈도우(10줄) 안에는 이 실패 + 직전 성공만 있으므로 트립 안 함
|
|
333
|
+
failBash('c', err, projectDir);
|
|
334
|
+
expect(listAntiPatterns(projectDir)).toHaveLength(0);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ───────── 차단 금지 ─────────
|
|
339
|
+
describe('hot path 안정성', () => {
|
|
340
|
+
it('항상 exit 0', () => {
|
|
341
|
+
const r = runCounter({
|
|
342
|
+
payload: { tool_name: 'Bash', tool_input: { command: 'x' }, tool_response: {} },
|
|
343
|
+
projectDir,
|
|
344
|
+
});
|
|
345
|
+
expect(r.status).toBe(0);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('잘못된 stdin JSON 도 차단 안 함', () => {
|
|
349
|
+
const r = spawnSync('node', [SCRIPT], {
|
|
350
|
+
input: 'not json at all',
|
|
351
|
+
encoding: 'utf-8',
|
|
352
|
+
timeout: 5000,
|
|
353
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir },
|
|
354
|
+
});
|
|
355
|
+
expect(r.status).toBe(0);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3 — Curation index loader
|
|
3
|
+
*
|
|
4
|
+
* `.vibe/recipes/*.md` 와 `.vibe/anti-patterns/*.md` 의 frontmatter 만 parse 해
|
|
5
|
+
* 1줄 요약 인덱스를 만든다. 본문은 읽지 않음 (세션 컨텍스트 절약).
|
|
6
|
+
*
|
|
7
|
+
* SPEC 결정:
|
|
8
|
+
* - INDEX.jsonl 미사용. 디렉토리 스캔 + frontmatter parse 가 충분히 빠르다 (<100 파일).
|
|
9
|
+
* - 최근 N=5 상한 (created 내림차순).
|
|
10
|
+
*
|
|
11
|
+
* 의도적 제한:
|
|
12
|
+
* - 본격 YAML parser 의존성 추가 거부. 우리가 *직접 작성*한 frontmatter 만
|
|
13
|
+
* 읽으므로 문법이 정해져 있다. 라인별 정규식이면 충분.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { projectVibeRoot } from '../utils.js';
|
|
18
|
+
|
|
19
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
|
|
20
|
+
// "key: value" 또는 'key: "quoted value"' (이스케이프 \\" 포함)
|
|
21
|
+
const FIELD_RE = /^([a-z][a-z0-9_-]*):\s*(?:"((?:[^"\\]|\\.)*)"|(.+?))\s*$/;
|
|
22
|
+
|
|
23
|
+
function parseFrontmatter(content) {
|
|
24
|
+
const m = FRONTMATTER_RE.exec(content);
|
|
25
|
+
if (!m) return null;
|
|
26
|
+
const fields = {};
|
|
27
|
+
for (const line of m[1].split('\n')) {
|
|
28
|
+
const fm = FIELD_RE.exec(line);
|
|
29
|
+
if (!fm) continue;
|
|
30
|
+
const key = fm[1];
|
|
31
|
+
const value = fm[2] !== undefined ? fm[2].replace(/\\"/g, '"') : fm[3];
|
|
32
|
+
fields[key] = value;
|
|
33
|
+
}
|
|
34
|
+
return fields;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readHead(filePath, bytes = 2048) {
|
|
38
|
+
const fd = fs.openSync(filePath, 'r');
|
|
39
|
+
try {
|
|
40
|
+
const buf = Buffer.alloc(bytes);
|
|
41
|
+
const n = fs.readSync(fd, buf, 0, bytes, 0);
|
|
42
|
+
return buf.toString('utf-8', 0, n);
|
|
43
|
+
} finally {
|
|
44
|
+
fs.closeSync(fd);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listMd(dir) {
|
|
49
|
+
if (!fs.existsSync(dir)) return [];
|
|
50
|
+
return fs.readdirSync(dir)
|
|
51
|
+
.filter((f) => f.endsWith('.md') && !f.startsWith('_') && f !== 'README.md')
|
|
52
|
+
.map((f) => path.join(dir, f));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeParse(filePath) {
|
|
56
|
+
try {
|
|
57
|
+
const head = readHead(filePath);
|
|
58
|
+
const fields = parseFrontmatter(head);
|
|
59
|
+
if (!fields || !fields.slug) return null;
|
|
60
|
+
return fields;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function compareCreatedDesc(a, b) {
|
|
67
|
+
// created 가 ISO 면 문자열 비교로 시간 정렬 가능, 아니면 mtime fallback
|
|
68
|
+
return (b.created || '').localeCompare(a.created || '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 프로젝트의 recipes + anti-patterns 인덱스를 로드.
|
|
73
|
+
* @returns { recipes: [{slug, summary}], antiPatterns: [{tag, summary}] }
|
|
74
|
+
*/
|
|
75
|
+
export function loadCurationIndex(projectDir, opts = {}) {
|
|
76
|
+
const { recipeLimit = 5, antiPatternLimit = 5 } = opts;
|
|
77
|
+
const root = projectVibeRoot(projectDir);
|
|
78
|
+
|
|
79
|
+
const recipes = listMd(path.join(root, 'recipes'))
|
|
80
|
+
.map(safeParse).filter(Boolean)
|
|
81
|
+
.sort(compareCreatedDesc)
|
|
82
|
+
.slice(0, recipeLimit)
|
|
83
|
+
.map((f) => ({
|
|
84
|
+
slug: f.slug,
|
|
85
|
+
summary: f.recipe || f['symptom-context'] || '(no summary)',
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
const antiPatterns = listMd(path.join(root, 'anti-patterns'))
|
|
89
|
+
.map(safeParse).filter(Boolean)
|
|
90
|
+
.sort(compareCreatedDesc)
|
|
91
|
+
.slice(0, antiPatternLimit)
|
|
92
|
+
.map((f) => ({
|
|
93
|
+
tag: f['root-cause-tag'] || 'other',
|
|
94
|
+
summary: f['suggested-stop'] || f['trigger-signature'] || '(no summary)',
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
return { recipes, antiPatterns };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Test-only: frontmatter parser 노출 */
|
|
101
|
+
export const _internal = { parseFrontmatter };
|