@tuzi-ince/hi-loop 0.2.1 → 0.3.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/src/vcs.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 브랜치·커밋 메커니즘 (FR-16) — 엔진이 소유하는 git 조작.
3
+ *
4
+ * 철학: **엔진은 메커니즘(git 실행)을, LLM 은 산문(좋은 커밋 메시지)을 소유한다.**
5
+ * 그래서 여기 함수들은 순수 git 이고, 메시지는 인자로 받거나(스킬이 지어 넘김) 없으면
6
+ * goal 기반 휴리스틱으로 만든다. checkpoint.js 의 git 패턴(실패 시 null, 비-git skip)을 따른다.
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { isGitRepo } from './checkpoint.js';
10
+
11
+ /** `{ ok, out }`. 실패(비-0 exit)면 ok:false, out 은 stdout+stderr. */
12
+ function git(args, cwd) {
13
+ return new Promise((resolve) => {
14
+ execFile('git', args, { cwd, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
15
+ const out = `${String(stdout ?? '')}${String(stderr ?? '')}`.trim();
16
+ resolve({ ok: !err, out });
17
+ });
18
+ });
19
+ }
20
+
21
+ /** 현재 브랜치명. detached/비-git 이면 null. */
22
+ export async function currentBranch(cwd) {
23
+ const r = await git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
24
+ if (!r.ok) return null;
25
+ const name = r.out.trim();
26
+ return name && name !== 'HEAD' ? name : null; // 'HEAD' = detached
27
+ }
28
+
29
+ /** 이 브랜치가 보호 대상인가. */
30
+ export function isProtected(branch, protectedBranches) {
31
+ if (!branch) return false;
32
+ const list = Array.isArray(protectedBranches) ? protectedBranches : [];
33
+ return list.includes(branch);
34
+ }
35
+
36
+ /** goal → 브랜치 slug. 소문자 kebab, 안전 문자만, 앞부분만. 비면 'change'. */
37
+ export function slugify(goal) {
38
+ const base = String(goal ?? '')
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9가-힣]+/g, '-') // 공백·기호 → 하이픈 (한글은 보존)
41
+ .replace(/^-+|-+$/g, '')
42
+ .slice(0, 40)
43
+ .replace(/-+$/g, '');
44
+ return base || 'change';
45
+ }
46
+
47
+ /** feature 브랜치로 생성/전환. 이미 있으면 전환. 반환 `{ ok, branch, created, reason }`. */
48
+ export async function createBranch(cwd, name) {
49
+ const branch = String(name ?? '').trim();
50
+ if (!branch) return { ok: false, reason: '브랜치 이름이 비어 있습니다.' };
51
+ const exists = (await git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], cwd)).ok;
52
+ const r = exists ? await git(['switch', branch], cwd) : await git(['switch', '-c', branch], cwd);
53
+ if (!r.ok) return { ok: false, reason: r.out || 'git switch 실패' };
54
+ return { ok: true, branch, created: !exists };
55
+ }
56
+
57
+ /** 워킹트리 전체를 스테이징하고 커밋. 변경 없으면 실패로 돌려준다. 반환 `{ ok, sha, reason }`. */
58
+ export async function commitAll(cwd, message) {
59
+ const msg = String(message ?? '').trim();
60
+ if (!msg) return { ok: false, reason: '커밋 메시지가 비어 있습니다.' };
61
+ await git(['add', '-A'], cwd);
62
+ // 스테이징 후에도 변경이 없으면 커밋할 게 없다 — 조용히 실패로 신호.
63
+ const staged = await git(['diff', '--cached', '--quiet'], cwd);
64
+ if (staged.ok) return { ok: false, reason: '커밋할 변경이 없습니다.' };
65
+ const c = await git(['commit', '-m', msg], cwd);
66
+ if (!c.ok) return { ok: false, reason: c.out || 'git commit 실패' };
67
+ const sha = await git(['rev-parse', 'HEAD'], cwd);
68
+ return { ok: true, sha: sha.ok ? sha.out.trim() : null };
69
+ }
70
+
71
+ const CONVENTIONAL = /^(\w+)(\(.+\))?!?:\s/;
72
+
73
+ /** 최근 커밋 제목들을 보고 Conventional Commits 관행인지 추정. 'conventional' | 'plain'. */
74
+ export async function detectCommitStyle(cwd) {
75
+ const r = await git(['log', '-20', '--pretty=%s'], cwd);
76
+ if (!r.ok || !r.out) return 'plain'; // 커밋 이력이 없으면 평문
77
+ const subjects = r.out.split('\n').filter(Boolean);
78
+ const conv = subjects.filter((s) => CONVENTIONAL.test(s)).length;
79
+ return conv / subjects.length >= 0.5 ? 'conventional' : 'plain';
80
+ }
81
+
82
+ /** goal 키워드 → conventional type. 확신 없으면 chore. */
83
+ function inferType(goal) {
84
+ const g = String(goal ?? '').toLowerCase();
85
+ if (/(추가|만들|신규|기능|feat|add|implement)/.test(g)) return 'feat';
86
+ if (/(고치|버그|수정|오류|fix|bug)/.test(g)) return 'fix';
87
+ if (/(리팩터|정리|refactor|cleanup)/.test(g)) return 'refactor';
88
+ if (/(문서|docs|readme)/.test(g)) return 'docs';
89
+ if (/(테스트|test)/.test(g)) return 'test';
90
+ return 'chore';
91
+ }
92
+
93
+ /** goal 첫 줄을 커밋 제목 길이로 다듬는다. */
94
+ function firstLine(goal) {
95
+ const line = String(goal ?? '').split('\n')[0].trim();
96
+ return line.length > 72 ? `${line.slice(0, 69)}...` : line;
97
+ }
98
+
99
+ /**
100
+ * 메시지가 안 주어졌을 때 goal 로 커밋 메시지를 만든다.
101
+ * styleConfig: 'korean'=goal 그대로 | 'conventional'=type 접두 | 'match-log'=repo 관행 따름.
102
+ */
103
+ export async function buildCommitMessage({ goal, styleConfig = 'match-log', cwd }) {
104
+ const subject = firstLine(goal) || 'update';
105
+ if (styleConfig === 'korean') return subject;
106
+ const conventional =
107
+ styleConfig === 'conventional' || (styleConfig === 'match-log' && (await detectCommitStyle(cwd)) === 'conventional');
108
+ return conventional ? `${inferType(goal)}: ${subject}` : subject;
109
+ }
110
+
111
+ /**
112
+ * 주입 가능한 브랜치 포트 (NFR-3). 보호 브랜치에 있을 때만 feature 로 분기한다.
113
+ * 반환: `{ branched, branch, reason }`.
114
+ */
115
+ export function makeBrancher() {
116
+ return async ({ cwd, name = null, goal, protectedBranches, logger = () => {} }) => {
117
+ if (!(await isGitRepo(cwd))) return { branched: false, reason: 'non-git' };
118
+ const cur = await currentBranch(cwd);
119
+ if (!isProtected(cur, protectedBranches)) {
120
+ return { branched: false, branch: cur, reason: 'not-protected' };
121
+ }
122
+ const target = (typeof name === 'string' && name.trim()) ? name.trim() : `feature/${slugify(goal)}`;
123
+ const r = await createBranch(cwd, target);
124
+ if (!r.ok) {
125
+ logger(`[hi-loop] ⚠️ 브랜치 분기 실패: ${r.reason}`);
126
+ return { branched: false, reason: r.reason };
127
+ }
128
+ logger(`[hi-loop] 🌱 ${cur} → ${r.branch} (${r.created ? '생성' : '전환'})`);
129
+ return { branched: true, branch: r.branch };
130
+ };
131
+ }
132
+
133
+ /**
134
+ * 브랜치 프리스텝 (FR-16) — loop.js 를 얇게 유지하려 여기 둔다.
135
+ * 분기했으면 브랜치명을, 아니면 null 을 돌려준다.
136
+ */
137
+ export async function branchPreStep({ brancher, cwd, branch, goal, protectedBranches, logger }) {
138
+ if (branch == null) return null;
139
+ const res = await brancher({
140
+ cwd,
141
+ name: typeof branch === 'string' ? branch : null,
142
+ goal,
143
+ protectedBranches,
144
+ logger,
145
+ });
146
+ return res.branched ? res.branch : null;
147
+ }
148
+
149
+ /**
150
+ * 주입 가능한 커밋 포트 (NFR-3). 메시지가 없으면 goal 로 만든다. 비-git·변경없음이면 조용히 skip.
151
+ * 반환: `{ committed, sha, message, reason }`.
152
+ */
153
+ export function makeCommitter() {
154
+ return async ({ cwd, message = null, goal, styleConfig = 'match-log', logger = () => {} }) => {
155
+ if (!(await isGitRepo(cwd))) return { committed: false, reason: 'non-git' };
156
+ const msg = (typeof message === 'string' && message.trim())
157
+ ? message.trim()
158
+ : await buildCommitMessage({ goal, styleConfig, cwd });
159
+ const r = await commitAll(cwd, msg);
160
+ if (!r.ok) {
161
+ logger(`[hi-loop] · 커밋 skip — ${r.reason}`);
162
+ return { committed: false, message: msg, reason: r.reason };
163
+ }
164
+ logger(`[hi-loop] ✅ 커밋 ${r.sha?.slice(0, 8)} — ${msg}`);
165
+ return { committed: true, sha: r.sha, message: msg };
166
+ };
167
+ }
package/src/verdict.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 판정 조립 — Tier 1 의 여러 신호를 하나의 실패 사유와 하나의 정체 지문으로 합친다.
3
+ *
4
+ * build.js 에서 뺀 이유는 크기(NFR-5)만이 아니다. 이 세 함수는 **부수효과가 없다** —
5
+ * state 도 로거도 만지지 않고, 신호를 받아 문자열을 돌려줄 뿐이다. 순수한 부분을 떼어놓으면
6
+ * 판정 규칙을 루프를 돌리지 않고 단위 테스트할 수 있고, 규칙이 늘어도 CHECK 블록이
7
+ * 커지지 않는다. 가드가 하나 늘 때마다 손대는 곳도 여기 한 곳이다.
8
+ */
9
+ import { errorSignature } from './state.js';
10
+ import { violationMessage } from './integrity.js';
11
+ import { regressionMessage } from './metrics.js';
12
+
13
+ /**
14
+ * 무결성 신호 합성: 지문 비교(L1) + 유령 테스트(L13).
15
+ * 둘 다 "테스트를 믿을 수 있는가"에 대한 답이라 한 결과로 합친다.
16
+ */
17
+ export function composeIntegrity(fpViolations, ghostMessages) {
18
+ const violations = [...(fpViolations ?? []), ...(ghostMessages ?? [])];
19
+ return { ok: violations.length === 0, violations };
20
+ }
21
+
22
+ /**
23
+ * 다음 루프의 연료가 될 실패 사유.
24
+ *
25
+ * 순서가 규칙이다. 절단은 tail-biased 이므로(§5.2) **꼬리에 있는 것이 살아남는다.**
26
+ * 그래서 에이전트가 반드시 봐야 할 위반 사유를 뒤에 둔다 — 앞에 두면 잘려나가고,
27
+ * 에이전트는 자기가 뭘 어겼는지 모른 채 같은 짓을 반복한다.
28
+ */
29
+ export function composeFailure({ specReject, output, checkCode, specPath, integrity, regressed, baseline, metrics }) {
30
+ if (specReject) {
31
+ return [
32
+ '스펙 검증이 이 구현을 기각했다. 테스트는 통과했지만 스펙 요구를 못 채웠다:',
33
+ specReject,
34
+ `스펙(${specPath})을 다시 읽고 그 요구를 충족하도록 구현을 고쳐라. 테스트만 통과시키는 것으로는 부족하다.`,
35
+ ].join('\n');
36
+ }
37
+ const parts = [output || `테스트가 코드 ${checkCode}로 실패했습니다.`];
38
+ if (!integrity.ok) parts.push(violationMessage(integrity.violations));
39
+ if (regressed?.length) parts.push(regressionMessage(baseline, metrics, regressed));
40
+ return parts.join('\n\n');
41
+ }
42
+
43
+ /**
44
+ * 정체 판정용 지문. 같은 값이 반복되면 이 접근으로는 못 고친다는 뜻이다.
45
+ *
46
+ * 실패의 **종류**마다 다른 접두사를 붙인다. 스펙 기각과 테스트 실패가 같은 지문을 가지면
47
+ * 서로 다른 두 문제를 오가는 실행이 정체로 오판된다.
48
+ */
49
+ export function stagnationSignature({ specReject, integrity, regressed, output }) {
50
+ if (specReject) return `SPEC:${errorSignature(specReject)}`;
51
+ if (!integrity.ok) return `INTEGRITY:${integrity.violations.join('|')}`;
52
+ if (regressed?.length) return `REGRESS:${regressed.join('|')}`;
53
+ return errorSignature(output);
54
+ }
package/src/verify.js CHANGED
@@ -40,12 +40,13 @@ export function parseVerdict(text) {
40
40
  const m = String(text).match(/\{[\s\S]*"verdict"[\s\S]*\}/);
41
41
  const obj = JSON.parse(m ? m[0] : text);
42
42
  const verdict = obj.verdict === 'reject' ? 'reject' : 'pass';
43
- return { verdict, reason: typeof obj.reason === 'string' ? obj.reason : '' };
43
+ // 여기만 verified: true 검증자가 실제로 판정문을 냈다는 뜻이다.
44
+ return { verdict, verified: true, reason: typeof obj.reason === 'string' ? obj.reason : '' };
44
45
  } catch {
45
46
  // 판정 형식을 못 읽으면 기각하지 않는다 — 검증자가 헛소리를 했다고 통과를
46
47
  // 막으면, 잘못된 입력이 방어를 "더 약하게"가 아니라 "더 세게" 만드는 게 아니라
47
48
  // 반대가 된다. Tier 2 는 하향 전용이고 확신 없으면 통과가 원칙이다.
48
- return { verdict: 'pass', reason: '검증자 응답을 파싱하지 못해 통과로 처리(하향 전용)' };
49
+ return { verdict: 'pass', verified: false, reason: '검증자 응답을 파싱하지 못해 통과로 처리(하향 전용)' };
49
50
  }
50
51
  }
51
52
 
@@ -63,7 +64,7 @@ export function makeSpecVerifier({
63
64
  const specFull = specPath ? join(cwd, specPath) : null;
64
65
  if (!specFull || !existsSync(specFull)) {
65
66
  // 스펙이 없으면 대조할 오라클이 없다 → 기각할 근거 없음 → 통과.
66
- resolve({ verdict: 'pass', reason: '스펙 파일이 없어 검증 생략' });
67
+ resolve({ verdict: 'pass', verified: false, reason: '스펙 파일이 없어 검증 생략' });
67
68
  return;
68
69
  }
69
70
  const specText = readFileSync(specFull, 'utf8');
@@ -81,12 +82,12 @@ export function makeSpecVerifier({
81
82
  child.on('error', () => {
82
83
  clearTimeout(timer);
83
84
  // 검증자 실행 자체가 실패하면 통과를 막지 않는다(인프라 장애가 판정을 뒤집으면 안 됨).
84
- resolve({ verdict: 'pass', reason: `검증자 실행 실패 — 통과로 처리` });
85
+ resolve({ verdict: 'pass', verified: false, reason: `검증자 실행 실패 — 통과로 처리` });
85
86
  });
86
87
  child.on('close', (code) => {
87
88
  clearTimeout(timer);
88
89
  if (code !== 0) {
89
- resolve({ verdict: 'pass', reason: `검증자가 코드 ${code}로 종료 — 통과로 처리` });
90
+ resolve({ verdict: 'pass', verified: false, reason: `검증자가 코드 ${code}로 종료 — 통과로 처리` });
90
91
  return;
91
92
  }
92
93
  const { text } = parseAgentOutput(stdout);
package/src/wiring.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 미배선 검출 (L19) — "만들었지만 아무도 안 쓰는" 통과.
3
+ *
4
+ * 이 엔진이 아직 못 보던 false green 이 하나 남아 있다. 에이전트가 `src/foo.js` 를 만들고
5
+ * `tests/foo.test.js` 가 그 모듈을 **직접** import 해서 테스트하면:
6
+ * · exit 0 (테스트가 통과한다)
7
+ * · 무결성 깨끗 (케이스 수가 늘었지 줄지 않았다)
8
+ * · 비회귀 깨끗 (통과 수가 늘었다)
9
+ * · 유령 아님 (git 에 추적된다)
10
+ * 그런데 **제품 코드 어디에서도 foo 를 부르지 않는다.** 기능은 없고 테스트만 있다.
11
+ * 기존 게이트는 전부 테스트를 보는데, 이 결함은 테스트가 아니라 **제품 배선**에 있다.
12
+ *
13
+ * bkit 은 이걸 CI 하드 게이트로 갖고 있다(`scripts/check-deadcode.js`, exit 1). 함수 단위
14
+ * 스캐너의 주석이 이름까지 붙여놨다 — *"Built But Not Wired"*.
15
+ *
16
+ * 판정이 아니라 **공개**로 다룬다. 새 모듈이 아직 안 불리는 정상 상황이 흔하기 때문이다
17
+ * (다음 회차에 배선할 수도 있고, 엔트리포인트일 수도 있다). 차단하면 오탐이 루프를 죽인다.
18
+ * 대신 Gaps 에 실어서 "테스트는 통과했지만 이 파일은 제품 경로에서 안 불린다"를 밝힌다.
19
+ */
20
+ import { readFileSync } from 'node:fs';
21
+ import { basename, extname, join } from 'node:path';
22
+
23
+ const CODE_RE = /\.[cm]?[jt]sx?$/;
24
+ const TEST_RE = /(?:^|\/)(?:tests?|__tests__)\//;
25
+ const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
26
+
27
+ /** 제품 코드인가 (테스트·설정이 아닌). */
28
+ export function isProductionFile(rel) {
29
+ return CODE_RE.test(rel) && !TEST_RE.test(rel) && !TEST_FILE_RE.test(rel);
30
+ }
31
+
32
+ /** 모듈 참조를 뽑는다. import/require/동적 import 를 모두 본다. */
33
+ export function referencedNames(text) {
34
+ const out = new Set();
35
+ const patterns = [
36
+ /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g,
37
+ /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
38
+ ];
39
+ for (const re of patterns) {
40
+ for (const m of String(text ?? '').matchAll(re)) {
41
+ const spec = m[1];
42
+ if (!spec.startsWith('.') && !spec.startsWith('/')) continue; // 패키지 import 는 관심 밖
43
+ out.add(basename(spec, extname(spec)));
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * baseline 이후 새로 생긴 제품 파일 중 **제품 코드 어디에서도 참조되지 않는 것**.
51
+ *
52
+ * 기준이 baseline 인 이유: 이 루프가 만든 것만 본다. 원래부터 안 불리던 파일까지 들추면
53
+ * 매 실행이 남의 부채를 보고하는 소음이 된다.
54
+ *
55
+ * 관측 불가(baseline 이 없거나 현재 목록을 못 얻음)면 빈 배열 — 인프라는 fail-open.
56
+ */
57
+ export function detectUnwired({ cwd, baselineFiles, currentFiles }) {
58
+ if (!Array.isArray(baselineFiles) || !Array.isArray(currentFiles)) return [];
59
+ const was = new Set(baselineFiles);
60
+ const fresh = currentFiles.filter((f) => !was.has(f) && isProductionFile(f));
61
+ if (!fresh.length) return [];
62
+
63
+ // 참조 집합은 **제품 파일에서만** 모은다. 테스트가 부르는 것은 배선이 아니다 —
64
+ // 그게 바로 이 검출기가 겨냥하는 형태다.
65
+ const referenced = new Set();
66
+ for (const rel of currentFiles.filter(isProductionFile)) {
67
+ try {
68
+ for (const name of referencedNames(readFileSync(join(cwd, rel), 'utf8'))) referenced.add(name);
69
+ } catch {
70
+ /* 읽는 사이 사라졌으면 무시 — 가드가 루프를 죽이면 안 된다 */
71
+ }
72
+ }
73
+ return fresh.filter((f) => !referenced.has(basename(f, extname(f))));
74
+ }