@tuzi-ince/hi-loop 0.2.1 → 0.2.2
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/bin/hi-loop.js +25 -3
- package/docs/design.md +12 -1
- package/docs/spec.md +3 -1
- package/package.json +1 -1
- package/src/args.js +18 -5
- package/src/blast.js +42 -0
- package/src/build.js +74 -62
- package/src/check.js +169 -0
- package/src/checkpoint.js +88 -4
- package/src/checks.js +122 -0
- package/src/cli-options.js +13 -0
- package/src/gates.js +104 -0
- package/src/integrity.js +30 -1
- package/src/loop.js +21 -37
- package/src/metrics.js +140 -0
- package/src/outcome.js +76 -0
- package/src/report.js +73 -7
- package/src/resume.js +93 -0
- package/src/seal.js +85 -0
- package/src/stages.js +16 -3
- package/src/state.js +21 -39
- package/src/treekey.js +63 -0
- package/src/verdict.js +54 -0
- package/src/verify.js +6 -5
- package/src/wiring.js +74 -0
package/src/checkpoint.js
CHANGED
|
@@ -87,8 +87,92 @@ export async function rollbackTo({ cwd, sha }) {
|
|
|
87
87
|
// 되돌리기 전 안전망: 지금 상태를 스냅샷으로 남긴다(롤백을 되돌릴 수 있게).
|
|
88
88
|
const safety = await git(['stash', 'create', '--include-untracked'], cwd);
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
// `git checkout <sha> -- .` 은 워킹트리만 건드리고 인덱스를 남긴다. `read-tree -u --reset`
|
|
91
|
+
// 은 인덱스와 워킹트리를 함께 그 트리로 맞춘다 — 스테이징된 추가까지 되돌린다.
|
|
92
|
+
//
|
|
93
|
+
// **그래도 되돌리지 못하는 것이 있다: 추적되지 않는 새 파일.** git 의 어떤 트리 연산도
|
|
94
|
+
// 추적 밖 파일을 지우지 않고, `git clean` 은 사용자의 정상 파일(.env, 로컬 스크립트)까지
|
|
95
|
+
// 지운다. 지우는 것보다 **남아 있다고 밝히는 것**이 맞다 — 이 엔진의 규칙은
|
|
96
|
+
// "관측하지 않은 것을 관측 결과로 발행하지 않는다"이고, 여기서는 "되돌리지 못한 것을
|
|
97
|
+
// 되돌렸다고 말하지 않는다"가 같은 규칙이다.
|
|
98
|
+
const reset = await git(['read-tree', '-u', '--reset', sha], cwd);
|
|
99
|
+
if (reset === null) {
|
|
100
|
+
// 인덱스를 못 건드리는 상황(충돌 중 등)에서는 예전 방식으로라도 되돌린다 —
|
|
101
|
+
// 부분 복원이 무복원보다 낫다. 다만 무엇이 덜 됐는지 호출부가 알아야 한다.
|
|
102
|
+
const done = await git(['checkout', sha, '--', '.'], cwd);
|
|
103
|
+
if (done === null) return { ok: false, reason: 'checkout 실패 — SHA 가 유효하지 않거나 접근 불가.' };
|
|
104
|
+
return { ok: true, safetySha: safety || null, partial: true };
|
|
105
|
+
}
|
|
106
|
+
// 되돌린 뒤에도 남아 있는 추적 밖 파일. 호출부가 사람에게 보여줘야 한다.
|
|
107
|
+
const untracked = await git(['ls-files', '--others', '--exclude-standard'], cwd);
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
safetySha: safety || null,
|
|
111
|
+
partial: false,
|
|
112
|
+
untrackedRemain: untracked ? untracked.split('\n').filter(Boolean) : [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 이번 워킹트리에서 **바뀐 파일들** (L18 경로 조건부 검사용).
|
|
118
|
+
*
|
|
119
|
+
* `git status --porcelain` 은 추적 파일의 수정·추가와 추적 안 되는 새 파일을 모두 준다.
|
|
120
|
+
* HEAD 대비가 아니라 워킹트리 대비라, 루프가 시작되기 전부터 더러웠던 파일도 포함된다 —
|
|
121
|
+
* 그게 맞다. 조건부 검사가 답해야 할 질문은 "이 트리에 UI 변경이 있는가"이지
|
|
122
|
+
* "이번 회차가 UI 를 건드렸는가"가 아니다. 후자로 하면 2회차에 e2e 가 사라진다.
|
|
123
|
+
*
|
|
124
|
+
* 주입 가능한 포트 (NFR-3). `({cwd}) => string[] | null` (null = 비-git, 관측 불가)
|
|
125
|
+
*/
|
|
126
|
+
export function makeChangeLister() {
|
|
127
|
+
return async ({ cwd }) => {
|
|
128
|
+
if (!(await isGitRepo(cwd))) return null;
|
|
129
|
+
const out = await git(['status', '--porcelain', '--untracked-files=all'], cwd);
|
|
130
|
+
if (out === null) return null;
|
|
131
|
+
return out
|
|
132
|
+
.split('\n')
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.map((l) => l.slice(3).trim())
|
|
135
|
+
// 이름이 바뀐 항목은 `old -> new` 로 온다. 새 이름이 판정 대상이다.
|
|
136
|
+
.map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 복구 경로 생존 확인 (L21).
|
|
143
|
+
*
|
|
144
|
+
* L3(체크포인트)는 에이전트가 코드를 망가뜨렸을 때의 **유일한** 복구 경로다. 그런데 그
|
|
145
|
+
* 경로 자체는 무방비였다 — 에이전트는 cwd 에서 `acceptEdits` 로 돌고 Bash 를 갖는다.
|
|
146
|
+
* `git reset --hard`, `rm -rf .git`, `git gc --prune=now` 한 번이면 체크포인트 사슬이
|
|
147
|
+
* 통째로 사라지고, 엔진은 그 사실을 롤백을 시도하는 순간까지 모른다.
|
|
148
|
+
*
|
|
149
|
+
* 자식 에이전트의 Bash 호출을 가로채는 방법(CC 훅 주입)도 있지만, 그건 훅이 실제로
|
|
150
|
+
* 발동하는지 검증하지 못한 채 배송하는 것이 된다. 검증 안 된 방어는 이 엔진이 거부하는
|
|
151
|
+
* 형태다. 대신 **엔진이 직접 소유할 수 있는 측정**으로 바꾼다: 매 회차 복구 경로가
|
|
152
|
+
* 아직 살아 있는지 확인한다. 막지는 못해도 **모른 채 진행하지는 않는다.**
|
|
153
|
+
*
|
|
154
|
+
* 반환: `{ ok, reason }`. 관측 불가(애초에 비-git)면 ok — 인프라는 fail-open.
|
|
155
|
+
*/
|
|
156
|
+
export async function verifyRecoveryPath({ cwd, checkpoints, expectGit = false }) {
|
|
157
|
+
const isRepo = await isGitRepo(cwd);
|
|
158
|
+
// **루프 시작 시점에 git 을 관측했는데 지금 아니라면 그건 손실이다.** 이 구분이 없으면
|
|
159
|
+
// 둘 중 하나를 포기해야 한다: 비-git 프로젝트 전체를 오탐으로 막거나(그러면 못 쓴다),
|
|
160
|
+
// `.git` 이 통째로 사라진 최악의 경우를 못 잡거나(그러면 이 함수가 무의미하다).
|
|
161
|
+
if (expectGit && !isRepo) {
|
|
162
|
+
return { ok: false, reason: 'git 저장소가 사라졌다 — 체크포인트를 되돌릴 수 없다.' };
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(checkpoints) || checkpoints.length === 0) return { ok: true, reason: '' };
|
|
165
|
+
if (!isRepo) return { ok: true, reason: '' }; // 애초에 git 이 아니었다 — 관측 불가는 손실이 아니다.
|
|
166
|
+
const dead = [];
|
|
167
|
+
for (const cp of checkpoints) {
|
|
168
|
+
const seen = await git(['cat-file', '-e', `${cp.sha}^{commit}`], cwd);
|
|
169
|
+
if (seen === null) dead.push(cp.iteration);
|
|
170
|
+
}
|
|
171
|
+
if (dead.length === checkpoints.length) {
|
|
172
|
+
return { ok: false, reason: `체크포인트 ${dead.length}개가 모두 접근 불가다(gc 되었거나 저장소가 초기화됐다).` };
|
|
173
|
+
}
|
|
174
|
+
if (dead.length) {
|
|
175
|
+
return { ok: true, reason: `체크포인트 ${dead.length}개가 접근 불가다(회차 ${dead.join(', ')}).` };
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, reason: '' };
|
|
94
178
|
}
|
package/src/checks.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 다중 조건 검사 (L18) — 판정 명령이 하나뿐이던 문제.
|
|
3
|
+
*
|
|
4
|
+
* 지금까지 이 엔진의 Tier 1 입력은 `testCommand` **하나**였다. 유닛 테스트 말고 lint·typecheck·
|
|
5
|
+
* e2e 까지 게이트로 쓰려면 사용자가 `npm test && npm run lint && npx playwright test` 라고
|
|
6
|
+
* 써야 했는데, 그건 셋 다 나쁘다:
|
|
7
|
+
*
|
|
8
|
+
* 1. `&&` 는 short-circuit 이다. 유닛이 깨지면 **e2e 상태를 영원히 모른다.** 고치고 다시
|
|
9
|
+
* 돌려야 비로소 두 번째 실패를 발견하고, 그때 또 한 회차를 태운다.
|
|
10
|
+
* 2. UI 와 무관한 회차에도 e2e 가 매번 돈다. playwright 를 10회 루프에 물리면 비용이
|
|
11
|
+
* 에이전트 호출을 넘어선다.
|
|
12
|
+
* 3. 세 실패가 **하나의 지문으로 뭉개져** 정체 감지(L2)가 오작동한다.
|
|
13
|
+
*
|
|
14
|
+
* 설계(moai `internal/goal/schema.go:29` 의 조건 배열): 검사를 배열로 받고 **short-circuit
|
|
15
|
+
* 하지 않는다.** 전부 돌리고, 조건별로 실패 꼬리를 따로 보관해 에이전트에게 "3개 중 2번이
|
|
16
|
+
* 이렇게 실패했다"를 준다. 그리고 각 검사에 `when` 글롭을 달 수 있다 — 이번 회차가 건드린
|
|
17
|
+
* 파일이 하나도 안 맞으면 **건너뛴다.** 이것이 "UI 를 고쳤을 때만 e2e" 를 가능하게 한다.
|
|
18
|
+
*
|
|
19
|
+
* 건너뛴 검사는 통과가 아니다. 원장에 'skipped' 로 남고 보고서 Gaps 에 나온다 —
|
|
20
|
+
* 안 돌린 것이 통과처럼 보이면 이 엔진의 존재 이유가 무너진다.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 꼬리 길이. 실패 출력 전체를 물고 다니면 핸드오프 압축이 무의미해진다. */
|
|
24
|
+
export const TAIL_LEN = 800;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 글롭 → 정규식. `**` 는 경로 구분자를 넘고, `*` 는 안 넘는다.
|
|
28
|
+
* 의존성을 늘리지 않으려고 직접 만든다(NFR-4). 지원하는 것은 `**`, `*`, `?` 뿐이다 —
|
|
29
|
+
* 더 필요해지면 그때 늘린다. 지금 없는 문법을 미리 만들면 검증 안 된 코드가 는다.
|
|
30
|
+
*/
|
|
31
|
+
export function globToRegExp(pattern) {
|
|
32
|
+
let out = '';
|
|
33
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
34
|
+
const c = pattern[i];
|
|
35
|
+
if (c === '*') {
|
|
36
|
+
if (pattern[i + 1] === '*') {
|
|
37
|
+
// `**/` 는 "0개 이상의 디렉터리"다. 슬래시까지 삼켜야 `src/**/*.ts` 가 `src/a.ts` 에도 맞는다.
|
|
38
|
+
if (pattern[i + 2] === '/') {
|
|
39
|
+
out += '(?:.*/)?';
|
|
40
|
+
i += 2;
|
|
41
|
+
} else {
|
|
42
|
+
out += '.*';
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
} else out += '[^/]*';
|
|
46
|
+
} else if (c === '?') out += '[^/]';
|
|
47
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
48
|
+
}
|
|
49
|
+
return new RegExp(`^${out}$`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 변경 파일 중 하나라도 패턴에 맞는가. 패턴이 없으면 항상 참(무조건 실행). */
|
|
53
|
+
export function matchesAny(pattern, files) {
|
|
54
|
+
if (!pattern) return true;
|
|
55
|
+
if (!Array.isArray(files)) return true; // 변경 목록을 못 얻으면 **건너뛰지 않는다** — 미실행보다 과실행이 안전하다.
|
|
56
|
+
const re = globToRegExp(pattern);
|
|
57
|
+
return files.some((f) => re.test(f));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* CLI/MCP 입력을 검사 배열로 정규화한다.
|
|
62
|
+
*
|
|
63
|
+
* `--check` 가 하나도 없으면 종전대로 `testCommand` 하나다 — 기존 사용자에게 아무것도
|
|
64
|
+
* 바뀌지 않는다. 이 하위 호환이 없으면 업데이트만 한 사람의 판정 규칙이 조용히 달라진다.
|
|
65
|
+
*/
|
|
66
|
+
export function normalizeChecks({ testCommand, checks }) {
|
|
67
|
+
if (!Array.isArray(checks) || checks.length === 0) return [{ cmd: testCommand, when: null }];
|
|
68
|
+
return checks
|
|
69
|
+
.map((c) => (typeof c === 'string' ? { cmd: c, when: null } : { cmd: c?.cmd, when: c?.when ?? null }))
|
|
70
|
+
.filter((c) => typeof c.cmd === 'string' && c.cmd.trim());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 검사를 전부 돌린다. **short-circuit 하지 않는다** — 그게 이 모듈의 존재 이유다.
|
|
75
|
+
*
|
|
76
|
+
* 반환: `{ ok, results, ran, skipped }`
|
|
77
|
+
* results: `[{ cmd, when, ok, code, tail, skipped }]`
|
|
78
|
+
*/
|
|
79
|
+
export async function runChecks({ checks, cwd, runner, changedFiles }) {
|
|
80
|
+
const results = [];
|
|
81
|
+
for (const c of checks) {
|
|
82
|
+
if (!matchesAny(c.when, changedFiles)) {
|
|
83
|
+
results.push({ ...c, skipped: true, ok: true, code: null, tail: '' });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const r = await runner({ command: c.cmd, cwd });
|
|
87
|
+
const out = [r?.stderr, r?.stdout].filter(Boolean).join('\n').trim();
|
|
88
|
+
results.push({ ...c, skipped: false, ok: Boolean(r?.ok), code: r?.code ?? null, tail: out.slice(-TAIL_LEN), stdout: r?.stdout ?? '', stderr: r?.stderr ?? '' });
|
|
89
|
+
}
|
|
90
|
+
const ran = results.filter((r) => !r.skipped);
|
|
91
|
+
return {
|
|
92
|
+
ok: ran.every((r) => r.ok),
|
|
93
|
+
results,
|
|
94
|
+
ran,
|
|
95
|
+
skipped: results.filter((r) => r.skipped),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 에이전트에게 줄 실패 요약. **어느 검사가 실패했는지**를 명시한다 —
|
|
101
|
+
* `&&` 로 이어붙였을 때는 알 수 없던 정보다.
|
|
102
|
+
*/
|
|
103
|
+
export function failureSummary(results) {
|
|
104
|
+
const failed = results.filter((r) => !r.skipped && !r.ok);
|
|
105
|
+
if (!failed.length) return '';
|
|
106
|
+
const head = `검사 ${results.filter((r) => !r.skipped).length}개 중 ${failed.length}개가 실패했다.`;
|
|
107
|
+
return [head, ...failed.map((r) => `\n### \`${r.cmd}\` (exit ${r.code})\n${r.tail || '(출력 없음)'}`)].join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 정체 지문용 키. 실패한 **명령의 집합**으로 만든다.
|
|
112
|
+
*
|
|
113
|
+
* 출력 텍스트를 이어붙여 해시하면 세 검사의 잡음이 섞여 매번 달라진다. 어느 검사가
|
|
114
|
+
* 실패하고 있는가는 그보다 훨씬 안정적인 신호이고, "lint 만 계속 깨진다"를 정확히 짚는다.
|
|
115
|
+
*/
|
|
116
|
+
export function failingSet(results) {
|
|
117
|
+
return results
|
|
118
|
+
.filter((r) => !r.skipped && !r.ok)
|
|
119
|
+
.map((r) => r.cmd)
|
|
120
|
+
.sort()
|
|
121
|
+
.join('|');
|
|
122
|
+
}
|
package/src/cli-options.js
CHANGED
|
@@ -103,6 +103,14 @@ export function parseRunOptions(args, { staged = {} } = {}) {
|
|
|
103
103
|
return bad('--start-from 에는 PLAN 을 쓸 수 없습니다. BUILD 로 시작하면 PLAN 부터 돕니다.');
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// `--check` / `--when` 을 위치로 짝짓는다. when 이 모자라면 나머지는 무조건 실행이다.
|
|
107
|
+
const pairChecks = (check, when) => {
|
|
108
|
+
if (check === undefined) return null;
|
|
109
|
+
const cmds = (Array.isArray(check) ? check : [check]).filter((c) => typeof c === 'string');
|
|
110
|
+
const globs = when === undefined ? [] : Array.isArray(when) ? when : [when];
|
|
111
|
+
return cmds.map((cmd, i) => ({ cmd, when: typeof globs[i] === 'string' ? globs[i] : null }));
|
|
112
|
+
};
|
|
113
|
+
|
|
106
114
|
// null = 자동(--full 또는 --ship 이면 켜짐). 명시 플래그가 그 자동을 양방향으로 덮는다.
|
|
107
115
|
const triState = (onKey, offKey) => (args[offKey] ? false : args[onKey] ? true : null);
|
|
108
116
|
|
|
@@ -113,6 +121,11 @@ export function parseRunOptions(args, { staged = {} } = {}) {
|
|
|
113
121
|
budgetUsd,
|
|
114
122
|
stagnationLimit,
|
|
115
123
|
verifySpec: Boolean(args['verify-spec']),
|
|
124
|
+
flakyProbe: Boolean(args['flaky-probe']),
|
|
125
|
+
// L18: `--check "<cmd>"` 를 여러 번 줄 수 있고, 각 뒤의 `--when "<glob>"` 이
|
|
126
|
+
// 그 검사에 붙는다. 짝을 이렇게 위치로 맺는 이유는 플래그 이름을 늘리지 않기 위해서다
|
|
127
|
+
// (`--check-e2e`, `--when-e2e` 식으로 가면 검사 개수만큼 플래그가 는다).
|
|
128
|
+
checks: pairChecks(args.check, args.when),
|
|
116
129
|
shipCommand: str('ship'),
|
|
117
130
|
watchCommand: str('watch'),
|
|
118
131
|
onShipFail,
|
package/src/gates.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 게이트 원장 (L14) — 어떤 게이트가 **실제로** 평가됐는지 기록한다.
|
|
3
|
+
*
|
|
4
|
+
* 문제: `reportGaps` 의 Evidence 는 하드코딩 문자열이었다. "무결성 위반 없음", "유령 테스트
|
|
5
|
+
* 없음" 같은 줄이 `status === 'passed'` 하나만 보고 인쇄됐다. 그런데 비-git 프로젝트에서는
|
|
6
|
+
* 유령 테스트 검사가 **수행 자체가 불가능**하고, 스펙 검증자는 스펙 파일이 없으면 검사 없이
|
|
7
|
+
* pass 를 돌려준다. 그 둘 다 보고서에는 "검사했고 깨끗했다"로 찍혔다.
|
|
8
|
+
*
|
|
9
|
+
* 즉 **관측하지 않은 것이 관측 결과로 발행됐다** — 이 엔진이 존재하는 이유인 바로 그 병이,
|
|
10
|
+
* 그 병을 막으려고 만든 보고 메커니즘 안에 있었다.
|
|
11
|
+
*
|
|
12
|
+
* 설계: 게이트가 평가되는 그 순간에만 원장에 한 줄이 남는다. 보고서는 문자열을 짓지 않고
|
|
13
|
+
* **원장을 읽어서** 렌더한다. 그러면 돌지 않은 게이트가 Evidence 를 만드는 일이 구조적으로
|
|
14
|
+
* 불가능해진다. 판정 근거를 지어내려면 원장에 없는 키를 읽어야 하는데, 그건 undefined 다.
|
|
15
|
+
*
|
|
16
|
+
* 결과값의 뜻:
|
|
17
|
+
* 'pass' — 평가했고 통과
|
|
18
|
+
* 'fail' — 평가했고 위반
|
|
19
|
+
* 'unobservable' — **평가할 수 없었다**(비-git 등). 통과가 아니다. Gaps 로 간다.
|
|
20
|
+
* 'off' — 사용자가 켜지 않았다(예: --verify-spec 없음). Gaps 로 간다.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 원장에 실릴 수 있는 게이트. 여기 없는 이름을 쓰면 보고서가 조용히 빠뜨린다. */
|
|
24
|
+
export const GATE_NAMES = ['exitCode', 'integrity', 'ghost', 'nonRegression', 'specVerify', 'flaky'];
|
|
25
|
+
|
|
26
|
+
/** 사람이 읽는 이름. 보고서 Evidence/Gaps 양쪽에서 같은 문구를 쓴다. */
|
|
27
|
+
const LABEL = {
|
|
28
|
+
exitCode: '테스트 명령 종료 코드',
|
|
29
|
+
integrity: '테스트 파일 무결성(직전 회차 대비 지문)',
|
|
30
|
+
ghost: '유령 테스트(git 추적 여부)',
|
|
31
|
+
nonRegression: '비회귀(통과 수 축소)',
|
|
32
|
+
specVerify: '스펙 대비 구현 심판(2단 판정)',
|
|
33
|
+
flaky: '플레이키 프로브(통과 회차 재실행)',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 게이트 평가 결과를 기록한다. **평가한 그 자리에서만** 부른다 —
|
|
38
|
+
* 나중에 몰아서 쓰면 "돌지 않은 게이트가 기록되는" 경로가 다시 열린다.
|
|
39
|
+
*/
|
|
40
|
+
export function recordGate(ledger, name, result, { iteration = null, detail = '' } = {}) {
|
|
41
|
+
if (!GATE_NAMES.includes(name)) throw new Error(`알 수 없는 게이트: ${name}`);
|
|
42
|
+
return { ...(ledger ?? {}), [name]: { result, iteration, detail } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 원장에서 Evidence 줄들을 만든다. 평가된 것만 나온다. */
|
|
46
|
+
export function evidenceLines(ledger) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const name of GATE_NAMES) {
|
|
49
|
+
const e = ledger?.[name];
|
|
50
|
+
if (!e || e.result !== 'pass') continue;
|
|
51
|
+
out.push(`- ${LABEL[name]} 통과${e.iteration ? ` (iteration ${e.iteration})` : ''}${e.detail ? ` — ${e.detail}` : ''}`);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 원장에서 Gaps 줄들을 만든다.
|
|
58
|
+
*
|
|
59
|
+
* **관측 불가와 미사용을 구분해서 밝힌다.** 둘 다 "검사 안 함"이지만 사용자가 할 일이
|
|
60
|
+
* 다르다 — 전자는 환경을 고쳐야 하고(git 저장소가 아님), 후자는 플래그를 켜면 된다.
|
|
61
|
+
*/
|
|
62
|
+
export function gapLines(ledger) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const name of GATE_NAMES) {
|
|
65
|
+
const e = ledger?.[name];
|
|
66
|
+
if (!e) {
|
|
67
|
+
out.push(`- ${LABEL[name]}: 이번 실행에서 평가되지 않았다.`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (e.result === 'unobservable') {
|
|
71
|
+
out.push(`- ${LABEL[name]}: **관측 불가**라 검사하지 못했다${e.detail ? ` (${e.detail})` : ''}. 통과가 아니라 미관측이다.`);
|
|
72
|
+
} else if (e.result === 'off') {
|
|
73
|
+
out.push(`- ${LABEL[name]}: 켜지 않아 검사하지 않았다${e.detail ? ` (${e.detail})` : ''}.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 판정↔트리 결속 (L15) — "통과한 트리"와 "지금 트리"가 같은가.
|
|
81
|
+
*
|
|
82
|
+
* 이게 없으면 `--start-from CODE_REVIEW` 처럼 BUILD 를 건너뛴 경로가 게이트를 **하나도**
|
|
83
|
+
* 통과하지 않고 `passed` 에 도달했다. 그리고 보고서는 "무결성·비회귀 게이트를 모두 넘었다"고
|
|
84
|
+
* 인쇄했다 — false green 을 막으려 만든 공개 메커니즘이 false green 을 생산한 것이다.
|
|
85
|
+
*
|
|
86
|
+
* 반환: `{ ok, reason }`. ok=false 면 호출부는 `passed` 를 주장해선 안 된다.
|
|
87
|
+
*
|
|
88
|
+
* 관측 불가(비-git)일 때는 결속을 **강제하지 못한다**. 그때도 "빌드가 통과했다"는 사실
|
|
89
|
+
* 자체는 원장에 있으므로 그것만 요구하고, 트리 일치는 건너뛴다(인프라 fail-open).
|
|
90
|
+
* 하지만 빌드를 아예 안 돈 것은 인프라 문제가 아니라 **측정의 부재**이므로 fail-closed 다.
|
|
91
|
+
*/
|
|
92
|
+
export function checkVerdictBinding({ ledger, passedTreeKey, currentTreeKey }) {
|
|
93
|
+
if (ledger?.exitCode?.result !== 'pass') {
|
|
94
|
+
return { ok: false, reason: '이번 실행에서 테스트 게이트를 통과한 적이 없다(BUILD 를 건너뛰었다).' };
|
|
95
|
+
}
|
|
96
|
+
if (!passedTreeKey || !currentTreeKey) return { ok: true, reason: '' }; // 트리 관측 불가 — 강제 불가
|
|
97
|
+
if (passedTreeKey !== currentTreeKey) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: `게이트를 통과한 트리(${passedTreeKey.slice(0, 12)})와 지금 트리(${currentTreeKey.slice(0, 12)})가 다르다.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, reason: '' };
|
|
104
|
+
}
|
package/src/integrity.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* 지금까지의 방어는 프롬프트 한 줄이었다("테스트를 무력화하지 마라"). 산문은 요청이지
|
|
8
8
|
* 메커니즘이 아니다 — 그리고 실측에서 에이전트가 프롬프트를 어길 수 있음이 확인됐다.
|
|
9
9
|
*
|
|
10
|
-
* 설계: git 을 쓰지 않는다. iteration 마다 테스트 파일의 **지문**을 떠서 직전 회차와
|
|
10
|
+
* 설계: 지문 비교는 git 을 쓰지 않는다. iteration 마다 테스트 파일의 **지문**을 떠서 직전 회차와
|
|
11
|
+
* 비교한다. (아래 유령 테스트 검출만 추적 목록을 필요로 하는데, 그것도 이 모듈이 git 을 부르지
|
|
12
|
+
* 않고 호출부가 이미 갖고 있는 목록을 인자로 받는다 — 이 파일은 여전히 순수하다.)
|
|
11
13
|
* - git 저장소가 아니어도 동작한다 (fail-open 예외 처리가 통째로 필요 없다).
|
|
12
14
|
* - 기준선이 HEAD 가 아니라 **직전 iteration** 이라, 실행 시작 시점의 더러운 워킹트리가
|
|
13
15
|
* 오탐을 만들지 않는다. 재는 것은 정확히 "이 루프가 테스트를 약화시켰는가"다.
|
|
@@ -118,6 +120,33 @@ export function compareFingerprints(before, after) {
|
|
|
118
120
|
return { ok: violations.length === 0, violations };
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
/**
|
|
124
|
+
* 유령 테스트 (L13) — 디스크에는 있는데 git 이 모르는 테스트 파일.
|
|
125
|
+
*
|
|
126
|
+
* 위 지문 비교는 파일을 **디스크에서** 읽는다. 그래서 에이전트가 테스트를 `.gitignore` 된
|
|
127
|
+
* 경로에 쓰거나 `tests/` 를 `.gitignore` 에 추가해버리면, 지문은 완벽하고 exit code 는
|
|
128
|
+
* 초록불인데 **사용자의 CI 에는 테스트가 하나도 없다.** 체크포인트(L3)로도 복구할 수 없다 —
|
|
129
|
+
* `git stash create` 가 담는 것은 git 이 아는 파일뿐이기 때문이다.
|
|
130
|
+
* L1 이 지키려던 바로 그 실패를, L1 이 보지 않는 문으로 통과시킨다.
|
|
131
|
+
*
|
|
132
|
+
* bkit 은 이걸 CI 하드 게이트로 갖고 있다(`scripts/check-test-tracking.js:92-112`,
|
|
133
|
+
* `git ls-files` 집합과의 차집합 → exit 1). 실제로 v2.1.14~v2.1.16 릴리스에서 CI 가
|
|
134
|
+
* 참조하는 테스트 파일이 추적되지 않은 채 나간 사고의 재발 방지책이다.
|
|
135
|
+
*
|
|
136
|
+
* 이건 **측정**이므로 fail-closed 다(통과로 인정하지 않는다). 다만 추적 목록 자체를
|
|
137
|
+
* 못 얻으면(비-git) 관측 불가이므로 조용히 빈 배열 — 인프라는 fail-open.
|
|
138
|
+
*/
|
|
139
|
+
export function detectUntrackedTests(testFiles, trackedFiles) {
|
|
140
|
+
if (!Array.isArray(trackedFiles) || !Array.isArray(testFiles)) return [];
|
|
141
|
+
const tracked = new Set(trackedFiles);
|
|
142
|
+
return testFiles.filter((f) => !tracked.has(f));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 유령 테스트를 위반 문장으로. 차단은 대안과 함께 배송한다(bkit). */
|
|
146
|
+
export function untrackedMessage(file) {
|
|
147
|
+
return `${file}: 테스트 파일이 git 에 추적되지 않는다 — CI 는 이 테스트를 보지 못하고 롤백으로도 복구되지 않는다. \`git add ${file}\` 로 추적에 넣어라(.gitignore 에 걸려 있다면 그 규칙을 고쳐라).`;
|
|
148
|
+
}
|
|
149
|
+
|
|
121
150
|
/** 위반을 에이전트에게 들이밀 텍스트로. 차단은 대안과 함께 배송한다(bkit). */
|
|
122
151
|
export function violationMessage(violations) {
|
|
123
152
|
return [
|
package/src/loop.js
CHANGED
|
@@ -14,7 +14,9 @@ import { saveState, statePathFor, resumeOrCreate, reportGaps } from './state.js'
|
|
|
14
14
|
import { makeAgentRunner, makeTestRunner } from './runners.js';
|
|
15
15
|
import { makeIntegrityChecker } from './integrity.js';
|
|
16
16
|
import { acquireLock } from './lock.js';
|
|
17
|
-
import { makeCheckpointer, makeFileLister } from './checkpoint.js';
|
|
17
|
+
import { makeCheckpointer, makeFileLister, makeChangeLister } from './checkpoint.js';
|
|
18
|
+
import { makeTreeKeyReader } from './treekey.js';
|
|
19
|
+
import { makeOutcome } from './outcome.js';
|
|
18
20
|
import { makeSpecVerifier } from './verify.js';
|
|
19
21
|
import { makeNotifier } from './telegram.js';
|
|
20
22
|
import { makeShipper, makeWatcher, WATCH_DEFAULTS } from './ship.js';
|
|
@@ -35,7 +37,6 @@ export const POST_BUILD_STAGES = ['CODE_REVIEW', 'SHIP', 'WATCH'];
|
|
|
35
37
|
/**
|
|
36
38
|
* 얇은 락 래퍼 (L4). 같은 cwd 에서 두 루프가 상태를 덮어쓰지 못하게 배타를 강제한다.
|
|
37
39
|
* body 는 return 지점이 여럿이라 여기서 한 번 감싸 finally 로 확실히 푼다.
|
|
38
|
-
* acquireLock 은 테스트를 위해 주입 가능하다.
|
|
39
40
|
*/
|
|
40
41
|
export async function runLoop(opts = {}) {
|
|
41
42
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -56,6 +57,8 @@ async function runLoopBody({
|
|
|
56
57
|
budgetUsd = null,
|
|
57
58
|
stagnationLimit = 3,
|
|
58
59
|
verifySpec = false,
|
|
60
|
+
flakyProbe = false,
|
|
61
|
+
checks = null,
|
|
59
62
|
handoffEvery = 4,
|
|
60
63
|
// ---- 라이프사이클 (FR-13, FR-14) ----
|
|
61
64
|
shipCommand = null,
|
|
@@ -83,6 +86,8 @@ async function runLoopBody({
|
|
|
83
86
|
integrityChecker = makeIntegrityChecker(),
|
|
84
87
|
checkpointer = makeCheckpointer(),
|
|
85
88
|
fileLister = makeFileLister(),
|
|
89
|
+
changeLister = makeChangeLister(),
|
|
90
|
+
treeKeyReader = makeTreeKeyReader(),
|
|
86
91
|
specVerifier = makeSpecVerifier(),
|
|
87
92
|
codeReviewer = makeCodeReviewer(),
|
|
88
93
|
designReviewer = makeDesignReviewer(),
|
|
@@ -129,22 +134,11 @@ async function runLoopBody({
|
|
|
129
134
|
// null 이면 비-git — 관측 자체가 불가능하니 아래에서 조용히 건너뛴다(인프라 fail-open).
|
|
130
135
|
const baselineFiles = await fileLister({ cwd });
|
|
131
136
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
state
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (stopReason) state.stopReason = stopReason;
|
|
138
|
-
const saved = saveState(statePath, state);
|
|
139
|
-
await notify({ type: status === 'passed' ? 'passed' : 'failed', iteration: state.iteration, detail });
|
|
140
|
-
// Gaps 는 passed 에만 붙인다. false green 이 조용히 지나가는 지점이 거기이기 때문이다.
|
|
141
|
-
// 실패는 stopReason 만으로 이미 정직하다 — 거기에 Gaps 를 얹으면 소음이다.
|
|
142
|
-
if (status !== 'passed') return { status, iterations: state.iteration, state: saved };
|
|
143
|
-
const gaps = reportGaps(saved);
|
|
144
|
-
logger(gaps);
|
|
145
|
-
return { status, iterations: state.iteration, state: saved, report: gaps };
|
|
146
|
-
};
|
|
147
|
-
|
|
137
|
+
const { finish, stopResult, pauseResult } = makeOutcome({
|
|
138
|
+
getState: () => state,
|
|
139
|
+
setState: (v) => { state = v; },
|
|
140
|
+
statePath, cwd, logger, notify, treeKeyReader,
|
|
141
|
+
});
|
|
148
142
|
|
|
149
143
|
/**
|
|
150
144
|
* 단계 기본값 (FR-17) — 조용히 비용을 얹지 않는다.
|
|
@@ -172,29 +166,15 @@ async function runLoopBody({
|
|
|
172
166
|
const gate = ({ stage, question, options, required = true }) => {
|
|
173
167
|
const answered = [...(state.answers ?? [])].reverse().find((a) => a.stage === stage);
|
|
174
168
|
if (answered) return { pause: false, choice: answered.choice, note: answered.note };
|
|
175
|
-
|
|
169
|
+
// `--yes` 는 **명시적 포괄 승인**이므로 승인('a')으로 친다.
|
|
170
|
+
// `--ask never` 는 "답할 사람이 없다"일 뿐 승인이 아니다 — null 을 돌려주고,
|
|
171
|
+
// 비가역 행위 쪽에서 그걸 거절로 해석한다. 되돌릴 수 있는 지점은 종전대로 진행한다.
|
|
172
|
+
if (yes) return { pause: false, choice: 'a' };
|
|
173
|
+
if (!shouldAsk({ policy: askPolicy, required })) return { pause: false, choice: null };
|
|
176
174
|
state = pauseForAsk(state, createAsk({ stage, question, options }));
|
|
177
175
|
return { pause: true };
|
|
178
176
|
};
|
|
179
177
|
|
|
180
|
-
const pauseResult = () => {
|
|
181
|
-
const saved = saveState(statePath, state);
|
|
182
|
-
logger(formatAsk(state.ask));
|
|
183
|
-
return { status: 'awaiting', iterations: state.iteration, state: saved, ask: state.ask };
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* `--stop-after` 로 멈춘 결과. 실패가 아니다 — 시킨 것을 다 했을 뿐이다.
|
|
188
|
-
* status 를 passed 로 하면 "테스트가 통과했다"는 거짓말이 되므로 별도 값을 쓴다.
|
|
189
|
-
*/
|
|
190
|
-
const stopResult = (stage) => {
|
|
191
|
-
state.status = 'stopped';
|
|
192
|
-
state.stopReason = `stop-after:${stage}`;
|
|
193
|
-
const saved = saveState(statePath, state);
|
|
194
|
-
logger(`[hi-loop] ⏹ ${stage} 까지 진행하고 멈췄습니다 (--stop-after).`);
|
|
195
|
-
return { status: 'stopped', iterations: state.iteration, state: saved, stoppedAt: stage };
|
|
196
|
-
};
|
|
197
|
-
|
|
198
178
|
/** 방금 끝낸 stage 가 정지 지점인가. 다음 stage 로 넘어가기 직전에만 묻는다. */
|
|
199
179
|
const shouldStopAfter = (stage) => stopAfter === stage;
|
|
200
180
|
|
|
@@ -230,6 +210,8 @@ async function runLoopBody({
|
|
|
230
210
|
integrityChecker,
|
|
231
211
|
checkpointer,
|
|
232
212
|
fileLister,
|
|
213
|
+
changeLister,
|
|
214
|
+
treeKeyReader,
|
|
233
215
|
specVerifier,
|
|
234
216
|
},
|
|
235
217
|
opts: {
|
|
@@ -246,6 +228,8 @@ async function runLoopBody({
|
|
|
246
228
|
testCommand,
|
|
247
229
|
stagnationLimit,
|
|
248
230
|
verifySpec,
|
|
231
|
+
flakyProbe,
|
|
232
|
+
checks,
|
|
249
233
|
handoffEvery,
|
|
250
234
|
maxDesignRounds,
|
|
251
235
|
stopAfter,
|
package/src/metrics.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 테스트 지표 파싱과 비회귀 게이트 (L11) — 판정을 불리언에서 벡터로.
|
|
3
|
+
*
|
|
4
|
+
* 문제: 이 엔진의 Tier 1 판정은 exit code 하나다. 그런데 exit 0 은 "몇 개가 통과했는가"를
|
|
5
|
+
* 말하지 않는다. 테스트 120개 통과 → 1개 통과 / 0개 실패로 붕괴해도 **exit 0 이고, passed 다.**
|
|
6
|
+
* `integrity.js` 가 이걸 막을 것 같지만 막지 못한다 — 그쪽은 파일의 지문(케이스 선언 수)을
|
|
7
|
+
* 보고, 이쪽은 **실행 결과**를 본다. 케이스를 지우지 않고도 통과 수는 무너질 수 있다
|
|
8
|
+
* (조건부 스킵, 파라미터 축소, 러너 설정 변경, import 실패로 스위트 통째 미수집).
|
|
9
|
+
*
|
|
10
|
+
* 그리고 더 중요한 사각: `integrity.js` 의 TEST_FILE_RE 는 `.test.js|.spec.ts` 계열만 안다.
|
|
11
|
+
* **Python·Go·Rust 프로젝트에서 무결성 가드는 0 이다.** 이 모듈은 파일이 아니라 러너의
|
|
12
|
+
* 표준출력을 읽으므로 언어에 무관하다 — 그 사각을 메우는 것이 이 파일의 두 번째 목적이다.
|
|
13
|
+
*
|
|
14
|
+
* 출처: moai 의 비회귀 게이트(`internal/harness/regression_gate.go:50` MetricTriple.Regressions
|
|
15
|
+
* → `applier.go:544` 회귀 시 RestoreSnapshot)와 지표 벡터 정체 판정(`internal/loop/feedback.go:43`
|
|
16
|
+
* IsImproved / `:54` IsStagnant). 둘 다 moai 에서 실제 배선된 경로다.
|
|
17
|
+
*
|
|
18
|
+
* 두 fail-safe 규칙은 moai 에서 그대로 가져온다. 이게 없으면 게이트가 오탐 기계가 된다:
|
|
19
|
+
* 1. 기준선이 없으면 절대 막지 않는다 (regression_gate.go:124-127).
|
|
20
|
+
* 2. 파싱 불가면 "의견 없음"이다 — 막지도, 통과시키지도 않는다.
|
|
21
|
+
* 즉 **측정은 fail-closed 이되, 측정의 부재는 fail-open** 이다. 지표를 못 읽는다는 이유로
|
|
22
|
+
* 루프가 죽으면 러너를 바꾼 사용자 전원이 피해자가 된다.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 러너별 추출기. 위에서부터 시도하고 **먼저 걸리는 것이 이긴다.**
|
|
27
|
+
*
|
|
28
|
+
* 순서가 중요하다: 구체적인 포맷을 먼저 두고 일반 패턴을 뒤에 둔다. 반대로 하면
|
|
29
|
+
* jest 의 "Tests: 1 failed, 205 passed" 를 느슨한 패턴이 먼저 반쪽만 집어간다.
|
|
30
|
+
*/
|
|
31
|
+
const EXTRACTORS = [
|
|
32
|
+
// node:test — `ℹ pass 206` / `ℹ fail 0`
|
|
33
|
+
(t) => {
|
|
34
|
+
const p = /^\s*(?:ℹ\s*)?pass\s+(\d+)\s*$/m.exec(t);
|
|
35
|
+
const f = /^\s*(?:ℹ\s*)?fail\s+(\d+)\s*$/m.exec(t);
|
|
36
|
+
return p ? { passed: +p[1], failed: f ? +f[1] : 0 } : null;
|
|
37
|
+
},
|
|
38
|
+
// jest — `Tests: 1 failed, 205 passed, 206 total`
|
|
39
|
+
(t) => {
|
|
40
|
+
const m = /Tests:\s+(?:(\d+)\s+failed,\s*)?(?:\d+\s+skipped,\s*)?(\d+)\s+passed/.exec(t);
|
|
41
|
+
return m ? { passed: +m[2], failed: m[1] ? +m[1] : 0 } : null;
|
|
42
|
+
},
|
|
43
|
+
// vitest — `Tests 1 failed | 205 passed (206)`
|
|
44
|
+
(t) => {
|
|
45
|
+
const m = /Tests\s+(?:(\d+)\s+failed\s*\|\s*)?(\d+)\s+passed/.exec(t);
|
|
46
|
+
return m ? { passed: +m[2], failed: m[1] ? +m[1] : 0 } : null;
|
|
47
|
+
},
|
|
48
|
+
// pytest — `===== 3 failed, 12 passed in 1.20s =====`
|
|
49
|
+
(t) => {
|
|
50
|
+
const p = /(\d+)\s+passed/.exec(t);
|
|
51
|
+
const f = /(\d+)\s+failed/.exec(t);
|
|
52
|
+
return p || f ? { passed: p ? +p[1] : 0, failed: f ? +f[1] : 0 } : null;
|
|
53
|
+
},
|
|
54
|
+
// go test — 개별 결과 줄을 센다. 요약 줄이 없는 러너의 마지막 보루.
|
|
55
|
+
(t) => {
|
|
56
|
+
const p = (t.match(/^\s*--- PASS:/gm) ?? []).length;
|
|
57
|
+
const f = (t.match(/^\s*--- FAIL:/gm) ?? []).length;
|
|
58
|
+
return p + f > 0 ? { passed: p, failed: f } : null;
|
|
59
|
+
},
|
|
60
|
+
// TAP — `# pass 12` / `# fail 0`
|
|
61
|
+
(t) => {
|
|
62
|
+
const p = /^#\s*pass\s+(\d+)/m.exec(t);
|
|
63
|
+
const f = /^#\s*fail\s+(\d+)/m.exec(t);
|
|
64
|
+
return p ? { passed: +p[1], failed: f ? +f[1] : 0 } : null;
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 러너 출력에서 `{passed, failed}` 를 뽑는다. 못 읽으면 **null** — 0 이 아니다.
|
|
70
|
+
* 이 구분이 게이트 전체를 떠받친다. 0 을 돌려주면 "전부 사라졌다"는 회귀로 오독된다.
|
|
71
|
+
*/
|
|
72
|
+
export function parseMetrics(text) {
|
|
73
|
+
if (typeof text !== 'string' || !text) return null;
|
|
74
|
+
for (const extract of EXTRACTORS) {
|
|
75
|
+
let got;
|
|
76
|
+
try {
|
|
77
|
+
got = extract(text);
|
|
78
|
+
} catch {
|
|
79
|
+
continue; // 추출기 하나가 죽어도 다음으로 넘어간다 — 인프라는 fail-open.
|
|
80
|
+
}
|
|
81
|
+
if (got && Number.isFinite(got.passed) && Number.isFinite(got.failed)) return got;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 기준선 대비 후퇴한 차원의 이름들. 빈 배열이면 회귀 없음.
|
|
88
|
+
*
|
|
89
|
+
* **통과 수 감소만 회귀로 친다.** 실패 수 증가는 어차피 exit code 가 잡으므로 여기서
|
|
90
|
+
* 또 세면 같은 사실을 두 번 벌하는 셈이고, 무엇보다 "고치는 중이라 실패가 잠깐 는다"는
|
|
91
|
+
* 정상 경로를 막아버린다. 이 게이트가 겨냥하는 것은 정확히 하나다 —
|
|
92
|
+
* **초록불인데 측정 범위가 쪼그라든 경우.**
|
|
93
|
+
*/
|
|
94
|
+
export function regressions(base, candidate) {
|
|
95
|
+
if (!base || !candidate) return []; // 기준선 없음 = 절대 막지 않는다 (fail-safe 1)
|
|
96
|
+
const out = [];
|
|
97
|
+
if (candidate.passed < base.passed) out.push('passed');
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 다음 기준선. **무결성이 깨끗하고** 회귀가 없을 때만 올린다.
|
|
103
|
+
*
|
|
104
|
+
* `integrityOk` 조건이 빠지면 두 가드가 서로를 무력화한다(실측으로 재현했다):
|
|
105
|
+
* · 가짜 단언 60개를 주입한 회차는 무결성 위반으로 차단되지만, 출력은 `pass 260` 이고
|
|
106
|
+
* 회귀도 아니므로 기준선이 260 으로 오염된다.
|
|
107
|
+
* · 다음 회차에 에이전트가 지시대로 가짜를 걷어내면 `pass 200` 이 되고, 이번엔
|
|
108
|
+
* **비회귀 게이트가 그 정직한 수정을 차단**한다.
|
|
109
|
+
* 그러면 엔진이 받아들이는 유일한 상태가 "가짜를 유지한 상태"다 — 무결성 가드가 벌하는
|
|
110
|
+
* 바로 그 보상 해킹을 비회귀 가드가 보상하게 된다. 정직한 수정을 막는 게이트는 게이트가 아니다.
|
|
111
|
+
*/
|
|
112
|
+
export function nextBaseline(current, metrics, { integrityOk, regressed }) {
|
|
113
|
+
return metrics && integrityOk && !(regressed ?? []).length ? metrics : current;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 이번 회차가 직전보다 나아졌는가. 하나라도 좋아지면 참이다. */
|
|
117
|
+
export function isImproved(prev, curr) {
|
|
118
|
+
if (!prev || !curr) return false;
|
|
119
|
+
return curr.failed < prev.failed || curr.passed > prev.passed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 지표 벡터가 완전히 동일한가 = 이번 회차가 아무것도 바꾸지 못했다. */
|
|
123
|
+
export function isSameMetrics(prev, curr) {
|
|
124
|
+
if (!prev || !curr) return false;
|
|
125
|
+
return prev.passed === curr.passed && prev.failed === curr.failed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 회귀를 에이전트에게 들이밀 텍스트로. 차단은 대안과 함께 배송한다(bkit). */
|
|
129
|
+
export function regressionMessage(base, candidate, regressed) {
|
|
130
|
+
return [
|
|
131
|
+
'[hi-loop] 비회귀 게이트 위반 — 테스트는 초록불이지만 통과로 인정하지 않는다.',
|
|
132
|
+
`- 통과 수가 ${base.passed}개 → ${candidate.passed}개로 줄었다 (실패 ${base.failed} → ${candidate.failed}).`,
|
|
133
|
+
`- 후퇴한 차원: ${regressed.join(', ')}`,
|
|
134
|
+
'',
|
|
135
|
+
'초록불은 "테스트가 통과했다"는 뜻이지 "측정 범위가 그대로다"라는 뜻이 아니다.',
|
|
136
|
+
'테스트가 수집되지 않거나 조건부로 건너뛰어져 통과 수가 줄면 exit 0 이 나온다.',
|
|
137
|
+
'사라진 테스트가 다시 실행되도록 되돌린 뒤, 구현을 고쳐서 통과시켜라.',
|
|
138
|
+
'테스트를 정말 지워야 한다면 왜인지 근거를 남기고 같은 범위를 덮는 것으로 교체하라.',
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|