@su-record/vibe 2.12.5 → 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 +8 -2
- package/README.en.md +11 -11
- package/README.md +7 -7
- 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.js +2 -2
- 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/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 +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 +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/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 +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.run/SKILL.md +144 -1681
- 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/hooks/scripts/figma-guard.js +0 -219
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils.js — getGlobalNpmPath() 파일 캐시 테스트
|
|
3
|
+
*
|
|
4
|
+
* 검증 범위:
|
|
5
|
+
* - L2 파일 캐시 히트 (TTL 내)
|
|
6
|
+
* - L2 파일 캐시 만료 (TTL 초과)
|
|
7
|
+
* - 캐시 파일 손상 시 fail-open (execSync 재실행)
|
|
8
|
+
* - 캐시 파일 없음 시 execSync 실행 후 파일 저장
|
|
9
|
+
*
|
|
10
|
+
* 격리 전략: 각 테스트는 별도 임시 디렉토리를 캐시 경로로 사용한다.
|
|
11
|
+
* NPM_ROOT_CACHE_FILE 환경 변수를 통해 경로를 주입한다.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { spawnSync } from 'child_process';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import os from 'os';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const UTILS_PATH = path.resolve(__dirname, '..', 'utils.js');
|
|
22
|
+
|
|
23
|
+
function makeTempCacheFile() {
|
|
24
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vibe-npm-root-test-'));
|
|
25
|
+
return path.join(dir, 'npm-root.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* utils.js 의 getGlobalNpmPath() 를 별도 프로세스에서 실행.
|
|
30
|
+
* VIBE_NPM_ROOT_CACHE_FILE 환경 변수로 캐시 파일 경로를 주입한다.
|
|
31
|
+
*/
|
|
32
|
+
function runGetNpmRoot(cacheFilePath) {
|
|
33
|
+
return spawnSync('node', ['--input-type=module', '--eval',
|
|
34
|
+
`import { getGlobalNpmPath } from '${UTILS_PATH}';
|
|
35
|
+
process.stdout.write(getGlobalNpmPath() || '');`
|
|
36
|
+
], {
|
|
37
|
+
encoding: 'utf-8',
|
|
38
|
+
timeout: 10000,
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
VIBE_NPM_ROOT_CACHE_FILE: cacheFilePath,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('utils.js — getGlobalNpmPath() npm-root 파일 캐시', () => {
|
|
47
|
+
it('캐시 파일 없으면 execSync 실행 후 캐시 파일 생성', () => {
|
|
48
|
+
const cacheFile = makeTempCacheFile();
|
|
49
|
+
// 캐시 파일이 없는 상태에서 시작
|
|
50
|
+
expect(fs.existsSync(cacheFile)).toBe(false);
|
|
51
|
+
|
|
52
|
+
const result = runGetNpmRoot(cacheFile);
|
|
53
|
+
expect(result.status).toBe(0);
|
|
54
|
+
expect(result.stdout.trim()).toBeTruthy();
|
|
55
|
+
|
|
56
|
+
// 캐시 파일이 생성되어야 함
|
|
57
|
+
expect(fs.existsSync(cacheFile)).toBe(true);
|
|
58
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
|
|
59
|
+
expect(cached.npmRoot).toBe(result.stdout.trim());
|
|
60
|
+
expect(typeof cached.savedAt).toBe('number');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('유효한 캐시 파일이 있으면 execSync 없이 캐시값 반환', () => {
|
|
64
|
+
const cacheFile = makeTempCacheFile();
|
|
65
|
+
const fakeRoot = '/fake/npm/root/for/test';
|
|
66
|
+
// 유효한 캐시 미리 작성
|
|
67
|
+
fs.writeFileSync(cacheFile, JSON.stringify({ npmRoot: fakeRoot, savedAt: Date.now() }), { mode: 0o600 });
|
|
68
|
+
|
|
69
|
+
const result = runGetNpmRoot(cacheFile);
|
|
70
|
+
expect(result.status).toBe(0);
|
|
71
|
+
expect(result.stdout.trim()).toBe(fakeRoot);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('TTL 초과 캐시는 무효화 — execSync 재실행', () => {
|
|
75
|
+
const cacheFile = makeTempCacheFile();
|
|
76
|
+
const staleRoot = '/stale/path/should/not/be/used';
|
|
77
|
+
const expiredAt = Date.now() - (25 * 60 * 60 * 1000); // 25시간 전
|
|
78
|
+
fs.writeFileSync(cacheFile, JSON.stringify({ npmRoot: staleRoot, savedAt: expiredAt }), { mode: 0o600 });
|
|
79
|
+
|
|
80
|
+
const result = runGetNpmRoot(cacheFile);
|
|
81
|
+
expect(result.status).toBe(0);
|
|
82
|
+
// stale 값이 아닌 실제 npm root 가 반환되어야 함
|
|
83
|
+
expect(result.stdout.trim()).not.toBe(staleRoot);
|
|
84
|
+
expect(result.stdout.trim()).toBeTruthy();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('손상된 캐시 파일 — fail-open (execSync 실행)', () => {
|
|
88
|
+
const cacheFile = makeTempCacheFile();
|
|
89
|
+
fs.writeFileSync(cacheFile, '{ broken json :::');
|
|
90
|
+
|
|
91
|
+
const result = runGetNpmRoot(cacheFile);
|
|
92
|
+
expect(result.status).toBe(0);
|
|
93
|
+
expect(result.stdout.trim()).toBeTruthy(); // 실제 경로 반환
|
|
94
|
+
// 손상된 파일 때문에 프로세스가 crash 나지 않아야 함
|
|
95
|
+
const stderr = result.stderr || '';
|
|
96
|
+
expect(stderr).not.toMatch(/^Error:/m);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -13,12 +13,38 @@
|
|
|
13
13
|
* 으로 롤백 가능. 최근 5개만 유지.
|
|
14
14
|
*/
|
|
15
15
|
import { execSync } from 'child_process';
|
|
16
|
-
import { PROJECT_DIR, readProjectConfig } from './utils.js';
|
|
16
|
+
import { PROJECT_DIR, readProjectConfig, logHookDecision } from './utils.js';
|
|
17
|
+
import { readLedger } from './lib/run-ledger.js';
|
|
17
18
|
|
|
18
19
|
// Opt-in 가드 — 명시적으로 켜지 않았으면 아무것도 하지 않는다.
|
|
19
20
|
const __autoCommitCfg = readProjectConfig();
|
|
20
21
|
if (__autoCommitCfg?.hooks?.['auto-commit']?.enabled !== true) process.exit(0);
|
|
21
22
|
|
|
23
|
+
// verify 게이트 — vibe.run 세션이 시작됐으면 verifyPassed가 true이고
|
|
24
|
+
// verifyAt > runStarted 인 경우에만 커밋을 허용한다.
|
|
25
|
+
const __ledger = readLedger(PROJECT_DIR);
|
|
26
|
+
if (__ledger && __ledger.runStarted) {
|
|
27
|
+
const verifyOk = __ledger.verifyPassed === true
|
|
28
|
+
&& __ledger.verifyAt
|
|
29
|
+
&& __ledger.verifyAt > __ledger.runStarted;
|
|
30
|
+
if (!verifyOk) {
|
|
31
|
+
const reason = !__ledger.verifyPassed
|
|
32
|
+
? 'vibe.verify not passed — run /vibe.verify before committing'
|
|
33
|
+
: 'verifyAt is not after runStarted — re-run /vibe.verify';
|
|
34
|
+
logHookDecision('auto-commit', 'git-commit', 'block', reason);
|
|
35
|
+
process.stderr.write(`[auto-commit] SKIP: ${reason}\n`);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// verifyRequired 게이트 — PostToolUse에서 P1 이슈가 발견되어 verify가 요구됨.
|
|
41
|
+
if (__ledger && __ledger.verifyRequired === true) {
|
|
42
|
+
const reason = `P1 issue requires verification: ${__ledger.verifyRequiredReason || 'see code-check findings'}`;
|
|
43
|
+
logHookDecision('auto-commit', 'git-commit', 'block', reason);
|
|
44
|
+
process.stderr.write(`[auto-commit] SKIP: ${reason}\n`);
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
const PROTECTED_BRANCHES = ['main', 'master', 'develop', 'production'];
|
|
23
49
|
const MAX_FILES_IN_MSG = 5;
|
|
24
50
|
const MAX_CHECKPOINTS = 5;
|
|
@@ -4,19 +4,33 @@
|
|
|
4
4
|
* 프로젝트에 설치된 포매터를 감지하고 수정된 파일에 자동 실행.
|
|
5
5
|
* Prettier(JS/TS), Black(Python), gofmt(Go) 지원.
|
|
6
6
|
* 200ms 이내 완료 목표 — 단일 파일만 처리.
|
|
7
|
+
*
|
|
8
|
+
* 변경 감지: mtime 비교로 prettier가 실제 파일을 수정했는지 판단.
|
|
9
|
+
* 수정된 경우 finding을 반환 — 디스패처가 additionalContext에 포함시킨다.
|
|
7
10
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
11
|
+
import { execFile } from 'child_process';
|
|
12
|
+
import { promisify } from 'util';
|
|
13
|
+
import { existsSync, statSync } from 'fs';
|
|
10
14
|
import path from 'path';
|
|
11
15
|
import { PROJECT_DIR } from './utils.js';
|
|
16
|
+
import { buildCliCtx, isDirectRun } from './lib/hook-context.js';
|
|
17
|
+
|
|
18
|
+
// WHY async execFile (not execSync): in-process 디스패처에서 다른 step과
|
|
19
|
+
// Promise.all로 병렬 실행되므로, 동기 실행은 이벤트 루프를 막아 체인을 직렬화시킨다.
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
12
21
|
|
|
13
22
|
const CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs|css|scss|json|md|html|vue|svelte)$/;
|
|
14
23
|
const PYTHON_EXT_RE = /\.py$/;
|
|
15
24
|
const GO_EXT_RE = /\.go$/;
|
|
25
|
+
const FORMAT_TIMEOUT_MS = 5000;
|
|
16
26
|
|
|
17
|
-
function getFilePath() {
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
function getFilePath(ctx) {
|
|
28
|
+
try {
|
|
29
|
+
const input = JSON.parse(ctx.toolInput || '{}');
|
|
30
|
+
return input.file_path || input.path || '';
|
|
31
|
+
} catch {
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
// PATH 직접 스캔 — `which` execSync는 매 파일 저장마다 자식 프로세스를 동기
|
|
@@ -37,33 +51,84 @@ function hasPrettier() {
|
|
|
37
51
|
return existsSync(path.join(PROJECT_DIR, 'node_modules', '.bin', 'prettier'));
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
|
|
54
|
+
/**
|
|
55
|
+
* mtimeMs 읽기 — stat 실패 시 0 반환 (fail-open).
|
|
56
|
+
* @param {string} resolvedPath
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
function getMtime(resolvedPath) {
|
|
60
|
+
try {
|
|
61
|
+
return statSync(resolvedPath).mtimeMs;
|
|
62
|
+
} catch {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 파일 포맷 실행. 실제 변경이 발생했으면 finding 문자열을 반환.
|
|
69
|
+
* @param {string} filePath
|
|
70
|
+
* @returns {Promise<string|null>} finding or null
|
|
71
|
+
*/
|
|
72
|
+
async function formatFile(filePath) {
|
|
41
73
|
const resolved = path.resolve(filePath);
|
|
42
|
-
if (!existsSync(resolved)) return;
|
|
74
|
+
if (!existsSync(resolved)) return null;
|
|
43
75
|
|
|
44
76
|
try {
|
|
45
77
|
if (CODE_EXT_RE.test(filePath) && hasPrettier()) {
|
|
46
|
-
|
|
78
|
+
const mtimeBefore = getMtime(resolved);
|
|
79
|
+
await execFileAsync('npx', ['prettier', '--write', resolved], {
|
|
47
80
|
cwd: PROJECT_DIR,
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
timeout: FORMAT_TIMEOUT_MS,
|
|
82
|
+
// Windows에서 npx는 npx.cmd — shell 없이는 execFile이 찾지 못함
|
|
83
|
+
shell: process.platform === 'win32',
|
|
50
84
|
});
|
|
51
|
-
|
|
85
|
+
const mtimeAfter = getMtime(resolved);
|
|
86
|
+
if (mtimeAfter > mtimeBefore) {
|
|
87
|
+
return `auto-format reformatted ${path.basename(resolved)} — re-read before further edits to avoid stale old_string`;
|
|
88
|
+
}
|
|
52
89
|
} else if (PYTHON_EXT_RE.test(filePath) && hasBin('black')) {
|
|
53
|
-
|
|
54
|
-
|
|
90
|
+
const mtimeBefore = getMtime(resolved);
|
|
91
|
+
await execFileAsync('black', ['--quiet', resolved], { timeout: FORMAT_TIMEOUT_MS });
|
|
92
|
+
const mtimeAfter = getMtime(resolved);
|
|
93
|
+
if (mtimeAfter > mtimeBefore) {
|
|
94
|
+
return `auto-format reformatted ${path.basename(resolved)} — re-read before further edits to avoid stale old_string`;
|
|
95
|
+
}
|
|
55
96
|
} else if (GO_EXT_RE.test(filePath) && hasBin('gofmt')) {
|
|
56
|
-
|
|
57
|
-
|
|
97
|
+
const mtimeBefore = getMtime(resolved);
|
|
98
|
+
await execFileAsync('gofmt', ['-w', resolved], { timeout: FORMAT_TIMEOUT_MS });
|
|
99
|
+
const mtimeAfter = getMtime(resolved);
|
|
100
|
+
if (mtimeAfter > mtimeBefore) {
|
|
101
|
+
return `auto-format reformatted ${path.basename(resolved)} — re-read before further edits to avoid stale old_string`;
|
|
102
|
+
}
|
|
58
103
|
}
|
|
59
104
|
} catch {
|
|
60
105
|
// Format failure should never block — silently continue
|
|
61
106
|
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* in-process 진입점 — 포맷 실행. finding 문자열 배열 반환.
|
|
112
|
+
* @param {{ toolInput: string }} ctx
|
|
113
|
+
* @returns {Promise<{ exitCode: number, findings: string[] }>}
|
|
114
|
+
*/
|
|
115
|
+
export async function run(ctx) {
|
|
116
|
+
const findings = [];
|
|
117
|
+
try {
|
|
118
|
+
const filePath = getFilePath(ctx);
|
|
119
|
+
if (filePath) {
|
|
120
|
+
const finding = await formatFile(filePath);
|
|
121
|
+
if (finding) findings.push(finding);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Silent fail
|
|
125
|
+
}
|
|
126
|
+
return { exitCode: 0, findings };
|
|
62
127
|
}
|
|
63
128
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
129
|
+
// standalone CLI 모드
|
|
130
|
+
if (isDirectRun(import.meta.url)) {
|
|
131
|
+
const { exitCode, findings } = await run(buildCliCtx());
|
|
132
|
+
if (findings.length > 0) process.stdout.write(findings.join('\n') + '\n');
|
|
133
|
+
process.exit(exitCode);
|
|
69
134
|
}
|
|
@@ -3,20 +3,43 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 수정된 파일에 대응하는 테스트 파일을 찾아 실행.
|
|
5
5
|
* 실패 시 마지막 5줄만 출력해서 context window 오염 방지.
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* debounce 지원: autoTest.mode='debounce'(기본) | 'always' | 'off' via .vibe/config.json
|
|
8
|
+
* debounce 모드: 동일 테스트 파일을 DEBOUNCE_COOLDOWN_MS(120s) 내에
|
|
9
|
+
* 소스 변경 없이 재실행 스킵.
|
|
10
|
+
*
|
|
11
|
+
* findings 반환 구조 (디스패처가 additionalContext에 주입).
|
|
7
12
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
13
|
+
import { execFile } from 'child_process';
|
|
14
|
+
import { promisify } from 'util';
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
16
|
+
import { createHash } from 'crypto';
|
|
10
17
|
import path from 'path';
|
|
11
|
-
import { PROJECT_DIR } from './utils.js';
|
|
18
|
+
import { PROJECT_DIR, readProjectConfig } from './utils.js';
|
|
19
|
+
import { buildCliCtx, isDirectRun } from './lib/hook-context.js';
|
|
20
|
+
|
|
21
|
+
// WHY async execFile (not execSync): in-process 디스패처에서 다른 step과
|
|
22
|
+
// Promise.all로 병렬 실행되므로, 60초 동기 실행은 체인 전체를 직렬화시킨다.
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
12
24
|
|
|
13
25
|
const CODE_EXT_RE = /\.(ts|tsx|js|jsx)$/;
|
|
14
26
|
const TEST_SUFFIXES = ['.test.', '.spec.'];
|
|
15
27
|
const MAX_OUTPUT_LINES = 5;
|
|
28
|
+
const TEST_TIMEOUT_MS = 60000;
|
|
16
29
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
30
|
+
/** debounce 쿨다운 — 동일 테스트 + 동일 소스 해시에 대해 스킵하는 시간(ms) */
|
|
31
|
+
const DEBOUNCE_COOLDOWN_MS = 120_000;
|
|
32
|
+
|
|
33
|
+
/** debounce 상태 파일 경로 */
|
|
34
|
+
const DEBOUNCE_STATE_FILE = path.join(PROJECT_DIR, '.vibe', 'metrics', 'auto-test-state.json');
|
|
35
|
+
|
|
36
|
+
function getFilePath(ctx) {
|
|
37
|
+
try {
|
|
38
|
+
const input = JSON.parse(ctx.toolInput || '{}');
|
|
39
|
+
return input.file_path || input.path || '';
|
|
40
|
+
} catch {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
20
43
|
}
|
|
21
44
|
|
|
22
45
|
function isTestFile(filePath) {
|
|
@@ -46,36 +69,163 @@ function hasJest() {
|
|
|
46
69
|
return existsSync(path.join(PROJECT_DIR, 'node_modules', '.bin', 'jest'));
|
|
47
70
|
}
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
/**
|
|
73
|
+
* autoTest.mode 설정 읽기 — 기본 'debounce'.
|
|
74
|
+
* @returns {'debounce'|'always'|'off'}
|
|
75
|
+
*/
|
|
76
|
+
function getTestMode() {
|
|
77
|
+
try {
|
|
78
|
+
const cfg = readProjectConfig();
|
|
79
|
+
const mode = cfg?.autoTest?.mode;
|
|
80
|
+
if (mode === 'always' || mode === 'off' || mode === 'debounce') return mode;
|
|
81
|
+
} catch { /* ignore */ }
|
|
82
|
+
return 'debounce';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 파일 내용 SHA-256 해시 (앞 16자만 사용). 파일 없으면 빈 문자열.
|
|
87
|
+
* @param {string} filePath
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function fileHash(filePath) {
|
|
91
|
+
try {
|
|
92
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
93
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
94
|
+
} catch {
|
|
95
|
+
return '';
|
|
64
96
|
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* debounce 상태 파일 읽기. fail-open → 빈 객체.
|
|
101
|
+
* @returns {Record<string, { lastRun: number, srcHash: string }>}
|
|
102
|
+
*/
|
|
103
|
+
function readDebounceState() {
|
|
104
|
+
try {
|
|
105
|
+
if (existsSync(DEBOUNCE_STATE_FILE)) {
|
|
106
|
+
return JSON.parse(readFileSync(DEBOUNCE_STATE_FILE, 'utf-8'));
|
|
107
|
+
}
|
|
108
|
+
} catch { /* ignore */ }
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* debounce 상태 파일 쓰기. fail-open.
|
|
114
|
+
* @param {Record<string, { lastRun: number, srcHash: string }>} state
|
|
115
|
+
*/
|
|
116
|
+
function writeDebounceState(state) {
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(path.dirname(DEBOUNCE_STATE_FILE), { recursive: true });
|
|
119
|
+
writeFileSync(DEBOUNCE_STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
|
|
120
|
+
} catch { /* fail-open */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* debounce 체크: testFile을 스킵해야 하면 true.
|
|
125
|
+
* @param {string} testFile — 절대 경로
|
|
126
|
+
* @param {string} srcFile — 절대 경로 (소스 파일, 테스트가 아닌 경우)
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
function shouldSkipDebounce(testFile, srcFile) {
|
|
130
|
+
try {
|
|
131
|
+
const state = readDebounceState();
|
|
132
|
+
const entry = state[testFile];
|
|
133
|
+
if (!entry) return false;
|
|
134
|
+
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const elapsed = now - entry.lastRun;
|
|
137
|
+
if (elapsed > DEBOUNCE_COOLDOWN_MS) return false;
|
|
138
|
+
|
|
139
|
+
const currentHash = fileHash(srcFile);
|
|
140
|
+
if (currentHash !== entry.srcHash) return false;
|
|
141
|
+
|
|
142
|
+
return true; // 쿨다운 내 + 소스 미변경 → 스킵
|
|
143
|
+
} catch {
|
|
144
|
+
return false; // fail-open
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* debounce 상태 업데이트.
|
|
150
|
+
* @param {string} testFile
|
|
151
|
+
* @param {string} srcFile
|
|
152
|
+
*/
|
|
153
|
+
function updateDebounceState(testFile, srcFile) {
|
|
154
|
+
try {
|
|
155
|
+
const state = readDebounceState();
|
|
156
|
+
state[testFile] = {
|
|
157
|
+
lastRun: Date.now(),
|
|
158
|
+
srcHash: fileHash(srcFile),
|
|
159
|
+
};
|
|
160
|
+
writeDebounceState(state);
|
|
161
|
+
} catch { /* fail-open */ }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* in-process 진입점 — 테스트 실행. findings 반환.
|
|
166
|
+
* @param {{ toolInput: string }} ctx
|
|
167
|
+
* @returns {Promise<{ exitCode: number, findings: string[] }>}
|
|
168
|
+
*/
|
|
169
|
+
export async function run(ctx) {
|
|
170
|
+
const findings = [];
|
|
171
|
+
try {
|
|
172
|
+
const filePath = getFilePath(ctx);
|
|
173
|
+
if (!filePath || !CODE_EXT_RE.test(filePath)) return { exitCode: 0, findings };
|
|
174
|
+
|
|
175
|
+
const mode = getTestMode();
|
|
176
|
+
if (mode === 'off') return { exitCode: 0, findings };
|
|
177
|
+
|
|
178
|
+
const srcFile = path.resolve(filePath);
|
|
179
|
+
const testFile = isTestFile(filePath) ? srcFile : findTestFile(srcFile);
|
|
180
|
+
if (!testFile) return { exitCode: 0, findings };
|
|
181
|
+
|
|
182
|
+
// debounce 모드: 스킵 여부 확인
|
|
183
|
+
if (mode === 'debounce') {
|
|
184
|
+
const skipSrc = isTestFile(filePath) ? testFile : srcFile;
|
|
185
|
+
if (shouldSkipDebounce(testFile, skipSrc)) {
|
|
186
|
+
// 스팸 방지: 스킵 시 finding 없음 (조용히)
|
|
187
|
+
return { exitCode: 0, findings };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const relPath = path.relative(PROJECT_DIR, testFile);
|
|
192
|
+
let args = null;
|
|
193
|
+
if (hasVitest()) {
|
|
194
|
+
args = ['vitest', 'run', relPath, '--reporter=verbose'];
|
|
195
|
+
} else if (hasJest()) {
|
|
196
|
+
args = ['jest', relPath, '--no-coverage'];
|
|
197
|
+
} else {
|
|
198
|
+
return { exitCode: 0, findings };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// debounce 상태 업데이트 (실행 전)
|
|
202
|
+
if (mode === 'debounce') {
|
|
203
|
+
const skipSrc = isTestFile(filePath) ? testFile : srcFile;
|
|
204
|
+
updateDebounceState(testFile, skipSrc);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const { stdout } = await execFileAsync('npx', args, {
|
|
208
|
+
cwd: PROJECT_DIR,
|
|
209
|
+
timeout: TEST_TIMEOUT_MS,
|
|
210
|
+
// Windows에서 npx는 npx.cmd — shell 없이는 execFile이 찾지 못함
|
|
211
|
+
shell: process.platform === 'win32',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const tail = stdout.trim().split('\n').slice(-MAX_OUTPUT_LINES).join('\n');
|
|
215
|
+
findings.push(`[AUTO-TEST] PASSED: ${relPath}\n${tail}`);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
const stderr = err.stderr ? err.stderr.toString() : '';
|
|
218
|
+
const stdout = err.stdout ? err.stdout.toString() : '';
|
|
219
|
+
const combined = (stdout + '\n' + stderr).trim();
|
|
220
|
+
const tail = combined.split('\n').slice(-MAX_OUTPUT_LINES).join('\n');
|
|
221
|
+
findings.push(`[AUTO-TEST] FAILED\n${tail}`);
|
|
222
|
+
}
|
|
223
|
+
return { exitCode: 0, findings };
|
|
224
|
+
}
|
|
65
225
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}).toString();
|
|
72
|
-
|
|
73
|
-
const tail = output.trim().split('\n').slice(-MAX_OUTPUT_LINES).join('\n');
|
|
74
|
-
console.log(`[AUTO-TEST] PASSED\n${tail}`);
|
|
75
|
-
} catch (err) {
|
|
76
|
-
const stderr = err.stderr ? err.stderr.toString() : '';
|
|
77
|
-
const stdout = err.stdout ? err.stdout.toString() : '';
|
|
78
|
-
const combined = (stdout + '\n' + stderr).trim();
|
|
79
|
-
const tail = combined.split('\n').slice(-MAX_OUTPUT_LINES).join('\n');
|
|
80
|
-
console.log(`[AUTO-TEST] FAILED\n${tail}`);
|
|
226
|
+
// standalone CLI 모드
|
|
227
|
+
if (isDirectRun(import.meta.url)) {
|
|
228
|
+
const { exitCode, findings } = await run(buildCliCtx());
|
|
229
|
+
if (findings.length > 0) process.stdout.write(findings.join('\n') + '\n');
|
|
230
|
+
process.exit(exitCode);
|
|
81
231
|
}
|