@mcpeak/cli 0.9.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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/cli.cjs +6 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +8 -0
- package/dist/commands.cjs +11 -0
- package/dist/commands.d.cts +67 -0
- package/dist/commands.d.mts +67 -0
- package/dist/commands.mjs +2 -0
- package/dist/index.cjs +6 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.mts +27 -0
- package/dist/index.mjs +2 -0
- package/dist/replay-command-BAtSzf8R.d.mts +186 -0
- package/dist/replay-command-V0ZSJPiD.d.cts +186 -0
- package/dist/src-D1NW6ckv.mjs +459 -0
- package/dist/src-Df8147pD.cjs +482 -0
- package/dist/test-command-BZ_94UmI.cjs +4576 -0
- package/dist/test-command-CsJfojut.mjs +4493 -0
- package/package.json +61 -0
|
@@ -0,0 +1,4576 @@
|
|
|
1
|
+
let node_fs_promises = require("node:fs/promises");
|
|
2
|
+
let node_path = require("node:path");
|
|
3
|
+
let node_readline_promises = require("node:readline/promises");
|
|
4
|
+
let _mcpeak_runner = require("@mcpeak/runner");
|
|
5
|
+
let _mcpeak_record = require("@mcpeak/record");
|
|
6
|
+
let node_child_process = require("node:child_process");
|
|
7
|
+
let _mcpeak_core = require("@mcpeak/core");
|
|
8
|
+
//#region package.json
|
|
9
|
+
var version = "0.9.0";
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/cassette-wiring.ts
|
|
12
|
+
/**
|
|
13
|
+
* 카세트가 없으면 새로 녹화하고, 있으면 재생하며 빠진 것만 녹화한다. `--record` 는 있는 파일을
|
|
14
|
+
* 무시한다. `replay` 는 쓰지 않는다. 새 케이스가 추가되는 것이 이 경로의 정상 흐름인데
|
|
15
|
+
* `replay` 는 미스에서 실패한다. 설계 문서 §5.2.
|
|
16
|
+
*/
|
|
17
|
+
const resolveMode = (cassette, forceRecord) => forceRecord || cassette === null ? "record" : "auto";
|
|
18
|
+
async function wireCassette(options) {
|
|
19
|
+
const { inner, path, forceRecord } = options;
|
|
20
|
+
if (path === void 0) return {
|
|
21
|
+
client: inner,
|
|
22
|
+
flush: async () => {},
|
|
23
|
+
warnings: []
|
|
24
|
+
};
|
|
25
|
+
const io = options.io ?? {
|
|
26
|
+
load: _mcpeak_record.loadCassette,
|
|
27
|
+
save: _mcpeak_record.saveCassette
|
|
28
|
+
};
|
|
29
|
+
const cassette = await io.load(path);
|
|
30
|
+
const warnings = [];
|
|
31
|
+
let snapshot;
|
|
32
|
+
const mode = resolveMode(cassette, forceRecord);
|
|
33
|
+
const recorder = (0, _mcpeak_record.cassetteClient)(inner, {
|
|
34
|
+
cassette,
|
|
35
|
+
mode,
|
|
36
|
+
cassettePath: path,
|
|
37
|
+
onFlush: async (value) => {
|
|
38
|
+
snapshot = value;
|
|
39
|
+
},
|
|
40
|
+
onWarning: (message) => {
|
|
41
|
+
warnings.push(message);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const client = {
|
|
45
|
+
listTools: () => recorder.listTools(),
|
|
46
|
+
callTool: (name, args) => recorder.callTool(name, args),
|
|
47
|
+
close: () => inner.close()
|
|
48
|
+
};
|
|
49
|
+
const flush = async () => {
|
|
50
|
+
await recorder.close();
|
|
51
|
+
if (snapshot === void 0) return;
|
|
52
|
+
await io.save(path, snapshot);
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
client,
|
|
56
|
+
mode,
|
|
57
|
+
flush,
|
|
58
|
+
warnings
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/dry-run.ts
|
|
63
|
+
/** 보고서가 상한을 넘긴 경우의 안내. 케이스 수 말고는 사용자가 손댈 수 있는 것이 없다. */
|
|
64
|
+
const REPORT_LIMIT_DETAIL = "보고서가 1MB 상한을 넘었습니다. 케이스 수를 줄인 뒤 다시 시도하세요.";
|
|
65
|
+
/**
|
|
66
|
+
* 케이스 하나가 상한을 넘긴 경우의 안내. 케이스 수를 줄이라고 하면 안 된다. 넘긴 것은 그
|
|
67
|
+
* 케이스 하나이고, 나머지를 지워도 같은 오류가 그대로 난다.
|
|
68
|
+
*/
|
|
69
|
+
const caseLimitDetail = (caseId) => `케이스${caseId === void 0 ? "" : ` '${caseId}'`} 가 상한을 넘었습니다. 그 케이스의 이름·입력·단언을 줄인 뒤 다시 시도하세요.`;
|
|
70
|
+
/** 툴 이름조차 알 수 없을 때의 안내. `runSuite` 가 통째로 던진 경우다. */
|
|
71
|
+
const CONNECTION_LOST_DETAIL = "MCP 서버 연결이 끊겼습니다.";
|
|
72
|
+
/** `renderReport` 의 진단·단언 줄 들여쓰기. reporter.ts 의 INDENT 와 같은 값이다. */
|
|
73
|
+
const INDENT$1 = " ";
|
|
74
|
+
/**
|
|
75
|
+
* `renderReport` 출력에서 케이스별 블록을 잘라 낸다. 반환 배열의 순서와 길이는
|
|
76
|
+
* `report.cases` 와 같다.
|
|
77
|
+
*
|
|
78
|
+
* 문장을 다시 만들지 않고 잘라 내기만 하는 것이 요점이다. 케이스 머리글 줄은 버린다.
|
|
79
|
+
* 호출 측이 자기 번호와 이름으로 머리글을 다시 그리기 때문이고, 그대로 두면 같은 이름이
|
|
80
|
+
* 두 줄 나온다.
|
|
81
|
+
*
|
|
82
|
+
* 경계 판정은 들여쓰기다. `renderReport` 는 케이스 본문 줄을 전부 4칸 들여쓰고 머리글만
|
|
83
|
+
* 기호로 시작한다. 메시지 안의 개행은 렌더러가 이미 이스케이프하므로 본문 줄이 임의로
|
|
84
|
+
* 늘어나지 않는다.
|
|
85
|
+
*/
|
|
86
|
+
const caseBlocks = (report) => {
|
|
87
|
+
const blocks = [];
|
|
88
|
+
for (const line of (0, _mcpeak_runner.renderReport)(report).split("\n").slice(2)) {
|
|
89
|
+
if (line === "") break;
|
|
90
|
+
if (line.startsWith(INDENT$1)) blocks.at(-1)?.push(line);
|
|
91
|
+
else blocks.push([]);
|
|
92
|
+
}
|
|
93
|
+
return report.cases.map((_, index) => (blocks[index] ?? []).join("\n"));
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* 연결이 끊긴 케이스의 위치를 찾는다. `runSuite` 는 호출이 던져도 멈추지 않고 다음 케이스로
|
|
97
|
+
* 가므로, 서버가 죽으면 남은 케이스가 전부 같은 오류로 채워진다. 첫 번째만 사실이고 나머지는
|
|
98
|
+
* 그 결과다. 그래서 첫 지점에서 자른다.
|
|
99
|
+
*/
|
|
100
|
+
const firstConnectionLoss = (report) => {
|
|
101
|
+
for (const [index, result] of report.cases.entries()) {
|
|
102
|
+
const diagnostic = result.operation.diagnostic;
|
|
103
|
+
if (result.operation.status === "failed" && diagnostic?.code === "OPERATION_FAILED") return {
|
|
104
|
+
index,
|
|
105
|
+
message: diagnostic.message
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* 러너가 케이스를 다 돌기 전에 멈춘 경우를 찾는다. `stopReason` 이 그 사실을 말한다.
|
|
111
|
+
*
|
|
112
|
+
* 이것을 읽지 않으면 실행되지도 않은 `notRun` 케이스가 실패 목록에 섞여 분류 화면으로 간다.
|
|
113
|
+
* 사용자가 그것을 `서버 결함` 으로 고르면 서버에 보낸 적도 없는 호출이 회귀 테스트가 된다.
|
|
114
|
+
*/
|
|
115
|
+
const stopDetail = (report) => {
|
|
116
|
+
const stop = report.stopReason;
|
|
117
|
+
if (stop === void 0) return void 0;
|
|
118
|
+
const name = report.cases.find((result) => result.spec.id === stop.caseId)?.spec.name;
|
|
119
|
+
const suffix = "남은 케이스는 실행되지 않았습니다.";
|
|
120
|
+
if (stop.type === "timeout") return `케이스 '${name ?? stop.caseId}' 가 제한 시간 안에 끝나지 않았습니다. ${suffix}`;
|
|
121
|
+
return name === void 0 ? `실행이 중단됐습니다. ${suffix}` : `케이스 '${name}' 에서 실행이 중단됐습니다. ${suffix}`;
|
|
122
|
+
};
|
|
123
|
+
const toResult = (report) => {
|
|
124
|
+
const blocks = caseBlocks(report);
|
|
125
|
+
const outcomes = report.cases.map((result, index) => ({
|
|
126
|
+
caseId: result.spec.id,
|
|
127
|
+
caseName: result.spec.name,
|
|
128
|
+
status: result.status,
|
|
129
|
+
detail: blocks[index] ?? "",
|
|
130
|
+
rejectionBasis: result.rejectionBasis,
|
|
131
|
+
...result.rejectionBody === void 0 ? {} : { rejectionBody: result.rejectionBody }
|
|
132
|
+
}));
|
|
133
|
+
const lost = firstConnectionLoss(report);
|
|
134
|
+
if (lost !== void 0) return {
|
|
135
|
+
outcomes: outcomes.slice(0, lost.index + 1),
|
|
136
|
+
aborted: {
|
|
137
|
+
reason: "connectionLost",
|
|
138
|
+
detail: lost.message
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const stopped = stopDetail(report);
|
|
142
|
+
if (stopped !== void 0) return {
|
|
143
|
+
outcomes: outcomes.filter((outcome) => outcome.status !== "notRun"),
|
|
144
|
+
aborted: {
|
|
145
|
+
reason: "stopped",
|
|
146
|
+
detail: stopped
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
return { outcomes };
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* 후보 명세 전량을 실행한다. 케이스를 골라내지 않는다. 설계 문서 §4.4 가 근거다.
|
|
153
|
+
* 실행 순서는 `cases` 배열 순서이고 정렬하지 않는다.
|
|
154
|
+
*/
|
|
155
|
+
async function runDryRun(options) {
|
|
156
|
+
const execution = (0, _mcpeak_runner.runSuite)({
|
|
157
|
+
client: options.client,
|
|
158
|
+
suite: options.suite
|
|
159
|
+
});
|
|
160
|
+
let report;
|
|
161
|
+
try {
|
|
162
|
+
report = await execution.report;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof _mcpeak_runner.RunnerPayloadLimitError) return {
|
|
165
|
+
outcomes: [],
|
|
166
|
+
aborted: {
|
|
167
|
+
reason: "payloadLimit",
|
|
168
|
+
detail: error.scope === "report" ? REPORT_LIMIT_DETAIL : caseLimitDetail(error.caseId)
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
outcomes: [],
|
|
173
|
+
aborted: {
|
|
174
|
+
reason: "connectionLost",
|
|
175
|
+
detail: CONNECTION_LOST_DETAIL
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
await execution.drain;
|
|
180
|
+
return toResult(report);
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/dry-run-review.ts
|
|
184
|
+
/** 선택지 한 글자와 화면 문안. 배열 순서가 곧 화면 순서다. */
|
|
185
|
+
const CHOICES = [
|
|
186
|
+
{
|
|
187
|
+
key: "s",
|
|
188
|
+
label: "서버 결함",
|
|
189
|
+
help: "명세가 옳다. 이 케이스를 회귀 테스트로 남긴다"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: "m",
|
|
193
|
+
label: "명세 오류",
|
|
194
|
+
help: "추측이 틀렸다. 저장 전에 고친다"
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
key: "?",
|
|
198
|
+
label: "판단 보류",
|
|
199
|
+
help: "분류를 미룬다. 저장은 막힌다"
|
|
200
|
+
}
|
|
201
|
+
];
|
|
202
|
+
/** 요약 줄의 항목 순서. 0건인 종류는 빼고 찍는다. */
|
|
203
|
+
const SUMMARY_LABELS = [
|
|
204
|
+
["s", "서버 결함"],
|
|
205
|
+
["m", "명세 오류"],
|
|
206
|
+
["?", "판단 보류"]
|
|
207
|
+
];
|
|
208
|
+
/** 실패 케이스인가. 통과가 아닌 것은 전부 사람의 판단이 필요하다. */
|
|
209
|
+
const isFailure = (outcome) => outcome.status !== "passed";
|
|
210
|
+
const writeSummary = (io, chosen) => {
|
|
211
|
+
const parts = SUMMARY_LABELS.filter(([key]) => chosen.includes(key)).map(([key, label]) => {
|
|
212
|
+
return `${label} ${chosen.filter((value) => value === key).length}건`;
|
|
213
|
+
});
|
|
214
|
+
io.write(` 분류: ${parts.join(", ")}\n`);
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* 시도 횟수를 세는 낱말. 교정은 케이스당 최대 2회라서(설계 §4.1) 두 개면 충분하다.
|
|
218
|
+
* 그보다 많이 들어오면 숫자로 적는다. 화면이 조용히 틀린 낱말을 쓰는 것보다 낫다.
|
|
219
|
+
*/
|
|
220
|
+
const COUNT_WORDS = ["한 번", "두 번"];
|
|
221
|
+
const countWord = (count) => COUNT_WORDS[count - 1] ?? `${count}번`;
|
|
222
|
+
/**
|
|
223
|
+
* 교정 시도 이력을 찍는다. 문안은 설계 문서 §8.7 이 전량 고정한다. 이력이 없는 케이스는
|
|
224
|
+
* 이 블록이 통째로 없고 기존 화면 그대로다.
|
|
225
|
+
*
|
|
226
|
+
* 결과를 `오류` 로 고정하는 이유. 이 화면에 닿은 케이스는 교정이 전부 실패한 것이다.
|
|
227
|
+
* 통과한 케이스는 애초에 실패 목록에 없다. §8.7 이 고정한 낱말도 `오류` 하나뿐이다.
|
|
228
|
+
*/
|
|
229
|
+
const writeAttempts = (io, attempts) => {
|
|
230
|
+
if (attempts.length === 0) return;
|
|
231
|
+
const width = Math.max(...attempts.map((attempt) => attempt.field.length));
|
|
232
|
+
io.write(` 입력값을 ${countWord(attempts.length)} 고쳐 봤지만 결과가 같습니다.\n`);
|
|
233
|
+
for (const attempt of attempts) io.write(` ${attempt.field.padEnd(width)}: ${JSON.stringify(attempt.value)} → 오류\n`);
|
|
234
|
+
io.write("\n");
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* 한 글자를 받는다. 대소문자를 구분하지 않고 앞뒤 공백을 버린다. 아는 글자가 나올 때까지
|
|
238
|
+
* 같은 질문을 다시 묻는다. 기본값으로 넘기지 않는다. 모르는 채로 눌린 엔터가 없는 버그를
|
|
239
|
+
* 회귀 테스트로 굳히면 안 된다.
|
|
240
|
+
*/
|
|
241
|
+
const askChoice = async (io) => {
|
|
242
|
+
for (const choice of CHOICES) io.write(` [${choice.key}] ${choice.label} ${choice.help}\n`);
|
|
243
|
+
for (;;) {
|
|
244
|
+
const answer = (await io.input(" 선택: ")).trim().toLowerCase();
|
|
245
|
+
if (CHOICES.some((choice) => choice.key === answer)) return answer;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* 실패 케이스를 분류하고 저장 가능 여부를 판정한다.
|
|
250
|
+
*
|
|
251
|
+
* `cleared` 가 false 면 `approvals` 를 비운다. 반쯤 채워 넘기면 호출 측이 그것을 저장할 여지가
|
|
252
|
+
* 생기고, 그 파일은 사람이 판단하지 않은 판정을 담게 된다.
|
|
253
|
+
*
|
|
254
|
+
* `attempts` 는 입력값 교정을 시도했던 케이스의 이력이다(§8.7). 선택이고, 안 넘기면 화면과
|
|
255
|
+
* 반환값이 지금과 완전히 같다.
|
|
256
|
+
*/
|
|
257
|
+
async function reviewDryRun(io, result, attempts) {
|
|
258
|
+
const blocked = {
|
|
259
|
+
cleared: false,
|
|
260
|
+
approvals: [],
|
|
261
|
+
specErrors: []
|
|
262
|
+
};
|
|
263
|
+
if (result.aborted !== void 0) return blocked;
|
|
264
|
+
const failures = result.outcomes.filter(isFailure);
|
|
265
|
+
const passedAll = () => result.outcomes.map((outcome) => ({
|
|
266
|
+
id: outcome.caseId,
|
|
267
|
+
status: "passed"
|
|
268
|
+
}));
|
|
269
|
+
if (failures.length === 0) return {
|
|
270
|
+
cleared: true,
|
|
271
|
+
approvals: passedAll(),
|
|
272
|
+
specErrors: []
|
|
273
|
+
};
|
|
274
|
+
const chosen = [];
|
|
275
|
+
for (const [index, outcome] of failures.entries()) {
|
|
276
|
+
io.write(` [${index + 1}] ${outcome.caseName}\n`);
|
|
277
|
+
writeAttempts(io, attempts?.get(outcome.caseId) ?? []);
|
|
278
|
+
chosen.push(await askChoice(io));
|
|
279
|
+
io.write("\n");
|
|
280
|
+
}
|
|
281
|
+
writeSummary(io, chosen);
|
|
282
|
+
const specErrors = failures.filter((_, index) => chosen[index] === "m").map((outcome) => outcome.caseId);
|
|
283
|
+
if (chosen.some((value) => value !== "s")) return {
|
|
284
|
+
...blocked,
|
|
285
|
+
specErrors
|
|
286
|
+
};
|
|
287
|
+
const defects = new Set(failures.map((outcome) => outcome.caseId));
|
|
288
|
+
return {
|
|
289
|
+
cleared: true,
|
|
290
|
+
approvals: result.outcomes.map((outcome) => ({
|
|
291
|
+
id: outcome.caseId,
|
|
292
|
+
status: defects.has(outcome.caseId) ? "serverDefect" : "passed"
|
|
293
|
+
})),
|
|
294
|
+
specErrors: []
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/finding-group.ts
|
|
299
|
+
/**
|
|
300
|
+
* finding 코드를 검사 종류로 가른다. `Record<SpecFindingCode, …>` 라서 `runner` 가 코드를
|
|
301
|
+
* 늘리면 여기서 타입 오류가 난다. 문자열 배열로 두면 새 코드가 어느 블록에도 못 들어간 채
|
|
302
|
+
* 조용히 사라지고, 이 화면에서 누락은 "위반이 없다" 로 읽히므로 가장 나쁜 실패다.
|
|
303
|
+
*
|
|
304
|
+
* **이 표는 한 벌이다.** `generate` 와 `test` 가 같은 표를 각자 갖고 있던 때는, 새 코드가 왔을 때
|
|
305
|
+
* 두 곳을 채우면서 서로 다른 종류를 고를 수 있었다. 둘 다 `FindingGroup` 의 유효한 값이라
|
|
306
|
+
* 컴파일러도 테스트도 잡지 못하고, 같은 finding 이 화면마다 다른 머리글 아래 뜬다. 그러면 읽는
|
|
307
|
+
* 사람이 멀쩡한 입력을 고치러 간다(이슈 #154).
|
|
308
|
+
*/
|
|
309
|
+
const FINDING_GROUP = {
|
|
310
|
+
TOOL_NOT_DECLARED: "inputContract",
|
|
311
|
+
REQUIRED_MISSING: "inputContract",
|
|
312
|
+
UNDECLARED_FIELD: "inputContract",
|
|
313
|
+
TYPE_MISMATCH: "inputContract",
|
|
314
|
+
ENUM_MISMATCH: "inputContract",
|
|
315
|
+
RANGE_MISMATCH: "inputContract",
|
|
316
|
+
SCHEMA_NOT_ANALYZABLE: "skipped",
|
|
317
|
+
REJECTION_WITHOUT_VIOLATION: "rejectionIntent",
|
|
318
|
+
VACUOUS_MIN_LENGTH: "assertionSubstance",
|
|
319
|
+
VACUOUS_MIN_ITEMS: "assertionSubstance"
|
|
320
|
+
};
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/help.ts
|
|
323
|
+
const TEST_USAGE = "사용법: mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>]";
|
|
324
|
+
/**
|
|
325
|
+
* 시험 실행 옵션 설명. `--determinism` 은 툴을 2회 호출하므로 부작용이 있는 서버에서 모르고
|
|
326
|
+
* 쓰면 사고가 난다. 그 경고가 사용법 한 줄에는 들어가지 않는다.
|
|
327
|
+
*/
|
|
328
|
+
const TEST_OPTIONS = `옵션:
|
|
329
|
+
--determinism 스위트를 2회 실행해 결과가 같은지 확인합니다. 툴을 2회
|
|
330
|
+
호출하므로 부작용이 있는 서버에서는 샌드박스에서 쓰세요.
|
|
331
|
+
--reset-cmd 와 함께 쓰면 결정론성 확인이 되고, 없으면
|
|
332
|
+
"2회 결과가 같았다" 까지만 확인합니다
|
|
333
|
+
--reset-cmd <command> 각 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지
|
|
334
|
+
않으므로 파이프나 && 는 쓸 수 없습니다`;
|
|
335
|
+
const GENERATE_USAGE = "사용법: mcpeak generate --suite-id <id> --name <name> --out <suite.json> --command <executable> [--arg <value> ...] [--baseline-only] [--provider <codex|claude>] [--model <model>] [--no-dry-run] [--cassette <path>] [--record] [--reset-cmd <command>] [--no-repair] [--force]";
|
|
336
|
+
/**
|
|
337
|
+
* 시험 실행 옵션 설명. 사용법 한 줄로는 `--cassette` 가 무엇을 파일로 남기는지, `--reset-cmd`
|
|
338
|
+
* 가 셸을 거치지 않는다는 제약을 알 수 없다. 둘 다 모르고 쓰면 사고가 나는 값이다.
|
|
339
|
+
*/
|
|
340
|
+
const GENERATE_DRY_RUN_OPTIONS = `옵션:
|
|
341
|
+
--no-dry-run 승인 전 시험 실행을 건너뜁니다. 케이스가 실제 서버에서 확인되지
|
|
342
|
+
않은 채 저장됩니다
|
|
343
|
+
--cassette <path> 서버 응답을 녹화·재생합니다. 반복 실행에서 서버를 다시 부르지
|
|
344
|
+
않습니다. 응답 전문이 저장되므로 .gitignore 를 확인하세요
|
|
345
|
+
--record 카세트를 처음부터 다시 녹화합니다 (--cassette 필요)
|
|
346
|
+
--reset-cmd <command> 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지 않으므로
|
|
347
|
+
파이프나 && 는 쓸 수 없습니다
|
|
348
|
+
--no-repair 시험 실행이 실패해도 입력값을 고쳐 다시 시도하지 않습니다.
|
|
349
|
+
실패가 곧바로 분류 화면으로 갑니다
|
|
350
|
+
--force \`--out\` 경로에 파일이 있으면 지우고 새로 씁니다. 기본은 저장을
|
|
351
|
+
멈추는 것입니다`;
|
|
352
|
+
const REPAIR_USAGE = "사용법: mcpeak repair <bundle.json> --provider <codex|claude> --model <model> [--max-cases <N>] [--no-stderr] [--yes]";
|
|
353
|
+
/**
|
|
354
|
+
* repair 옵션 설명. 사용법 한 줄로는 `--no-stderr` 가 무엇을 빼는지, `--yes` 가 무엇을 건너뛰는지
|
|
355
|
+
* 알 수 없다. 둘 다 외부 provider 로 나가는 내용을 바꾸는 값이다.
|
|
356
|
+
*/
|
|
357
|
+
const REPAIR_OPTIONS = `옵션:
|
|
358
|
+
--provider <id> 진단을 물을 AI CLI 입니다. codex 또는 claude. 기본값은 없습니다
|
|
359
|
+
--model <model> provider 에 넘길 모델 식별자입니다. 기본값은 없습니다
|
|
360
|
+
--max-cases <N> 한 번에 보낼 실패 개수 상한입니다. 넘으면 앞에서부터 남깁니다
|
|
361
|
+
--no-stderr 서버 stderr 를 전송에서 뺍니다. stderr 는 서버가 자유롭게 쓰는
|
|
362
|
+
텍스트라 경로·토큰·데이터가 섞일 수 있습니다
|
|
363
|
+
--yes 전송 확인 화면을 건너뜁니다. 비대화형 환경에서 필요합니다`;
|
|
364
|
+
const REPLAY_USAGE = "사용법: mcpeak replay <suite.json> --cassette <path>";
|
|
365
|
+
/**
|
|
366
|
+
* 재생이 무엇을 하지 않는지가 이 명령의 핵심이다. 서버를 안 띄운다는 것과, 마스킹된 값에서는
|
|
367
|
+
* 판정이 실제와 갈릴 수 있다는 것을 한 줄 사용법으로는 알 수 없다. ADR-0028.
|
|
368
|
+
*/
|
|
369
|
+
const REPLAY_OPTIONS = `옵션:
|
|
370
|
+
--cassette <path> 재생할 카세트 파일입니다. 필수입니다.
|
|
371
|
+
|
|
372
|
+
replay 는 MCP 서버를 실행하지 않고 카세트에 녹화된 응답만 돌려줍니다. 카세트에 없는 호출을
|
|
373
|
+
만나면 실패합니다. 녹화는 \`mcpeak generate --cassette <path> --record\` 로 합니다.
|
|
374
|
+
|
|
375
|
+
카세트는 저장할 때 \`token\`·\`apiKey\` 같은 이름의 값을 가립니다. 그 자리의 판정은 실제 서버와
|
|
376
|
+
다를 수 있어 재생 시 경고합니다.`;
|
|
377
|
+
const VERIFY_USAGE = "사용법: mcpeak verify <cassette.json> --command <executable> [--arg <value> ...]";
|
|
378
|
+
/**
|
|
379
|
+
* `verify` 가 무엇을 **하지 않는지**가 이 명령의 핵심이다. 서버를 부르지만 카세트를 고치지
|
|
380
|
+
* 않는다. `--record` 와 헷갈리면 사용자는 지우려던 적이 없는 파일을 지운다.
|
|
381
|
+
*
|
|
382
|
+
* 다만 "읽기 전용" 을 그 한 마디로 끝내면 안 된다. 읽기 전용인 것은 **카세트 파일**이고,
|
|
383
|
+
* 서버 쪽으로는 녹화된 요청을 전부 다시 부른다. 메일 발송·결제·파일 쓰기 툴이 카세트에
|
|
384
|
+
* 들어 있으면 그 부작용이 실제로 다시 일어난다. `--determinism` 이 툴을 2회 부른다는
|
|
385
|
+
* 경고와 같은 성격이라 같은 자리에 적는다.
|
|
386
|
+
*/
|
|
387
|
+
const VERIFY_OPTIONS = `옵션:
|
|
388
|
+
--command <executable> MCP 서버 실행 파일입니다. 필수입니다.
|
|
389
|
+
--arg <value> 서버에 넘길 인자입니다. 여러 번 쓸 수 있습니다.
|
|
390
|
+
|
|
391
|
+
verify 는 카세트에 녹화된 요청을 실서버에 다시 보내 응답이 아직 같은지 확인합니다.
|
|
392
|
+
**카세트를 고치지도 저장하지도 않습니다.**
|
|
393
|
+
|
|
394
|
+
읽기 전용인 것은 카세트 파일입니다. 서버 쪽은 녹화된 요청을 **전부 다시 호출**하므로,
|
|
395
|
+
메일 발송·결제·파일 쓰기 같은 툴이 카세트에 있으면 그 부작용이 실제로 다시 일어납니다.
|
|
396
|
+
부작용이 있는 서버에서는 샌드박스에서 쓰세요.
|
|
397
|
+
|
|
398
|
+
auto 모드(\`--record\` 없는 실행)는 카세트에 있는 요청이면 서버를 부르지 않으므로, 서버
|
|
399
|
+
응답이 바뀌어도 알아채지 못합니다. 이 명령이 그 드리프트를 확인하는 비파괴 경로입니다.
|
|
400
|
+
|
|
401
|
+
카세트는 저장할 때 \`token\`·\`apiKey\` 같은 이름의 값을 가립니다. 그 값이 **요청 인자**에
|
|
402
|
+
있었다면 원래 요청을 복원할 수 없어 "확인불가" 로 보고합니다. 응답 쪽 비밀값은 양쪽 모두
|
|
403
|
+
마스킹해 비교하므로, 비밀값 자체만 바뀐 경우는 감지되지 않습니다.
|
|
404
|
+
|
|
405
|
+
불일치나 호출 실패가 하나라도 있으면 종료 코드 1 입니다. 확인불가는 실패로 보지 않습니다.`;
|
|
406
|
+
const GLOBAL_HELP = `OhMyMCP — MCP 서버 테스트 프레임워크
|
|
407
|
+
|
|
408
|
+
사용법: mcpeak <명령> [옵션]
|
|
409
|
+
mcpeak help [명령]
|
|
410
|
+
|
|
411
|
+
명령:
|
|
412
|
+
test JSON 테스트 명세로 MCP 서버를 실행하고 검증합니다.
|
|
413
|
+
generate MCP 서버의 툴 스키마에서 테스트 명세를 생성합니다.
|
|
414
|
+
repair 실패한 test 실행의 번들로 서버 코드의 원인 후보를 제안받습니다.
|
|
415
|
+
replay 녹화된 카세트로 서버 없이 테스트 명세를 재생합니다.
|
|
416
|
+
verify 카세트가 아직 실서버 응답과 맞는지 확인합니다 (카세트를 고치지 않음).
|
|
417
|
+
|
|
418
|
+
옵션:
|
|
419
|
+
-h, --help 도움말을 표시합니다.
|
|
420
|
+
--version 버전을 표시합니다.
|
|
421
|
+
|
|
422
|
+
서브커맨드 도움말:
|
|
423
|
+
mcpeak help <명령>
|
|
424
|
+
mcpeak <명령> --help
|
|
425
|
+
`;
|
|
426
|
+
const commandDiscovery = "사용 가능한 명령: test, generate, repair, replay, verify. 전체 도움말: mcpeak --help";
|
|
427
|
+
const TEST_USAGE_HINT = `${TEST_USAGE} ${commandDiscovery}`;
|
|
428
|
+
const GENERATE_USAGE_HINT = `${GENERATE_USAGE} ${commandDiscovery}`;
|
|
429
|
+
const REPAIR_USAGE_HINT = `${REPAIR_USAGE} ${commandDiscovery}`;
|
|
430
|
+
const REPLAY_USAGE_HINT = `${REPLAY_USAGE} ${commandDiscovery}`;
|
|
431
|
+
const VERIFY_USAGE_HINT = `${VERIFY_USAGE} ${commandDiscovery}`;
|
|
432
|
+
function commandHelp(command) {
|
|
433
|
+
if (command === "verify") return `verify — 카세트가 아직 실서버 응답과 맞는지 확인합니다.
|
|
434
|
+
|
|
435
|
+
${VERIFY_USAGE}
|
|
436
|
+
|
|
437
|
+
${VERIFY_OPTIONS}
|
|
438
|
+
`;
|
|
439
|
+
if (command === "test") return `test — JSON 테스트 명세로 MCP 서버를 실행하고 검증합니다.
|
|
440
|
+
|
|
441
|
+
${TEST_USAGE}
|
|
442
|
+
|
|
443
|
+
${TEST_OPTIONS}
|
|
444
|
+
`;
|
|
445
|
+
if (command === "repair") return `repair — 실패한 test 실행의 번들로 서버 코드의 원인 후보를 제안받습니다.
|
|
446
|
+
|
|
447
|
+
${REPAIR_USAGE}
|
|
448
|
+
|
|
449
|
+
${REPAIR_OPTIONS}
|
|
450
|
+
`;
|
|
451
|
+
if (command === "replay") return `replay — 녹화된 카세트로 서버 없이 테스트 명세를 재생합니다.
|
|
452
|
+
|
|
453
|
+
${REPLAY_USAGE}
|
|
454
|
+
|
|
455
|
+
${REPLAY_OPTIONS}
|
|
456
|
+
`;
|
|
457
|
+
return `generate — MCP 서버의 툴 스키마에서 테스트 명세를 생성합니다.
|
|
458
|
+
|
|
459
|
+
${GENERATE_USAGE}
|
|
460
|
+
|
|
461
|
+
${GENERATE_DRY_RUN_OPTIONS}
|
|
462
|
+
`;
|
|
463
|
+
}
|
|
464
|
+
//#endregion
|
|
465
|
+
//#region src/input-repair.ts
|
|
466
|
+
/** 케이스 머리글 들여쓰기. 분류 화면(§8.3)과 같은 값이라 번호가 이어 읽힌다. */
|
|
467
|
+
const HEAD = " ";
|
|
468
|
+
/** 본문 들여쓰기. 설계 문서 §8.6 의 모든 본문 줄이 이 폭이다. */
|
|
469
|
+
const BODY = " ";
|
|
470
|
+
/**
|
|
471
|
+
* 교정 대상의 실패 사유는 항상 이 한 줄이다. 대상 판별이 `isError` 단언의 `expected` 가
|
|
472
|
+
* true 가 아닌 실패만 통과시키므로(§4.2), 진단 문장이 이것 말고 나올 수 없다.
|
|
473
|
+
* `RepairTarget` 이 `detail` 을 나르지 않아 여기서 다시 적는다.
|
|
474
|
+
*/
|
|
475
|
+
const FAILURE_LINE = `${BODY}isError 정상 응답을 기대했지만 오류 응답을 받았습니다.`;
|
|
476
|
+
const PROPOSED_LEAD = `${BODY}입력값이 거절된 것으로 보입니다. 서버 응답에서 값을 찾았습니다.`;
|
|
477
|
+
const MANUAL_LEAD = `${BODY}입력값이 거절된 것으로 보입니다. 서버 응답에 쓸 만한 값이 없어 직접 받습니다.`;
|
|
478
|
+
const RERUN_LINE = `${BODY}▸ 다시 실행 중... 1건`;
|
|
479
|
+
const PASSED_LINE = `${BODY}✓ 통과`;
|
|
480
|
+
const EXHAUSTED_LINE = `${BODY}✗ 여전히 실패합니다. 입력값 문제가 아닐 수 있습니다.`;
|
|
481
|
+
/** JSON Schema 가 부르는 타입 이름. 값에서 뽑아 선언과 맞춰 본다. */
|
|
482
|
+
const jsonTypeOf = (value) => {
|
|
483
|
+
if (value === null) return "null";
|
|
484
|
+
if (Array.isArray(value)) return "array";
|
|
485
|
+
return typeof value;
|
|
486
|
+
};
|
|
487
|
+
/**
|
|
488
|
+
* 선언 타입과 값이 맞는가. 선언이 없으면 검사하지 않는다(§4.5).
|
|
489
|
+
* `integer` 는 `number` 의 부분집합이라 정수 여부까지 본다.
|
|
490
|
+
*/
|
|
491
|
+
const matchesDeclaredType = (declared, value) => {
|
|
492
|
+
if (declared === void 0) return true;
|
|
493
|
+
if (declared === "integer") return typeof value === "number" && Number.isInteger(value);
|
|
494
|
+
if (declared === "number") return typeof value === "number";
|
|
495
|
+
return jsonTypeOf(value) === declared;
|
|
496
|
+
};
|
|
497
|
+
const plainObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
498
|
+
/**
|
|
499
|
+
* 툴 선언에서 필드의 `type` 을 읽는다. 스키마는 서버가 주는 값이라 `unknown` 이다.
|
|
500
|
+
* 우리가 아는 모양이 아니면 선언이 없는 것으로 본다. 추측해서 되묻으면 사람이 못 넘어간다.
|
|
501
|
+
*/
|
|
502
|
+
const declaredTypeOf = (tools, tool, field) => {
|
|
503
|
+
const schema = tools.find((candidate) => candidate.name === tool)?.inputSchema;
|
|
504
|
+
if (!plainObject$2(schema)) return void 0;
|
|
505
|
+
const properties = schema.properties;
|
|
506
|
+
if (!plainObject$2(properties)) return void 0;
|
|
507
|
+
const declared = properties[field];
|
|
508
|
+
if (!plainObject$2(declared)) return void 0;
|
|
509
|
+
return typeof declared.type === "string" ? declared.type : void 0;
|
|
510
|
+
};
|
|
511
|
+
/**
|
|
512
|
+
* 사람이 친 글을 값으로 바꾼다. JSON 으로 읽어 보고 안 되면 문자열 그대로 쓴다(§4.5).
|
|
513
|
+
* `서울` 은 문자열, `42` 는 숫자, `{"a":1}` 은 객체가 된다.
|
|
514
|
+
*/
|
|
515
|
+
const parseAnswer = (answer) => {
|
|
516
|
+
try {
|
|
517
|
+
return JSON.parse(answer);
|
|
518
|
+
} catch {
|
|
519
|
+
return answer;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
/** §8.6 의 기본값 표시. 문자열은 따옴표 없이 그대로 보여준다. */
|
|
523
|
+
const renderDefault = (value) => typeof value === "string" ? value : JSON.stringify(value);
|
|
524
|
+
/** §8.6.1 의 현재값 표시. 값의 경계가 보여야 해서 JSON 으로 적는다. */
|
|
525
|
+
const renderCurrent = (value) => JSON.stringify(value) ?? "null";
|
|
526
|
+
/** `(tool, 필드명)` 캐시 키. 필드 이름에 점이 있어도 툴 이름과 섞이지 않게 앞을 길이로 가른다. */
|
|
527
|
+
const cacheKey = (tool, field) => `${tool.length}:${tool}.${field}`;
|
|
528
|
+
/**
|
|
529
|
+
* 한 필드를 사람에게 받는다. 선언 타입과 안 맞으면 같은 질문을 다시 한다(§4.5).
|
|
530
|
+
* `proposed` 가 있으면 §8.6 형식, 없으면 §8.6.1 형식이다.
|
|
531
|
+
*/
|
|
532
|
+
const askField = async (io, options) => {
|
|
533
|
+
const fallback = options.proposed ?? options.current;
|
|
534
|
+
const declared = options.declared === void 0 ? "" : `${options.declared}, `;
|
|
535
|
+
const question = options.proposed === void 0 ? `${BODY}${options.field} (${declared}현재 ${renderCurrent(options.current)}): ` : `${BODY}${options.field}: [${renderDefault(options.proposed)}]`;
|
|
536
|
+
for (;;) {
|
|
537
|
+
const answer = await io.input(question);
|
|
538
|
+
if (answer.trim() === "") return fallback;
|
|
539
|
+
const value = parseAnswer(answer);
|
|
540
|
+
if (matchesDeclaredType(options.declared, value)) return value;
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
/**
|
|
544
|
+
* 한 회차의 입력값을 만든다. `reuse` 에 값이 있는 필드는 묻지 않고 그대로 쓴다(§4.6).
|
|
545
|
+
* 키 순서는 `current` 의 순서를 그대로 지킨다.
|
|
546
|
+
*/
|
|
547
|
+
const askRound = async (io, target, options) => {
|
|
548
|
+
const next = {};
|
|
549
|
+
const asked = [];
|
|
550
|
+
let unchanged = true;
|
|
551
|
+
for (const [field, value] of Object.entries(options.current)) {
|
|
552
|
+
const cached = options.reuse?.get(field);
|
|
553
|
+
const answered = cached === void 0 ? await askField(io, {
|
|
554
|
+
field,
|
|
555
|
+
current: value,
|
|
556
|
+
proposed: options.proposed?.[field],
|
|
557
|
+
declared: declaredTypeOf(options.tools, target.tool, field)
|
|
558
|
+
}) : cached;
|
|
559
|
+
if (cached === void 0) asked.push(field);
|
|
560
|
+
next[field] = answered;
|
|
561
|
+
if (JSON.stringify(answered) !== JSON.stringify(value)) unchanged = false;
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
input: next,
|
|
565
|
+
asked,
|
|
566
|
+
unchanged
|
|
567
|
+
};
|
|
568
|
+
};
|
|
569
|
+
async function repairInputs(options) {
|
|
570
|
+
const outcomes = [];
|
|
571
|
+
/**
|
|
572
|
+
* 사람이 확인했고 재실행까지 통과한 값. `(tool, 필드명)` 하나당 하나다.
|
|
573
|
+
* 이 호출이 끝나면 사라진다(§4.6).
|
|
574
|
+
*/
|
|
575
|
+
const cache = /* @__PURE__ */ new Map();
|
|
576
|
+
/** §8.6.3 을 이미 찍은 키. 같은 안내를 케이스마다 되풀이하지 않는다. */
|
|
577
|
+
const announced = /* @__PURE__ */ new Set();
|
|
578
|
+
for (const [index, target] of options.targets.entries()) {
|
|
579
|
+
options.io.write(`${HEAD}[${index + 1}] ${target.caseName}\n`);
|
|
580
|
+
options.io.write(`${FAILURE_LINE}\n`);
|
|
581
|
+
options.io.write("\n");
|
|
582
|
+
const attempts = [];
|
|
583
|
+
const fields = Object.keys(target.input);
|
|
584
|
+
const reused = fields.filter((field) => cache.has(cacheKey(target.tool, field)));
|
|
585
|
+
const askable = fields.filter((field) => !cache.has(cacheKey(target.tool, field)));
|
|
586
|
+
for (const field of reused) {
|
|
587
|
+
const key = cacheKey(target.tool, field);
|
|
588
|
+
if (announced.has(key)) continue;
|
|
589
|
+
announced.add(key);
|
|
590
|
+
const shared = options.targets.slice(index).filter((other) => other.tool === target.tool && field in other.input).length;
|
|
591
|
+
options.io.write(`${BODY}같은 값을 ${target.tool}.${field} 를 쓰는 케이스 ${shared}건에 함께 적용합니다.\n`);
|
|
592
|
+
}
|
|
593
|
+
const proposed = askable.length === 0 ? void 0 : await options.propose?.(target);
|
|
594
|
+
if (askable.length > 0) options.io.write(proposed === void 0 ? `${MANUAL_LEAD}\n` : `${PROPOSED_LEAD}\n`);
|
|
595
|
+
const first = await askRound(options.io, target, {
|
|
596
|
+
current: target.input,
|
|
597
|
+
proposed,
|
|
598
|
+
reuse: new Map(reused.map((field) => [field, cache.get(cacheKey(target.tool, field))])),
|
|
599
|
+
tools: options.tools
|
|
600
|
+
});
|
|
601
|
+
if (first.unchanged) {
|
|
602
|
+
outcomes.push({
|
|
603
|
+
caseId: target.caseId,
|
|
604
|
+
repaired: false,
|
|
605
|
+
attempts: []
|
|
606
|
+
});
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
options.io.write(`${RERUN_LINE}\n`);
|
|
610
|
+
const firstVerdict = await options.rerun(target.caseId, first.input);
|
|
611
|
+
for (const [field, value] of Object.entries(first.input)) attempts.push({
|
|
612
|
+
field,
|
|
613
|
+
value,
|
|
614
|
+
passed: firstVerdict.passed
|
|
615
|
+
});
|
|
616
|
+
if (firstVerdict.passed) {
|
|
617
|
+
for (const field of first.asked) cache.set(cacheKey(target.tool, field), first.input[field]);
|
|
618
|
+
options.io.write(`${PASSED_LINE}\n`);
|
|
619
|
+
outcomes.push({
|
|
620
|
+
caseId: target.caseId,
|
|
621
|
+
repaired: true,
|
|
622
|
+
input: first.input,
|
|
623
|
+
attempts
|
|
624
|
+
});
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (proposed === void 0) {
|
|
628
|
+
options.io.write(`${EXHAUSTED_LINE}\n`);
|
|
629
|
+
outcomes.push({
|
|
630
|
+
caseId: target.caseId,
|
|
631
|
+
repaired: false,
|
|
632
|
+
attempts
|
|
633
|
+
});
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const second = await askRound(options.io, target, {
|
|
637
|
+
current: first.input,
|
|
638
|
+
proposed: void 0,
|
|
639
|
+
tools: options.tools
|
|
640
|
+
});
|
|
641
|
+
if (second.unchanged) {
|
|
642
|
+
options.io.write(`${EXHAUSTED_LINE}\n`);
|
|
643
|
+
outcomes.push({
|
|
644
|
+
caseId: target.caseId,
|
|
645
|
+
repaired: false,
|
|
646
|
+
attempts
|
|
647
|
+
});
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
options.io.write(`${RERUN_LINE}\n`);
|
|
651
|
+
const secondVerdict = await options.rerun(target.caseId, second.input);
|
|
652
|
+
for (const [field, value] of Object.entries(second.input)) attempts.push({
|
|
653
|
+
field,
|
|
654
|
+
value,
|
|
655
|
+
passed: secondVerdict.passed
|
|
656
|
+
});
|
|
657
|
+
if (secondVerdict.passed) {
|
|
658
|
+
for (const field of second.asked) cache.set(cacheKey(target.tool, field), second.input[field]);
|
|
659
|
+
options.io.write(`${PASSED_LINE}\n`);
|
|
660
|
+
outcomes.push({
|
|
661
|
+
caseId: target.caseId,
|
|
662
|
+
repaired: true,
|
|
663
|
+
input: second.input,
|
|
664
|
+
attempts
|
|
665
|
+
});
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
options.io.write(`${EXHAUSTED_LINE}\n`);
|
|
669
|
+
outcomes.push({
|
|
670
|
+
caseId: target.caseId,
|
|
671
|
+
repaired: false,
|
|
672
|
+
attempts
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
return outcomes;
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region src/pre-fill-wiring.ts
|
|
679
|
+
const plainObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
680
|
+
/** 케이스 하나에 제안 값들을 얹은 입력. 원본을 바꾸지 않는다. */
|
|
681
|
+
function withProposals(input, proposals) {
|
|
682
|
+
const next = { ...input };
|
|
683
|
+
for (const proposal of proposals) next[proposal.field] = proposal.value;
|
|
684
|
+
return next;
|
|
685
|
+
}
|
|
686
|
+
/** 케이스 id 로 골라 만든 부분 명세. 원본의 케이스 순서를 유지한다. */
|
|
687
|
+
const subsetSuite = (suite, ids, replace) => ({
|
|
688
|
+
...suite,
|
|
689
|
+
cases: suite.cases.filter((item) => ids.has(item.id)).map((item) => withInput(item, replace?.get(item.id)))
|
|
690
|
+
});
|
|
691
|
+
/**
|
|
692
|
+
* 케이스의 입력만 바꾼다. `TestCaseSpec` 은 유니온인데 판별자가 `operation.type` 이라
|
|
693
|
+
* 중첩 속성으로는 좁혀지지 않는다. 좁힌 뒤 단언으로 넘긴다.
|
|
694
|
+
*/
|
|
695
|
+
function withInput(item, input) {
|
|
696
|
+
if (input === void 0 || item.operation.type !== "callTool") return item;
|
|
697
|
+
const callTool = item;
|
|
698
|
+
return {
|
|
699
|
+
...callTool,
|
|
700
|
+
operation: {
|
|
701
|
+
...callTool.operation,
|
|
702
|
+
input
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const passedIds = (result) => new Set(result.outcomes.filter((outcome) => outcome.status === "passed").map((item) => item.caseId));
|
|
707
|
+
/**
|
|
708
|
+
* 제안 값을 baseline 값과 나란히 실행해 채택을 정한다.
|
|
709
|
+
*
|
|
710
|
+
* | baseline | AI | 채택 |
|
|
711
|
+
* |---|---|---|
|
|
712
|
+
* | 통과 | 통과 | **baseline** — 결정론적이고 재현 가능한 쪽이 기본값이다 |
|
|
713
|
+
* | 통과 | 실패 | baseline |
|
|
714
|
+
* | 실패 | 통과 | **AI** |
|
|
715
|
+
* | 실패 | 실패 | baseline (분류 화면으로. 사후수리가 이어받는다) |
|
|
716
|
+
*
|
|
717
|
+
* 시험 실행이 중단되면(`aborted`) 그 회차의 판정을 믿지 않고 전부 baseline 을 유지한다.
|
|
718
|
+
* 끊긴 연결에서 나온 "실패" 를 근거로 AI 값을 채택하면 서버가 옳은데 우리가 바꾸는 것이 된다.
|
|
719
|
+
*/
|
|
720
|
+
async function applyPreFill(options) {
|
|
721
|
+
const { client, baseline, preFill } = options;
|
|
722
|
+
const run = options.dryRun ?? runDryRun;
|
|
723
|
+
const byCase = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const proposal of preFill.accepted) {
|
|
725
|
+
const list = byCase.get(proposal.caseId) ?? [];
|
|
726
|
+
list.push({
|
|
727
|
+
field: proposal.field,
|
|
728
|
+
value: proposal.value
|
|
729
|
+
});
|
|
730
|
+
byCase.set(proposal.caseId, list);
|
|
731
|
+
}
|
|
732
|
+
if (byCase.size === 0) return {
|
|
733
|
+
suite: baseline,
|
|
734
|
+
cases: [],
|
|
735
|
+
adopted: 0,
|
|
736
|
+
notAdopted: 0
|
|
737
|
+
};
|
|
738
|
+
const targetIds = /* @__PURE__ */ new Set();
|
|
739
|
+
const aiInputs = /* @__PURE__ */ new Map();
|
|
740
|
+
for (const item of baseline.cases) {
|
|
741
|
+
const proposals = byCase.get(item.id);
|
|
742
|
+
if (proposals === void 0) continue;
|
|
743
|
+
if (item.operation.type !== "callTool") continue;
|
|
744
|
+
const input = item.operation.input;
|
|
745
|
+
if (!plainObject$1(input)) continue;
|
|
746
|
+
targetIds.add(item.id);
|
|
747
|
+
aiInputs.set(item.id, withProposals(input, proposals));
|
|
748
|
+
}
|
|
749
|
+
if (targetIds.size === 0) return {
|
|
750
|
+
suite: baseline,
|
|
751
|
+
cases: [],
|
|
752
|
+
adopted: 0,
|
|
753
|
+
notAdopted: 0
|
|
754
|
+
};
|
|
755
|
+
const baselineRun = await run({
|
|
756
|
+
client,
|
|
757
|
+
suite: subsetSuite(baseline, targetIds)
|
|
758
|
+
});
|
|
759
|
+
const aiRun = await run({
|
|
760
|
+
client,
|
|
761
|
+
suite: subsetSuite(baseline, targetIds, aiInputs)
|
|
762
|
+
});
|
|
763
|
+
const aborted = baselineRun.aborted !== void 0 || aiRun.aborted !== void 0;
|
|
764
|
+
const baselinePassed = passedIds(baselineRun);
|
|
765
|
+
const aiPassed = passedIds(aiRun);
|
|
766
|
+
const cases = [];
|
|
767
|
+
const adoptedIds = /* @__PURE__ */ new Set();
|
|
768
|
+
for (const item of baseline.cases) {
|
|
769
|
+
if (!targetIds.has(item.id)) continue;
|
|
770
|
+
const basePass = baselinePassed.has(item.id);
|
|
771
|
+
const aiPass = aiPassed.has(item.id);
|
|
772
|
+
const useAi = !aborted && !basePass && aiPass;
|
|
773
|
+
if (useAi) adoptedIds.add(item.id);
|
|
774
|
+
cases.push({
|
|
775
|
+
caseId: item.id,
|
|
776
|
+
source: useAi ? "ai" : "baseline",
|
|
777
|
+
needsClassification: !aborted && !basePass && !aiPass
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
const adopted = adoptedIds.size;
|
|
781
|
+
return {
|
|
782
|
+
suite: adopted === 0 ? baseline : {
|
|
783
|
+
...baseline,
|
|
784
|
+
cases: baseline.cases.map((item) => withInput(item, adoptedIds.has(item.id) ? aiInputs.get(item.id) : void 0))
|
|
785
|
+
},
|
|
786
|
+
cases,
|
|
787
|
+
adopted,
|
|
788
|
+
notAdopted: cases.length - adopted
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/** 점 표기 경로를 따라 필드 스키마를 찾는다. 못 찾으면 undefined 다. */
|
|
792
|
+
function schemaAt(inputSchema, path) {
|
|
793
|
+
let current = inputSchema;
|
|
794
|
+
for (const segment of path.split(".")) {
|
|
795
|
+
if (!plainObject$1(current)) return void 0;
|
|
796
|
+
const properties = current.properties;
|
|
797
|
+
if (typeof properties !== "object" || properties === null) return void 0;
|
|
798
|
+
current = properties[segment];
|
|
799
|
+
while (plainObject$1(current) && current.type === "array") current = current.items;
|
|
800
|
+
}
|
|
801
|
+
return plainObject$1(current) ? current : void 0;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* AI 를 부를 수 없는 경로에서 건너뛸 툴을 고른다.
|
|
805
|
+
*
|
|
806
|
+
* `--baseline-only` 처럼 **애초에 provider 를 안 부르기로 한 경로에서만** 쓴다. provider 가
|
|
807
|
+
* 죽은 경우에는 쓰지 않는다. 그것은 사용자 서버의 문제가 아니라 우리 쪽 사정이고, 그것 때문에
|
|
808
|
+
* 케이스를 잃는 손해가 더 크다.
|
|
809
|
+
*/
|
|
810
|
+
function unknownFormatSkips(tools, provenance) {
|
|
811
|
+
if (!Array.isArray(provenance)) return [];
|
|
812
|
+
const declared = /* @__PURE__ */ new Map();
|
|
813
|
+
for (const tool of tools) if (!declared.has(tool.name)) declared.set(tool.name, tool);
|
|
814
|
+
const skips = [];
|
|
815
|
+
for (const item of provenance) for (const field of item.unknownFormatFields) {
|
|
816
|
+
const format = schemaAt(declared.get(item.tool)?.inputSchema, field)?.format;
|
|
817
|
+
skips.push({
|
|
818
|
+
tool: item.tool,
|
|
819
|
+
field,
|
|
820
|
+
format: typeof format === "string" ? format : ""
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
return skips;
|
|
824
|
+
}
|
|
825
|
+
/** 건너뛸 툴의 케이스를 명세에서 뺀다. 그 축은 커버리지에 미검증으로 남는다. */
|
|
826
|
+
function dropSkippedTools(suite, skips) {
|
|
827
|
+
if (skips.length === 0) return suite;
|
|
828
|
+
const dropped = new Set(skips.map((skip) => skip.tool));
|
|
829
|
+
return {
|
|
830
|
+
...suite,
|
|
831
|
+
cases: suite.cases.filter((item) => item.operation.type !== "callTool" || !dropped.has(item.operation.tool))
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
//#endregion
|
|
835
|
+
//#region src/process-diagnostics.ts
|
|
836
|
+
/** TAB. 이 모듈만 이스케이프 대상에서 빼는 문자다. 아래 escapeTokens 주석에 근거가 있다. */
|
|
837
|
+
const TAB = 9;
|
|
838
|
+
/**
|
|
839
|
+
* 터미널 제어 문자를 무해한 문자열로 바꾼다. 문자 하나가 토큰 하나가 된다. 설계 문서 §5.5.
|
|
840
|
+
*
|
|
841
|
+
* `packages/runner/src/reporter.ts` 의 `escapeTerminalText`,
|
|
842
|
+
* `packages/cli/src/test-command.ts` 의 `escapeTerminalText` 와 같은 계열이다. 그 함수들을
|
|
843
|
+
* import 하지 않고 사본을 두는 근거는 ADR-0013 과 같다. (줄 번호로 가리키면 금방 낡으므로
|
|
844
|
+
* 심볼 이름으로 가리킨다.)
|
|
845
|
+
*
|
|
846
|
+
* **다만 이 사본만 TAB(0x09)을 이스케이프하지 않는다.** 다른 두 사본은 우리가 만든 메시지
|
|
847
|
+
* 문자열만 다루므로 TAB 이 들어올 일이 없다. 이 모듈은 서버 stderr 를 그대로 그리는데, Java 의
|
|
848
|
+
* `\tat com.example.Foo.bar(Foo.java:42)` 나 Go 패닉 트레이스의 파일·줄 행이 전부 TAB
|
|
849
|
+
* 들여쓰기다. 그것을 ` ` 로 바꾸면 이 기능이 보여주려던 스택 트레이스가 도리어 읽기
|
|
850
|
+
* 어려워진다. TAB 은 커서를 옮길 뿐이라 터미널 주입 벡터가 아니다.
|
|
851
|
+
*
|
|
852
|
+
* 문자열이 아니라 토큰 배열을 돌려주는 이유는 자를 때 이스케이프 시퀀스 중간을 끊지 않기
|
|
853
|
+
* 위해서다. `clampTokens` 를 봐라.
|
|
854
|
+
*/
|
|
855
|
+
function escapeTokens(value) {
|
|
856
|
+
return Array.from(value, (character) => {
|
|
857
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
858
|
+
if (codePoint === TAB) return character;
|
|
859
|
+
return codePoint <= 31 || codePoint >= 127 && codePoint <= 159 || codePoint === 8232 || codePoint === 8233 ? `\\u${codePoint.toString(16).padStart(4, "0")}` : character;
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* 우리 종료 절차가 보내는 시그널. `packages/core/src/lifecycle.ts` 가 stdin EOF 뒤
|
|
864
|
+
* `STDIN_CLOSE_GRACE_MS` 가 지나면 SIGTERM 을, 다시 `SIGTERM_GRACE_MS` 뒤에 SIGKILL 을 보낸다.
|
|
865
|
+
* 타이머나 소켓 핸들을 들고 있어 유예 안에 못 끝나는 멀쩡한 서버가 여기 걸린다.
|
|
866
|
+
*/
|
|
867
|
+
const SHUTDOWN_SIGNALS = /* @__PURE__ */ new Set(["SIGTERM", "SIGKILL"]);
|
|
868
|
+
/**
|
|
869
|
+
* 프로세스가 비정상 종료했는지 판정한다.
|
|
870
|
+
* 우리가 보내는 종료 시그널(SIGTERM·SIGKILL)은 그 자체로 비정상이 아니다. 정상 실행에서도
|
|
871
|
+
* 나오므로 그것으로 경보를 울리면 거짓 경보가 된다. SIGSEGV·SIGABRT·SIGBUS 처럼 우리가 보내지
|
|
872
|
+
* 않는 시그널은 그대로 비정상이다.
|
|
873
|
+
* exitCode 가 null 이면 아직 종료하지 않았다는 뜻이므로 비정상이 아니다.
|
|
874
|
+
*
|
|
875
|
+
* 알려진 한계: OOM killer 가 보낸 SIGKILL 을 우리가 보낸 것과 구분하지 못해 놓친다.
|
|
876
|
+
* core 진단에 "우리가 보냈다" 표식이 없어 지금은 구분할 방법이 없다. 거짓 경보를 매 실행마다
|
|
877
|
+
* 내는 쪽보다 이 한 경우를 놓치는 쪽이 낫다고 보고 받아들인다. 설계 문서 §4.3.
|
|
878
|
+
*/
|
|
879
|
+
function isAbnormalExit(diagnostics) {
|
|
880
|
+
if (diagnostics.signal !== null) return !SHUTDOWN_SIGNALS.has(diagnostics.signal);
|
|
881
|
+
return diagnostics.exitCode !== null && diagnostics.exitCode !== 0;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* 블록에 담을 내용이 있는지 판정한다. 설계 문서 §4.3 의 "진단에 내용이 없으면 쓰지 않는다".
|
|
885
|
+
* stderr 가 비고 비정상 종료도 아니면 남는 것은 `종료 코드: 0 시그널: 없음` 과
|
|
886
|
+
* `stderr: (비어 있음)` 뿐이라 정보량이 0이다.
|
|
887
|
+
*
|
|
888
|
+
* 이 판정을 호출부마다 다시 쓰지 않고 여기 한 곳에 둔다. 억제 규칙이 갈라지면 진단이 가장
|
|
889
|
+
* 필요한 경로에만 조용히 미적용되는 일이 생긴다.
|
|
890
|
+
*/
|
|
891
|
+
function hasDiagnosticContent(diagnostics) {
|
|
892
|
+
return diagnostics.stderr !== "" || isAbnormalExit(diagnostics);
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* stderr 를 줄로 나눈다. 설계 문서 §5.4.
|
|
896
|
+
* 개행 하나로 끝나는 경우에 생기는 마지막 빈 줄만 하나 버린다. 그 외의 빈 줄은 정보다.
|
|
897
|
+
*/
|
|
898
|
+
function splitLines(stderr) {
|
|
899
|
+
const lines = stderr.split(/\r?\n/);
|
|
900
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
901
|
+
return lines;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* 한 줄에 표시할 문자 수 상한. 구조화 로거는 크래시마다 수십 KB JSON 을 한 줄로 뱉는다.
|
|
905
|
+
* 줄 수만 제한하면 그 한 줄이 통째로 터미널에 쏟아진다. 1000자면 스택 프레임 한 줄과 긴 경로를
|
|
906
|
+
* 담고도 터미널 몇 줄에 들어간다.
|
|
907
|
+
*/
|
|
908
|
+
const MAX_LINE_CHARACTERS = 1e3;
|
|
909
|
+
/**
|
|
910
|
+
* 상한을 넘는 줄을 자르고 생략한 문자 수를 알린다. 설계 문서 §5.4.
|
|
911
|
+
*
|
|
912
|
+
* **이스케이프 뒤에 자른다.** 이스케이프는 제어문자 하나를 `\uXXXX` 6자로 부풀리므로, 원문
|
|
913
|
+
* 기준으로 자르면 상한이 상한 노릇을 못 한다. 제어문자로 채운 1000자가 6000자가 된다.
|
|
914
|
+
* 그래서 세는 단위도 이스케이프된 문자다.
|
|
915
|
+
*
|
|
916
|
+
* 자르는 지점이 ` ` 같은 시퀀스 중간이 되면 안 되므로 문자열이 아니라 토큰 배열을 받는다.
|
|
917
|
+
* 토큰 하나는 원문 문자 하나에 대응하고 절대 쪼개지 않는다.
|
|
918
|
+
*/
|
|
919
|
+
function clampTokens(tokens) {
|
|
920
|
+
let total = 0;
|
|
921
|
+
for (const token of tokens) total += token.length;
|
|
922
|
+
if (total <= MAX_LINE_CHARACTERS) return tokens.join("");
|
|
923
|
+
let kept = 0;
|
|
924
|
+
const head = [];
|
|
925
|
+
for (const token of tokens) {
|
|
926
|
+
if (kept + token.length > MAX_LINE_CHARACTERS) break;
|
|
927
|
+
head.push(token);
|
|
928
|
+
kept += token.length;
|
|
929
|
+
}
|
|
930
|
+
return `${head.join("")} …(${total - kept}자 생략)`;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* 진단 블록을 만든다. maxLines 가 0 이면 빈 문자열을 반환한다.
|
|
934
|
+
* 빈 문자열이 아니면 항상 개행으로 끝난다.
|
|
935
|
+
*/
|
|
936
|
+
function renderProcessDiagnostics(diagnostics, options) {
|
|
937
|
+
if (options.maxLines === 0) return "";
|
|
938
|
+
const head = `서버 프로세스 진단\n 종료 코드: ${diagnostics.exitCode === null ? "없음" : String(diagnostics.exitCode)} 시그널: ${diagnostics.signal === null ? "없음" : diagnostics.signal}\n`;
|
|
939
|
+
if (diagnostics.stderr === "") return `${head} stderr: (비어 있음)\n`;
|
|
940
|
+
const lines = splitLines(diagnostics.stderr);
|
|
941
|
+
const shown = lines.slice(Math.max(0, lines.length - options.maxLines));
|
|
942
|
+
const dropped = lines.length - shown.length;
|
|
943
|
+
const whole = diagnostics.stderrTruncated ? "수집된 전체" : "전체";
|
|
944
|
+
const notes = [dropped === 0 ? whole : `마지막 ${options.maxLines}줄`];
|
|
945
|
+
if (dropped > 0) notes.push(`위로 ${dropped}줄 더 있음`);
|
|
946
|
+
if (diagnostics.stderrTruncated) notes.push("앞부분이 수집 상한으로 잘렸습니다");
|
|
947
|
+
const body = shown.map((line) => ` ${clampTokens(escapeTokens(line))}\n`).join("");
|
|
948
|
+
return `${head} stderr (${notes.join(", ")}):\n${body}`;
|
|
949
|
+
}
|
|
950
|
+
//#endregion
|
|
951
|
+
//#region src/repair-proposal.ts
|
|
952
|
+
/** 요청 문안에서 서버 응답 본문이 시작하는 자리. 치환 뒤 본문이 남았는지 여기서 확인한다. */
|
|
953
|
+
const SERVER_LABEL = "서버 응답: ";
|
|
954
|
+
/** 설계 문서 §4.4 의 문안. 값만 채운다. 문장을 새로 만들지 마라. */
|
|
955
|
+
const buildInstruction = (target) => [
|
|
956
|
+
"시험 실행에서 아래 케이스가 실패했습니다. 입력값만 고쳐 주세요.",
|
|
957
|
+
"단언과 케이스 구조는 바꾸지 마세요.",
|
|
958
|
+
"",
|
|
959
|
+
`케이스: ${target.caseName} (id: ${target.caseId})`,
|
|
960
|
+
`툴: ${target.tool}`,
|
|
961
|
+
`보낸 입력: ${(0, _mcpeak_runner.canonicalJson)(target.input)}`,
|
|
962
|
+
`${SERVER_LABEL}${target.serverMessage}`
|
|
963
|
+
].join("\n");
|
|
964
|
+
/**
|
|
965
|
+
* 치환을 거친 요청에 서버 응답 본문이 남아 있는가. 본문이 통째로 지워졌으면 보낼 근거가
|
|
966
|
+
* 없으므로 요청하지 않는다(설계 §4.4). 라벨 자체가 사라진 경우도 같다.
|
|
967
|
+
*/
|
|
968
|
+
const hasServerMessage = (instruction) => {
|
|
969
|
+
const at = instruction.indexOf(SERVER_LABEL);
|
|
970
|
+
if (at < 0) return false;
|
|
971
|
+
return instruction.slice(at + 7).split(_mcpeak_runner.REDACTED).join("").trim() !== "";
|
|
972
|
+
};
|
|
973
|
+
/** 깊은 동등 비교. 키 순서에 좌우되지 않도록 정규화 직렬화로 대조한다. */
|
|
974
|
+
const same = (left, right) => (0, _mcpeak_runner.canonicalJson)(left) === (0, _mcpeak_runner.canonicalJson)(right);
|
|
975
|
+
/** 대상 케이스의 입력값. `callTool` 이 아니면 입력이라는 것이 없다. */
|
|
976
|
+
const inputOf = (spec) => spec.operation.type === "callTool" ? spec.operation.input : void 0;
|
|
977
|
+
const toolOf = (spec) => spec.operation.type === "callTool" ? spec.operation.tool : void 0;
|
|
978
|
+
/**
|
|
979
|
+
* 응답의 권한 경계를 검사한다. 허용되는 변경은 **대상 케이스 `operation.input` 안의 값** 하나
|
|
980
|
+
* 뿐이고, 설계 문서 §4.3 의 금지 항목을 하나라도 어기면 제안 전체를 폐기한다. 부분 수용은
|
|
981
|
+
* 하지 않는다. 여기가 뚫리면 AI 가 서버에 맞춰 단언을 무력화하는 경로가 열리고, 그 순간
|
|
982
|
+
* "서버가 틀렸을 가능성" 이 사라져 시험 실행 게이트의 존재 이유가 없어진다.
|
|
983
|
+
*/
|
|
984
|
+
function acceptProposal(options) {
|
|
985
|
+
const { target, before, after } = options;
|
|
986
|
+
if (!same({
|
|
987
|
+
...before,
|
|
988
|
+
cases: []
|
|
989
|
+
}, {
|
|
990
|
+
...after,
|
|
991
|
+
cases: []
|
|
992
|
+
})) return void 0;
|
|
993
|
+
if (after.cases.length !== before.cases.length) return void 0;
|
|
994
|
+
const index = before.cases.findIndex((spec) => spec.id === target.caseId);
|
|
995
|
+
if (index < 0) return void 0;
|
|
996
|
+
for (let position = 0; position < before.cases.length; position++) {
|
|
997
|
+
if (position === index) continue;
|
|
998
|
+
if (!same(before.cases[position], after.cases[position])) return void 0;
|
|
999
|
+
}
|
|
1000
|
+
const beforeCase = before.cases[index];
|
|
1001
|
+
const afterCase = after.cases[index];
|
|
1002
|
+
const beforeTool = toolOf(beforeCase);
|
|
1003
|
+
const afterTool = toolOf(afterCase);
|
|
1004
|
+
if (beforeTool === void 0 || afterTool !== beforeTool) return void 0;
|
|
1005
|
+
if (!same(beforeCase.assertions, afterCase.assertions)) return void 0;
|
|
1006
|
+
if (afterCase.id !== beforeCase.id) return void 0;
|
|
1007
|
+
if (afterCase.name !== beforeCase.name) return void 0;
|
|
1008
|
+
if (afterCase.timeoutMs !== beforeCase.timeoutMs) return void 0;
|
|
1009
|
+
const beforeInput = inputOf(beforeCase);
|
|
1010
|
+
const afterInput = inputOf(afterCase);
|
|
1011
|
+
if (beforeInput === void 0 || afterInput === void 0) return void 0;
|
|
1012
|
+
const beforeKeys = Object.keys(beforeInput);
|
|
1013
|
+
const afterKeys = Object.keys(afterInput);
|
|
1014
|
+
if (beforeKeys.length !== afterKeys.length) return void 0;
|
|
1015
|
+
const known = new Set(beforeKeys);
|
|
1016
|
+
if (afterKeys.some((key) => !known.has(key))) return void 0;
|
|
1017
|
+
if (same(beforeInput, afterInput)) return void 0;
|
|
1018
|
+
return afterInput;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* 교정안을 provider 에게 요청한다. 실패는 전부 `undefined` 로 접는다. 교정은 선택이지
|
|
1022
|
+
* 전제가 아니라서, 여기서 던지면 교정 실패가 시험 실행을 통째로 죽인다.
|
|
1023
|
+
*/
|
|
1024
|
+
async function proposeRepair(options) {
|
|
1025
|
+
if (options.target.serverMessage === "") return void 0;
|
|
1026
|
+
const before = options.session.approvedDraft.suite;
|
|
1027
|
+
try {
|
|
1028
|
+
const preview = options.prepare({
|
|
1029
|
+
mode: "revise",
|
|
1030
|
+
instruction: buildInstruction(options.target),
|
|
1031
|
+
baseline: options.session.baseline.suite,
|
|
1032
|
+
candidate: before,
|
|
1033
|
+
tools: options.tools,
|
|
1034
|
+
providerId: options.provider.id,
|
|
1035
|
+
model: options.provider.model ?? "",
|
|
1036
|
+
redaction: options.redaction
|
|
1037
|
+
});
|
|
1038
|
+
if (!hasServerMessage(preview.request.instruction)) return void 0;
|
|
1039
|
+
const result = await options.dispatch({
|
|
1040
|
+
provider: options.provider,
|
|
1041
|
+
preview,
|
|
1042
|
+
approval: {
|
|
1043
|
+
approved: true,
|
|
1044
|
+
fingerprint: preview.fingerprint
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
if (result.status !== "preview") return void 0;
|
|
1048
|
+
return acceptProposal({
|
|
1049
|
+
target: options.target,
|
|
1050
|
+
before,
|
|
1051
|
+
after: result.preview.result.suite
|
|
1052
|
+
});
|
|
1053
|
+
} catch {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
//#endregion
|
|
1058
|
+
//#region src/repair-render.ts
|
|
1059
|
+
/**
|
|
1060
|
+
* 터미널 제어 문자를 무해한 토큰으로 바꾼다.
|
|
1061
|
+
*
|
|
1062
|
+
* `packages/cli/src/test-command.ts` 의 `escapeTerminalText` 와 같은 계열이다. 그 함수를
|
|
1063
|
+
* import 하지 않고 사본을 두는 근거는 ADR-0013 이고 `process-diagnostics.ts` 의 선례와 같다.
|
|
1064
|
+
* 이 모듈이 찍는 문자열은 **외부 provider 가 준 산문**이라 원문 보존보다 화면 무해성이 앞선다.
|
|
1065
|
+
*
|
|
1066
|
+
* TAB(0x09)도 이스케이프한다. `process-diagnostics.ts` 사본만 TAB 을 남기는데 그것은 서버
|
|
1067
|
+
* stderr 의 스택트레이스 들여쓰기를 보존하기 위해서다. AI 산문에는 보존할 들여쓰기가 없고,
|
|
1068
|
+
* 우리가 만든 라벨 정렬을 TAB 이 흔들면 화면이 어긋난다.
|
|
1069
|
+
*/
|
|
1070
|
+
const escapeTerminalText$1 = (value) => Array.from(value, (character) => {
|
|
1071
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
1072
|
+
return codePoint <= 31 || codePoint >= 127 && codePoint <= 159 || codePoint === 8232 || codePoint === 8233 ? `\\u${codePoint.toString(16).padStart(4, "0")}` : character;
|
|
1073
|
+
}).join("");
|
|
1074
|
+
/** 코드 포인트 단위로 자른다. 서로게이트 쌍(이모지)의 중간이 끊기지 않는다. */
|
|
1075
|
+
function clampDisplay(text, limit) {
|
|
1076
|
+
const points = [...text];
|
|
1077
|
+
return points.length <= limit ? text : `${points.slice(0, limit).join("")}…`;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* 경계 문장 둘. **모든 경로에서** 찍는다. `unsure` 여도, 진단이 잘 나와도 찍는다.
|
|
1081
|
+
* 이것이 없으면 사용자가 멀쩡한 서버 코드를 판다. 억제 조건을 만들지 않는다. 설계서 §6.3.
|
|
1082
|
+
*/
|
|
1083
|
+
const REPAIR_BOUNDARY_LINES = "※ AI 제안입니다. 파일을 고치지 않았고 명세도 그대로입니다.\n※ 명세 쪽이 틀렸다고 판단되면 `mcpeak generate` 로 다시 승인받으세요.\n";
|
|
1084
|
+
/** 비대화형에서 `--yes` 없이 부른 경우. 물어볼 수 없는 곳에서 조용히 보내지 않는다. */
|
|
1085
|
+
const REPAIR_CONFIRM_REQUIRED_LINE = "오류 [REPAIR_CONFIRM_REQUIRED]: 확인을 받을 수 없는 환경입니다. 전송 내용을 보여줄 수 없어 보내지 않았습니다.\n해결: 내용을 확인했다면 `--yes` 를 붙여 다시 실행하세요.\n";
|
|
1086
|
+
/** 사용자가 `n` 을 답한 경로. 실패가 아니라 의도한 종료다. */
|
|
1087
|
+
const REPAIR_CANCELLED_LINE = "전송하지 않았습니다.\n";
|
|
1088
|
+
const APPROVAL_LABEL = {
|
|
1089
|
+
matched: "승인 지문 일치",
|
|
1090
|
+
mismatched: "승인 지문 불일치",
|
|
1091
|
+
absent: "승인 지문 없음"
|
|
1092
|
+
};
|
|
1093
|
+
/**
|
|
1094
|
+
* 지문이 승인 상태가 아닐 때 결과 맨 위에 붙는 블록. 결과가 `unsure` 여도 붙는다. 설계서 §6.5.
|
|
1095
|
+
* `matched` 면 빈 문자열이다.
|
|
1096
|
+
*/
|
|
1097
|
+
function renderApprovalNotice(approval) {
|
|
1098
|
+
if (approval === "matched") return "";
|
|
1099
|
+
if (approval === "mismatched") return "⚠ 이 명세는 승인 상태가 아닙니다 (지문 불일치).\n 실패 원인이 서버가 아니라 명세일 수 있습니다. 아래 제안은 그 전제로 받았습니다.\n\n";
|
|
1100
|
+
return "⚠ 이 명세는 승인 지문이 없습니다.\n 실제 서버로 검증된 적이 없는 명세일 수 있습니다. 아래 제안은 그 전제로 받았습니다.\n\n";
|
|
1101
|
+
}
|
|
1102
|
+
/** KB 표기. 소수 한 자리다. 바이트 그대로 찍으면 큰 수를 눈으로 못 읽는다. */
|
|
1103
|
+
function kilobytes(bytes) {
|
|
1104
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* 전송 확인 화면(설계서 §6.1). 질문 줄은 붙이지 않는다. 그것은 `ReviewIO.confirm` 이 찍는다.
|
|
1108
|
+
*/
|
|
1109
|
+
function renderRepairConfirm(view) {
|
|
1110
|
+
const scope = view.omittedFailures === 0 ? `실패 ${view.totalFailures}건 중 ${view.sentFailures}건` : `실패 ${view.totalFailures}건 중 ${view.sentFailures}건 (--max-cases ${view.maxCases}, ${view.omittedFailures}건 제외)`;
|
|
1111
|
+
let stderrLine;
|
|
1112
|
+
if (!view.includeStderr) stderrLine = "(전송하지 않음)";
|
|
1113
|
+
else if (view.stderr === void 0 || view.stderr === "") stderrLine = "없음";
|
|
1114
|
+
else {
|
|
1115
|
+
const lines = view.stderr.split("\n");
|
|
1116
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
1117
|
+
stderrLine = `${lines.length}줄 ${kilobytes(Buffer.byteLength(view.stderr, "utf8"))} (--no-stderr 로 제외할 수 있습니다)`;
|
|
1118
|
+
}
|
|
1119
|
+
return [
|
|
1120
|
+
"repair 요청을 보냅니다.\n",
|
|
1121
|
+
"\n",
|
|
1122
|
+
` provider ${view.providerId} (${escapeTerminalText$1(view.model)})\n`,
|
|
1123
|
+
` 대상 ${scope}\n`,
|
|
1124
|
+
` 명세 상태 ${APPROVAL_LABEL[view.approval]}\n`,
|
|
1125
|
+
` stderr ${stderrLine}\n`,
|
|
1126
|
+
` 전송 크기 ${kilobytes(view.requestBytes)}\n`,
|
|
1127
|
+
"\n",
|
|
1128
|
+
"※ 위 내용이 외부 provider 로 전송됩니다.\n",
|
|
1129
|
+
"※ stderr 는 서버가 자유롭게 쓰는 텍스트라 경로·토큰·데이터가 섞일 수 있습니다.\n"
|
|
1130
|
+
].join("");
|
|
1131
|
+
}
|
|
1132
|
+
function discardedTotal(discarded) {
|
|
1133
|
+
return discarded.unknownCase + discarded.specTarget + discarded.unsureCauses;
|
|
1134
|
+
}
|
|
1135
|
+
function renderDiscardReasons(discarded) {
|
|
1136
|
+
const reasons = [];
|
|
1137
|
+
if (discarded.unknownCase > 0) reasons.push(`요청에 없는 케이스를 가리킨 제안 ${discarded.unknownCase}건`);
|
|
1138
|
+
if (discarded.specTarget > 0) reasons.push(`승인된 명세를 고치라는 제안 ${discarded.specTarget}건`);
|
|
1139
|
+
if (discarded.unsureCauses > 0) reasons.push(`판단 불가 응답에 함께 온 원인 후보 ${discarded.unsureCauses}건`);
|
|
1140
|
+
return reasons;
|
|
1141
|
+
}
|
|
1142
|
+
function renderDiscardActions(discarded) {
|
|
1143
|
+
const actions = [];
|
|
1144
|
+
if (discarded.specTarget > 0) actions.push(" → 명세가 실제로 틀렸다고 보시면 `mcpeak generate` 로 다시 승인받으세요.\n", " 승인된 명세를 고치라는 제안은 그 전에는 화면에 올리지 않습니다.\n");
|
|
1145
|
+
if (discarded.unknownCase > 0 || discarded.unsureCauses > 0) actions.push(" → 같은 번들로 한 번 더 물어보세요. 같은 입력에도 답은 달라집니다.\n");
|
|
1146
|
+
return actions;
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* 제안이 전부 폐기돼 남은 항목이 0 인 경우의 문안.
|
|
1150
|
+
*
|
|
1151
|
+
* 사용자가 알아야 할 것은 셋이다. AI 가 답을 냈다는 사실, 우리가 왜 버렸는지, 다음에 무엇을
|
|
1152
|
+
* 할 수 있는지. generate 가 보존한 사유별 개수로 관련 있는 행동만 안내한다.
|
|
1153
|
+
*/
|
|
1154
|
+
function renderAllDiscarded(discarded) {
|
|
1155
|
+
const parts = [
|
|
1156
|
+
`AI 가 원인 후보 ${discardedTotal(discarded)}건을 냈지만 전부 검증에서 제외했습니다.\n`,
|
|
1157
|
+
"판단 근거가 없어서가 아니라 답을 그대로 쓸 수 없어서입니다.\n",
|
|
1158
|
+
"\n"
|
|
1159
|
+
];
|
|
1160
|
+
for (const reason of renderDiscardReasons(discarded)) parts.push(` 제외 사유 ${reason}\n`);
|
|
1161
|
+
parts.push("\n", ...renderDiscardActions(discarded));
|
|
1162
|
+
return parts.join("");
|
|
1163
|
+
}
|
|
1164
|
+
/** 케이스 머리줄. 툴이 없는 케이스(listTools)는 괄호를 안 찍는다. */
|
|
1165
|
+
function caseHeading(caseId, tool) {
|
|
1166
|
+
const id = escapeTerminalText$1(caseId);
|
|
1167
|
+
return tool === void 0 ? `${id}\n` : `${id} (${escapeTerminalText$1(tool)})\n`;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* 결과 화면(설계서 §6.2·§6.4·§6.5).
|
|
1171
|
+
*
|
|
1172
|
+
* 문안은 CLI 가 소유한다. AI 는 필드만 채운다. 라벨·들여쓰기·순서·경계 문장은 전부 우리 것이다.
|
|
1173
|
+
* 케이스 순서는 **번들 순서**다. AI 응답 순서로 정렬하지 않는다. 같은 실행을 두 번 볼 때 화면이
|
|
1174
|
+
* 달라지면 안 된다.
|
|
1175
|
+
*/
|
|
1176
|
+
function renderRepairResult(options) {
|
|
1177
|
+
const parts = [
|
|
1178
|
+
`── 서버 수정 방향 (${options.providerId} / ${escapeTerminalText$1(options.model)}) ──\n`,
|
|
1179
|
+
"\n",
|
|
1180
|
+
renderApprovalNotice(options.bundle.spec.approval)
|
|
1181
|
+
];
|
|
1182
|
+
if (options.result.status === "unsure") {
|
|
1183
|
+
/**
|
|
1184
|
+
* `unsure` 는 두 가지 다른 일이다. 하나로 뭉치면 화면이 거짓말을 한다.
|
|
1185
|
+
*
|
|
1186
|
+
* `discarded` 합계가 0 이면 provider 가 실제로 판단을 못 한 것이고, 그때만 "근거가 부족" 이
|
|
1187
|
+
* 사실이다. 합계가 0 보다 크면 **답은 왔는데 우리가 경계 밖이라 버린 것**이다.
|
|
1188
|
+
* 그 경로에 "근거가 부족" 을 찍으면 사용자는 번들에 정보를 더 담아야 한다고 읽는다.
|
|
1189
|
+
* 실제로 그 화면을 보고 "구현이 실패한 것 아니냐" 는 질문이 나왔다.
|
|
1190
|
+
*/
|
|
1191
|
+
if (discardedTotal(options.result.discarded) > 0) parts.push(renderAllDiscarded(options.result.discarded));
|
|
1192
|
+
else parts.push("판단 근거가 부족해 원인 후보를 제시하지 못했습니다.\n");
|
|
1193
|
+
if (options.result.shortfall !== "") {
|
|
1194
|
+
const [first, ...rest] = escapeTerminalText$1(clampDisplay(options.result.shortfall, 500)).split("\n");
|
|
1195
|
+
parts.push("\n", ` → ${first}\n`);
|
|
1196
|
+
for (const line of rest) parts.push(` ${line}\n`);
|
|
1197
|
+
}
|
|
1198
|
+
} else {
|
|
1199
|
+
const byCase = /* @__PURE__ */ new Map();
|
|
1200
|
+
for (const cause of options.result.causes) {
|
|
1201
|
+
const list = byCase.get(cause.caseId) ?? [];
|
|
1202
|
+
list.push(cause);
|
|
1203
|
+
byCase.set(cause.caseId, list);
|
|
1204
|
+
}
|
|
1205
|
+
let first = true;
|
|
1206
|
+
for (const failure of options.bundle.failures) {
|
|
1207
|
+
const causes = byCase.get(failure.caseId);
|
|
1208
|
+
if (causes === void 0) continue;
|
|
1209
|
+
for (const cause of causes) {
|
|
1210
|
+
if (!first) parts.push("\n");
|
|
1211
|
+
first = false;
|
|
1212
|
+
parts.push(caseHeading(failure.caseId, failure.tool));
|
|
1213
|
+
parts.push(` 원인 후보 ${escapeTerminalText$1(cause.summary)}\n`);
|
|
1214
|
+
if (cause.location !== "") parts.push(` 확인할 곳 ${escapeTerminalText$1(cause.location)}\n`);
|
|
1215
|
+
if (cause.evidence !== "") parts.push(` 근거 ${escapeTerminalText$1(cause.evidence)}\n`);
|
|
1216
|
+
if (cause.target === "spec" && options.bundle.spec.approval !== "matched") parts.push(" 분류 명세 쪽 원인으로 봄\n");
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (discardedTotal(options.result.discarded) > 0 && options.result.status !== "unsure") {
|
|
1221
|
+
parts.push("\n");
|
|
1222
|
+
for (const reason of renderDiscardReasons(options.result.discarded)) parts.push(`※ ${reason}이 검증에서 제외됐습니다.\n`);
|
|
1223
|
+
parts.push("\n", ...renderDiscardActions(options.result.discarded));
|
|
1224
|
+
}
|
|
1225
|
+
parts.push("\n", REPAIR_BOUNDARY_LINES);
|
|
1226
|
+
return parts.join("");
|
|
1227
|
+
}
|
|
1228
|
+
/** provider 실패 안내. 닫힌 enum 만 화면에 오고 raw stdout·stderr 는 오지 않는다. */
|
|
1229
|
+
function renderRepairProviderFailure(failure) {
|
|
1230
|
+
const reason = failure.reason === void 0 ? "" : ` (${failure.reason})`;
|
|
1231
|
+
return `오류 [REPAIR_PROVIDER_FAILED]: ${failure.providerId} 에게 진단을 받지 못했습니다. 코드: ${failure.code}${reason}\n해결: \`${failure.providerId} --version\` 으로 설치와 인증을 확인한 뒤 다시 실행하세요. 파일은 하나도 바뀌지 않았습니다.\n`;
|
|
1232
|
+
}
|
|
1233
|
+
/** 응답이 계약을 못 지킨 경우. 우리가 버렸다는 사실을 말한다. */
|
|
1234
|
+
const REPAIR_INVALID_RESULT_LINE = "오류 [REPAIR_RESULT_INVALID]: provider 응답이 진단 형식을 지키지 않아 버렸습니다.\n해결: 같은 번들로 다시 실행하거나 다른 `--model` 로 시도하세요. 파일은 하나도 바뀌지 않았습니다.\n";
|
|
1235
|
+
/**
|
|
1236
|
+
* 응답이 `maxResultBytes` 를 넘어 거절된 경우. 형식이 틀린 것과 다르다. 답은 형식을 지켰지만
|
|
1237
|
+
* 우리가 정한 상한 밖이라 받지 않은 것이고, 사용자가 할 일도 다르다. 같은 문장을 쓰면
|
|
1238
|
+
* "다른 모델로 시도하세요" 라는 엉뚱한 안내를 하게 된다.
|
|
1239
|
+
*/
|
|
1240
|
+
const REPAIR_RESULT_LIMIT_LINE = "오류 [REPAIR_RESULT_LIMIT_EXCEEDED]: provider 응답이 허용 크기를 넘어 받지 않았습니다.\n해결: `--max-cases` 로 한 번에 묻는 실패 수를 줄여 다시 실행하세요. 파일은 하나도 바뀌지 않았습니다.\n";
|
|
1241
|
+
/** 승인 검사에 걸린 경우. 사용자가 본 것과 나가는 것이 달라졌다는 뜻이다. */
|
|
1242
|
+
const REPAIR_APPROVAL_INVALIDATED_LINE = "오류 [REPAIR_APPROVAL_INVALIDATED]: 확인한 내용과 보내려는 내용이 달라 전송을 멈췄습니다.\n해결: 같은 번들로 다시 실행하세요. 파일은 하나도 바뀌지 않았습니다.\n";
|
|
1243
|
+
//#endregion
|
|
1244
|
+
//#region src/repair-target.ts
|
|
1245
|
+
/** `renderReport` 가 케이스 본문 줄에 쓰는 들여쓰기. dry-run.ts 의 INDENT 와 같은 값이다. */
|
|
1246
|
+
const INDENT = " ";
|
|
1247
|
+
/** 단언 진단의 위반 줄 표시. 이 줄에만 서버가 돌려준 값이 실려 있다. */
|
|
1248
|
+
const VIOLATION_MARK = "→ ";
|
|
1249
|
+
/** 실패 사유 판정에 쓰는 단언 타입. 이 줄이 있어야 "정상 응답을 기대했는데 오류" 다. */
|
|
1250
|
+
const IS_ERROR = "isError";
|
|
1251
|
+
/** 케이스 본문 줄에서 들여쓰기를 벗긴다. 들여쓰기가 없는 줄은 본문이 아니다. */
|
|
1252
|
+
const bodyLines = (detail) => detail.split("\n").filter((line) => line.startsWith(INDENT)).map((line) => line.slice(4));
|
|
1253
|
+
/**
|
|
1254
|
+
* 실패 사유가 `isError` 단언인가. 단언 줄은 타입 이름으로 시작하고, 진단 문장과 해결 줄은
|
|
1255
|
+
* 그렇지 않다. 통과한 단언은 애초에 그려지지 않으므로 존재 자체가 실패의 근거다.
|
|
1256
|
+
*/
|
|
1257
|
+
const failedByIsError = (detail) => bodyLines(detail).some((line) => line.startsWith(IS_ERROR));
|
|
1258
|
+
/**
|
|
1259
|
+
* 서버가 돌려준 오류 본문을 뽑는다. 문장을 새로 만들지 않고 위반 줄을 그대로 옮긴다.
|
|
1260
|
+
* `isError` 진단에는 값이 실리지 않으므로 근거가 되는 것은 본문 단언의 위반 줄뿐이다.
|
|
1261
|
+
* 뽑을 것이 없으면 빈 문자열이고, 그때 호출 측은 사람 입력으로 간다(§4.4).
|
|
1262
|
+
*/
|
|
1263
|
+
const serverMessageOf = (detail) => bodyLines(detail).filter((line) => line.startsWith(VIOLATION_MARK)).map((line) => line.slice(2)).join("\n");
|
|
1264
|
+
/**
|
|
1265
|
+
* 위반 케이스인가. ADR-0022 가 만드는 케이스는 입력을 일부러 어긋나게 만든 것이라
|
|
1266
|
+
* 고치면 케이스의 목적이 사라진다. 판별은 `isError` 단언의 `expected` 로 한다.
|
|
1267
|
+
*/
|
|
1268
|
+
const expectsError = (spec) => spec.assertions.some((assertion) => assertion.type === "isError" && assertion.expected === true);
|
|
1269
|
+
const toTarget = (spec, outcome, origins) => {
|
|
1270
|
+
if (outcome.status === "passed") return void 0;
|
|
1271
|
+
if (spec.operation.type !== "callTool") return void 0;
|
|
1272
|
+
if (Object.keys(spec.operation.input).length === 0) return void 0;
|
|
1273
|
+
if (origins.get(outcome.caseId) === "user") return void 0;
|
|
1274
|
+
if (expectsError(spec)) return void 0;
|
|
1275
|
+
if (!failedByIsError(outcome.detail)) return void 0;
|
|
1276
|
+
return {
|
|
1277
|
+
caseId: outcome.caseId,
|
|
1278
|
+
caseName: outcome.caseName,
|
|
1279
|
+
tool: spec.operation.tool,
|
|
1280
|
+
input: spec.operation.input,
|
|
1281
|
+
serverMessage: serverMessageOf(outcome.detail)
|
|
1282
|
+
};
|
|
1283
|
+
};
|
|
1284
|
+
/**
|
|
1285
|
+
* 교정 대상을 고른다. 반환 배열은 `outcomes` 순서다. 정렬하지 않는다.
|
|
1286
|
+
* 순서를 바꾸면 화면 번호가 앞선 결과 화면과 어긋난다.
|
|
1287
|
+
*/
|
|
1288
|
+
function selectRepairTargets(options) {
|
|
1289
|
+
const specs = new Map(options.suite.cases.map((spec) => [spec.id, spec]));
|
|
1290
|
+
const targets = [];
|
|
1291
|
+
for (const outcome of options.outcomes) {
|
|
1292
|
+
const spec = specs.get(outcome.caseId);
|
|
1293
|
+
if (spec === void 0) continue;
|
|
1294
|
+
const target = toTarget(spec, outcome, options.origins);
|
|
1295
|
+
if (target !== void 0) targets.push(target);
|
|
1296
|
+
}
|
|
1297
|
+
return targets;
|
|
1298
|
+
}
|
|
1299
|
+
//#endregion
|
|
1300
|
+
//#region src/reset-hook.ts
|
|
1301
|
+
/**
|
|
1302
|
+
* 시험 실행 직전에 서버 상태를 되돌리는 초기화 명령을 실행한다. ADR-0023 의 결정을 구현한다.
|
|
1303
|
+
*
|
|
1304
|
+
* 셸을 거치지 않는다. `;`·`&&`·백틱은 해석되지 않고 그대로 인자가 된다. 실행 파일을 감싼
|
|
1305
|
+
* 큰따옴표만 경계로 인식하며 인자의 따옴표는 해석하지 않는다. 공백이 든 인자가 필요하면
|
|
1306
|
+
* 사용자가 스크립트 파일을 만들어야 한다. 이 제약은 도움말에 적힌 사양이지 구현 편의가 아니다.
|
|
1307
|
+
*/
|
|
1308
|
+
/** 초기화 명령의 제한 시간. 설계 문서 §6. */
|
|
1309
|
+
const TIMEOUT_MS = 6e4;
|
|
1310
|
+
/** 보관하는 stderr 상한. 실패 안내에 쓰는 꼬리만 있으면 되고 그 이상은 화면을 덮는다. */
|
|
1311
|
+
const MAX_STDERR_BYTES = 8 * 1024;
|
|
1312
|
+
/** 제한 시간 초과일 때 `stderr` 에 넣는 문장. 종료 코드가 없으므로 사유가 여기에만 남는다. */
|
|
1313
|
+
const TIMEOUT_STDERR = `타임아웃(60초)`;
|
|
1314
|
+
/**
|
|
1315
|
+
* 초기화 명령이 정상 종료하지 못한 경우. 시험 실행을 시작하지 않고 저장도 하지 않는 사유가
|
|
1316
|
+
* 되므로, 호출 측이 화면에 쓸 수 있도록 명령·종료 코드·stderr 을 그대로 들고 다닌다.
|
|
1317
|
+
*
|
|
1318
|
+
* `exitCode` 가 `null` 인 경우는 셋이다. 실행 파일이 없거나(ENOENT), 시그널로 죽었거나,
|
|
1319
|
+
* 제한 시간을 넘겨 우리가 죽였을 때다.
|
|
1320
|
+
*/
|
|
1321
|
+
var ResetCommandError = class extends Error {
|
|
1322
|
+
command;
|
|
1323
|
+
exitCode;
|
|
1324
|
+
stderr;
|
|
1325
|
+
constructor(command, exitCode, stderr) {
|
|
1326
|
+
super(`초기화 명령이 실패했습니다: ${command}`);
|
|
1327
|
+
this.command = command;
|
|
1328
|
+
this.exitCode = exitCode;
|
|
1329
|
+
this.stderr = stderr;
|
|
1330
|
+
this.name = "ResetCommandError";
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
/**
|
|
1334
|
+
* stderr 의 **마지막** 상한만큼을 모은다. 앞부분을 버리는 것이 요점이다. 화면에 쓰는 것은
|
|
1335
|
+
* 마지막 3줄이고, 실패 원인은 출력의 끝에 있다. 앞에서 잘라 보관하면 긴 출력을 내는 명령에서
|
|
1336
|
+
* 정작 사유가 담긴 마지막 줄이 사라진다.
|
|
1337
|
+
*/
|
|
1338
|
+
var StderrTail = class {
|
|
1339
|
+
chunks = [];
|
|
1340
|
+
size = 0;
|
|
1341
|
+
push(chunk) {
|
|
1342
|
+
this.chunks.push(chunk);
|
|
1343
|
+
this.size += chunk.byteLength;
|
|
1344
|
+
while (this.size > MAX_STDERR_BYTES) {
|
|
1345
|
+
const first = this.chunks[0];
|
|
1346
|
+
if (first === void 0) break;
|
|
1347
|
+
const excess = this.size - MAX_STDERR_BYTES;
|
|
1348
|
+
if (first.byteLength <= excess) {
|
|
1349
|
+
this.chunks.shift();
|
|
1350
|
+
this.size -= first.byteLength;
|
|
1351
|
+
} else {
|
|
1352
|
+
this.chunks[0] = first.subarray(excess);
|
|
1353
|
+
this.size -= excess;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
text() {
|
|
1358
|
+
return Buffer.concat(this.chunks).toString("utf8");
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
/**
|
|
1362
|
+
* 초기화 명령을 한 번 실행한다. 재시도하지 않는다.
|
|
1363
|
+
*
|
|
1364
|
+
* stdout 은 아예 받지 않는다. 성공 경로에서 남의 명령 출력을 우리 화면에 섞지 않기 위함이고,
|
|
1365
|
+
* 버퍼 상한에 걸려 멀쩡한 명령이 죽는 일도 없앤다.
|
|
1366
|
+
*
|
|
1367
|
+
* 빈 문자열이나 공백뿐인 명령은 `ResetCommandError` 가 아니라 `TypeError` 다. 옵션 파싱에서
|
|
1368
|
+
* 이미 걸러야 하는 값이고, 여기까지 왔다면 사용자 입력 문제가 아니라 호출 측 결함이다.
|
|
1369
|
+
*/
|
|
1370
|
+
async function runResetCommand(command) {
|
|
1371
|
+
let tokens;
|
|
1372
|
+
try {
|
|
1373
|
+
tokens = (0, _mcpeak_core.tokenizeCommand)(command);
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
if (!(error instanceof _mcpeak_core.CommandParseError)) throw error;
|
|
1376
|
+
const reason = {
|
|
1377
|
+
EMPTY_EXECUTABLE: "실행 파일 경로가 비어 있습니다.",
|
|
1378
|
+
UNTERMINATED_EXECUTABLE_QUOTE: "실행 파일을 감싼 큰따옴표가 닫히지 않았습니다.",
|
|
1379
|
+
MISSING_EXECUTABLE_ARGUMENT_SEPARATOR: "실행 파일을 감싼 큰따옴표 뒤에는 공백이 필요합니다."
|
|
1380
|
+
}[error.code];
|
|
1381
|
+
throw new ResetCommandError(command, null, `잘못된 초기화 명령: ${reason}`);
|
|
1382
|
+
}
|
|
1383
|
+
const [file, ...args] = tokens;
|
|
1384
|
+
if (file === void 0) throw new TypeError("초기화 명령이 비어 있습니다.");
|
|
1385
|
+
return new Promise((resolve, reject) => {
|
|
1386
|
+
const stderr = new StderrTail();
|
|
1387
|
+
let timedOut = false;
|
|
1388
|
+
let settled = false;
|
|
1389
|
+
const child = (0, node_child_process.spawn)(file, args, {
|
|
1390
|
+
stdio: [
|
|
1391
|
+
"ignore",
|
|
1392
|
+
"ignore",
|
|
1393
|
+
"pipe"
|
|
1394
|
+
],
|
|
1395
|
+
shell: false
|
|
1396
|
+
});
|
|
1397
|
+
const timer = setTimeout(() => {
|
|
1398
|
+
timedOut = true;
|
|
1399
|
+
child.kill("SIGKILL");
|
|
1400
|
+
}, TIMEOUT_MS);
|
|
1401
|
+
const finish = (settle) => {
|
|
1402
|
+
if (settled) return;
|
|
1403
|
+
settled = true;
|
|
1404
|
+
clearTimeout(timer);
|
|
1405
|
+
settle();
|
|
1406
|
+
};
|
|
1407
|
+
child.stderr?.on("data", (chunk) => {
|
|
1408
|
+
stderr.push(chunk);
|
|
1409
|
+
});
|
|
1410
|
+
child.on("error", (error) => {
|
|
1411
|
+
finish(() => {
|
|
1412
|
+
reject(new ResetCommandError(command, null, error.message));
|
|
1413
|
+
});
|
|
1414
|
+
});
|
|
1415
|
+
child.on("close", (code, signal) => {
|
|
1416
|
+
finish(() => {
|
|
1417
|
+
if (timedOut) {
|
|
1418
|
+
reject(new ResetCommandError(command, null, TIMEOUT_STDERR));
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
if (code === 0) {
|
|
1422
|
+
resolve();
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
reject(new ResetCommandError(command, code, stderr.text() || describeSignal(signal)));
|
|
1426
|
+
});
|
|
1427
|
+
});
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
/** stderr 이 비어 있는데 시그널로 죽었으면 그 사실만이라도 남긴다. */
|
|
1431
|
+
const describeSignal = (signal) => signal === null ? "" : `시그널 ${signal} 로 종료되었습니다.`;
|
|
1432
|
+
//#endregion
|
|
1433
|
+
//#region src/generate-command.ts
|
|
1434
|
+
var UsageError$1 = class extends Error {};
|
|
1435
|
+
/**
|
|
1436
|
+
* 출력 경로에 이미 파일이 있어 저장을 멈춘 경우. 다른 I/O 실패와 사용자 조치가 다르므로
|
|
1437
|
+
* 타입으로 갈라 둔다. 뭉뚱그리면 "저장하지 못했습니다"만 남아 어떤 파일이 왜 막았는지 모른다.
|
|
1438
|
+
*/
|
|
1439
|
+
var OutputExistsError = class extends Error {
|
|
1440
|
+
path;
|
|
1441
|
+
constructor(path) {
|
|
1442
|
+
super("output exists");
|
|
1443
|
+
this.path = path;
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
/**
|
|
1447
|
+
* 출력 파일 충돌 안내. 경로는 라벨 뒤에 두어 조사가 변수에 붙지 않게 한다.
|
|
1448
|
+
*
|
|
1449
|
+
* 두 자리에서 같은 코드로 끊는다. 다른 것은 사용자가 무엇을 잃었는지뿐이라 그 한 마디만
|
|
1450
|
+
* 갈라 적는다. 선검사는 아직 아무것도 안 했고, 저장 단계는 검토와 시험 실행을 이미 지났다.
|
|
1451
|
+
* 설계 문서 §6.
|
|
1452
|
+
*/
|
|
1453
|
+
function outputExistsFailure(deps, path, stage = "save") {
|
|
1454
|
+
const lost = stage === "start" ? "시작하지" : "저장하지";
|
|
1455
|
+
deps.writeStderr(`오류 [GENERATE_OUTPUT_EXISTS]: 출력 파일이 이미 있어 ${lost} 않았습니다. 경로: ${path}\n해결: 다른 \`--out\` 경로를 지정하거나, 기존 파일을 덮어쓰려면 \`--force\` 를 붙이세요.\n`);
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* `--force` 인데 기존 출력 파일을 지우지 못한 경우. `ENOENT` 는 원하는 상태와 같으므로
|
|
1459
|
+
* 여기 오지 않는다. `GENERATE_FAILED` 로 뭉뚱그리면 사용자가 무엇을 확인해야 할지 모른다.
|
|
1460
|
+
*/
|
|
1461
|
+
var OutputReplaceError = class extends Error {
|
|
1462
|
+
path;
|
|
1463
|
+
code;
|
|
1464
|
+
constructor(path, code) {
|
|
1465
|
+
super("output replace failed");
|
|
1466
|
+
this.path = path;
|
|
1467
|
+
this.code = code;
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
/**
|
|
1471
|
+
* 기존 출력 파일 삭제 실패 안내. 원인을 단정하지 않는다. `--out` 이 디렉터리인지 권한
|
|
1472
|
+
* 문제인지 여기서 구분할 수단이 없고, 그것을 알려고 `stat` 을 주입하지 않는다. 설계 문서 §6.
|
|
1473
|
+
*/
|
|
1474
|
+
function outputReplaceFailure(deps, path, code) {
|
|
1475
|
+
const suffix = code === void 0 ? "" : ` (${code})`;
|
|
1476
|
+
deps.writeStderr(`오류 [GENERATE_OUTPUT_REPLACE_FAILED]: 기존 출력 파일을 지우지 못해 저장하지 않았습니다. 경로: ${path}${suffix}\n해결: 그 경로가 디렉터리이거나 쓰기 권한이 없는지 확인하세요. 다른 \`--out\` 경로를 지정해도 됩니다.\n`);
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* `generate` 가 이미 만들어 둔 원인을 그대로 보여 준다.
|
|
1480
|
+
*
|
|
1481
|
+
* `GENERATE_FAILED` 로 뭉개면 사용자는 "MCP 서버와 출력 경로를 확인하세요" 를 보는데,
|
|
1482
|
+
* 스키마 거절에서 그건 **틀린 조치다** — 서버도 경로도 멀쩡하다. 실제로 이 결함 때문에
|
|
1483
|
+
* 원인을 알아내려고 `generate` 소스를 읽어야 했다 (#136 · `docs/adoption.md` §2.3).
|
|
1484
|
+
*/
|
|
1485
|
+
function generateTestsFailure(deps, error) {
|
|
1486
|
+
deps.writeStderr(`오류 [${error.code}]: ${error.message} 경로: ${error.path}\n해결: ${error.hint}\n`);
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* core 의 McpClientError 인지 구조로 확인한다. core 를 import 하지 않는다.
|
|
1490
|
+
* `test-command.ts` 의 `coreError` 와 같은 판별인데, 그쪽은 AggregateError 안까지 내려가고
|
|
1491
|
+
* 진단을 함께 꺼낸다. 여기 연결 오류는 core 가 직접 던지므로 겉모양 검사로 충분하다.
|
|
1492
|
+
*/
|
|
1493
|
+
function isCoreClientError(value) {
|
|
1494
|
+
return typeof value === "object" && value !== null && "name" in value && value.name === "McpClientError" && "code" in value && typeof value.code === "string" && "message" in value && typeof value.message === "string" && "hint" in value && typeof value.hint === "string";
|
|
1495
|
+
}
|
|
1496
|
+
var LinkUnsupportedError = class extends Error {
|
|
1497
|
+
path;
|
|
1498
|
+
code;
|
|
1499
|
+
constructor(path, code) {
|
|
1500
|
+
super("link unsupported");
|
|
1501
|
+
this.path = path;
|
|
1502
|
+
this.code = code;
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
/**
|
|
1506
|
+
* hard link 불가 안내. `code`는 위 두 값만 들어오는 닫힌 집합이라 그대로 보여준다.
|
|
1507
|
+
* errno 이름은 사용자가 검색할 때 쓰는 단서이고 raw stderr나 인증정보가 아니다.
|
|
1508
|
+
* 임의의 오류 문자열을 흘려보내지 않으려고 타입으로 좁혀 뒀다.
|
|
1509
|
+
*/
|
|
1510
|
+
function linkUnsupportedFailure(deps, path, code) {
|
|
1511
|
+
deps.writeStderr(`오류 [GENERATE_LINK_UNSUPPORTED]: 출력 디렉터리가 hard link를 지원하지 않거나 권한이 없어 저장하지 못했습니다. 경로: ${path} (원인: ${code})\n해결: 로컬 디스크의 다른 디렉터리를 \`--out\`으로 지정한 뒤 다시 저장하세요. 네트워크 마운트(NFS·SMB 일부), FAT/exFAT USB, 컨테이너 바인드 마운트에서 주로 납니다.\n`);
|
|
1512
|
+
}
|
|
1513
|
+
/** 검토 도중 입력 스트림이 닫혔음을 알리는 sentinel. 사용자 취소와 같은 경로로 처리한다. */
|
|
1514
|
+
var ReviewInputClosedError = class extends Error {};
|
|
1515
|
+
/**
|
|
1516
|
+
* 입력 스트림이 닫혀 발생한 오류인지 판정한다.
|
|
1517
|
+
* sentinel 외에 Node readline의 ERR_USE_AFTER_CLOSE도 인정한다. nodeReviewIO가 아닌 ReviewIO
|
|
1518
|
+
* 구현이 readline을 직접 감싸도 같은 종료 경로를 타게 하기 위함이다.
|
|
1519
|
+
*/
|
|
1520
|
+
function isReviewInputClosed(error) {
|
|
1521
|
+
if (error instanceof ReviewInputClosedError) return true;
|
|
1522
|
+
return error?.code === "ERR_USE_AFTER_CLOSE";
|
|
1523
|
+
}
|
|
1524
|
+
const optionNames$1 = /* @__PURE__ */ new Set([
|
|
1525
|
+
"--suite-id",
|
|
1526
|
+
"--name",
|
|
1527
|
+
"--out",
|
|
1528
|
+
"--command",
|
|
1529
|
+
"--arg",
|
|
1530
|
+
"--baseline-only",
|
|
1531
|
+
"--provider",
|
|
1532
|
+
"--model",
|
|
1533
|
+
"--no-dry-run",
|
|
1534
|
+
"--cassette",
|
|
1535
|
+
"--record",
|
|
1536
|
+
"--reset-cmd",
|
|
1537
|
+
"--no-repair",
|
|
1538
|
+
"--force"
|
|
1539
|
+
]);
|
|
1540
|
+
/** 값을 받지 않는 옵션. `=` 를 붙여 쓸 수 없고 두 번 쓸 수 없다. */
|
|
1541
|
+
const flagNames$1 = /* @__PURE__ */ new Set([
|
|
1542
|
+
"--baseline-only",
|
|
1543
|
+
"--no-dry-run",
|
|
1544
|
+
"--record",
|
|
1545
|
+
"--no-repair",
|
|
1546
|
+
"--force"
|
|
1547
|
+
]);
|
|
1548
|
+
function optionValue$1(argv, index, option) {
|
|
1549
|
+
const item = argv[index];
|
|
1550
|
+
if (item === void 0) throw new UsageError$1(`\`${option}\` 옵션 값이 필요합니다.`);
|
|
1551
|
+
const equals = item.indexOf("=");
|
|
1552
|
+
if (equals > 0) return [item.slice(equals + 1), index];
|
|
1553
|
+
const value = argv[index + 1];
|
|
1554
|
+
if (value === void 0 || option !== "--arg" && value.startsWith("--")) throw new UsageError$1(`\`${option}\` 옵션 값이 필요합니다.`);
|
|
1555
|
+
return [value, index + 1];
|
|
1556
|
+
}
|
|
1557
|
+
function parseGenerateCommand(argv) {
|
|
1558
|
+
const values = /* @__PURE__ */ new Map();
|
|
1559
|
+
const args = [];
|
|
1560
|
+
const flags = /* @__PURE__ */ new Set();
|
|
1561
|
+
for (let index = 0; index < argv.length; index++) {
|
|
1562
|
+
const item = argv[index];
|
|
1563
|
+
if (item === void 0) continue;
|
|
1564
|
+
const option = item.includes("=") ? item.slice(0, item.indexOf("=")) : item;
|
|
1565
|
+
if (!option.startsWith("--")) throw new UsageError$1(`추가 위치 인자 '${item}'는 허용되지 않습니다.`);
|
|
1566
|
+
if (!optionNames$1.has(option)) throw new UsageError$1(`지원하지 않는 generate 옵션 '${option}'입니다.`);
|
|
1567
|
+
if (flagNames$1.has(option)) {
|
|
1568
|
+
if (item !== option || flags.has(option)) throw new UsageError$1(`\`${option}\`는 한 번만 사용할 수 있습니다.`);
|
|
1569
|
+
flags.add(option);
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
const [value, consumed] = optionValue$1(argv, index, option);
|
|
1573
|
+
index = consumed;
|
|
1574
|
+
if (option === "--arg") {
|
|
1575
|
+
args.push(value);
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
if (values.has(option)) throw new UsageError$1(`\`${option}\`는 한 번만 사용할 수 있습니다.`);
|
|
1579
|
+
values.set(option, value);
|
|
1580
|
+
}
|
|
1581
|
+
for (const option of [
|
|
1582
|
+
"--suite-id",
|
|
1583
|
+
"--name",
|
|
1584
|
+
"--out",
|
|
1585
|
+
"--command"
|
|
1586
|
+
]) if (values.get(option) === void 0) throw new UsageError$1(`\`${option}\` 옵션이 필요합니다.`);
|
|
1587
|
+
const outPath = values.get("--out");
|
|
1588
|
+
if (!outPath.toLowerCase().endsWith(".json")) throw new UsageError$1("`--out`은 .json 파일이어야 합니다.");
|
|
1589
|
+
const rawProvider = values.get("--provider");
|
|
1590
|
+
if (rawProvider !== void 0 && rawProvider !== "codex" && rawProvider !== "claude") throw new UsageError$1("`--provider`는 codex 또는 claude여야 합니다.");
|
|
1591
|
+
if (values.has("--model") && rawProvider === void 0) throw new UsageError$1("`--model`은 `--provider`와 함께만 사용할 수 있습니다.");
|
|
1592
|
+
const dryRun = !flags.has("--no-dry-run");
|
|
1593
|
+
const repair = !flags.has("--no-repair");
|
|
1594
|
+
if (!dryRun && !repair) throw new UsageError$1("`--no-dry-run`과 `--no-repair`는 함께 사용할 수 없습니다.");
|
|
1595
|
+
const cassettePath = values.get("--cassette");
|
|
1596
|
+
const resetCmd = values.get("--reset-cmd");
|
|
1597
|
+
if (!dryRun && cassettePath !== void 0) throw new UsageError$1("`--no-dry-run`과 `--cassette`는 함께 사용할 수 없습니다.");
|
|
1598
|
+
if (!dryRun && resetCmd !== void 0) throw new UsageError$1("`--no-dry-run`과 `--reset-cmd`는 함께 사용할 수 없습니다.");
|
|
1599
|
+
if (flags.has("--record") && cassettePath === void 0) throw new UsageError$1("`--record`는 `--cassette`와 함께만 사용할 수 있습니다.");
|
|
1600
|
+
if (resetCmd !== void 0 && resetCmd.trim() === "") throw new UsageError$1("`--reset-cmd` 옵션 값이 필요합니다.");
|
|
1601
|
+
return Object.freeze({
|
|
1602
|
+
suiteId: values.get("--suite-id"),
|
|
1603
|
+
name: values.get("--name"),
|
|
1604
|
+
outPath,
|
|
1605
|
+
command: values.get("--command"),
|
|
1606
|
+
args: Object.freeze(args),
|
|
1607
|
+
baselineOnly: flags.has("--baseline-only"),
|
|
1608
|
+
provider: rawProvider,
|
|
1609
|
+
model: values.get("--model"),
|
|
1610
|
+
dryRun,
|
|
1611
|
+
cassettePath,
|
|
1612
|
+
forceRecord: flags.has("--record"),
|
|
1613
|
+
force: flags.has("--force"),
|
|
1614
|
+
resetCmd,
|
|
1615
|
+
repair
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* 저장 키 순서는 설계 문서 §3.3이 고정한다. `approval`을 `cases` 앞에 두는 이유는 사람이
|
|
1620
|
+
* 파일을 열었을 때 첫 화면에서 보이게 하기 위해서다. 지문 계산은 `canonicalJson`이 키를
|
|
1621
|
+
* 정렬하므로 이 순서에 영향받지 않는다. 즉 가독성 결정이지 계약이 아니다.
|
|
1622
|
+
*/
|
|
1623
|
+
function renderSuite(suite, fingerprint, cases) {
|
|
1624
|
+
const ordered = {
|
|
1625
|
+
schemaVersion: suite.schemaVersion,
|
|
1626
|
+
id: suite.id,
|
|
1627
|
+
name: suite.name,
|
|
1628
|
+
approval: cases.length === 0 ? { fingerprint } : {
|
|
1629
|
+
fingerprint,
|
|
1630
|
+
cases
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
if (suite.defaultTimeoutMs !== void 0) ordered.defaultTimeoutMs = suite.defaultTimeoutMs;
|
|
1634
|
+
ordered.cases = suite.cases;
|
|
1635
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
1636
|
+
}
|
|
1637
|
+
let temporarySequence = 0;
|
|
1638
|
+
/**
|
|
1639
|
+
* 임시 파일 이름은 실행마다 고유해야 한다. 고정 이름이면 같은 디렉터리에서 두 실행이 겹칠 때
|
|
1640
|
+
* `openTemp`의 `wx`가 EEXIST로 실패하는데, 그것은 출력 경로 충돌과 전혀 다른 실패다.
|
|
1641
|
+
* 저장되는 suite 내용에는 들어가지 않으므로 결정론성 요구와 무관하다.
|
|
1642
|
+
*/
|
|
1643
|
+
function temporaryPath(outPath) {
|
|
1644
|
+
temporarySequence += 1;
|
|
1645
|
+
return (0, node_path.join)((0, node_path.dirname)(outPath), `.${(0, node_path.basename)(outPath)}.mcpeak.${process.pid}.${temporarySequence}.tmp`);
|
|
1646
|
+
}
|
|
1647
|
+
async function saveSuite(input, suite, fingerprint, deps, approvals = []) {
|
|
1648
|
+
if (!input.force && await deps.exists(input.outPath)) throw new OutputExistsError(input.outPath);
|
|
1649
|
+
const temporary = temporaryPath(input.outPath);
|
|
1650
|
+
let created = false;
|
|
1651
|
+
try {
|
|
1652
|
+
const handle = await deps.openTemp(temporary);
|
|
1653
|
+
created = true;
|
|
1654
|
+
try {
|
|
1655
|
+
await handle.writeFile(renderSuite(suite, fingerprint, approvals), "utf8");
|
|
1656
|
+
await handle.sync();
|
|
1657
|
+
} finally {
|
|
1658
|
+
await handle.close();
|
|
1659
|
+
}
|
|
1660
|
+
const bytes = await deps.readFile(temporary);
|
|
1661
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1662
|
+
const validated = deps.validateSuite(parsed);
|
|
1663
|
+
if (!validated.valid || (0, _mcpeak_runner.suiteFingerprint)(validated.value) !== fingerprint || validated.value.approval?.fingerprint !== fingerprint) throw new Error("invalid saved suite");
|
|
1664
|
+
if (input.force) await deps.unlink(input.outPath).catch((error) => {
|
|
1665
|
+
const code = error?.code;
|
|
1666
|
+
if (code === "ENOENT") return;
|
|
1667
|
+
throw new OutputReplaceError(input.outPath, typeof code === "string" ? code : void 0);
|
|
1668
|
+
});
|
|
1669
|
+
try {
|
|
1670
|
+
await deps.link(temporary, input.outPath);
|
|
1671
|
+
} catch (error) {
|
|
1672
|
+
const code = error?.code;
|
|
1673
|
+
if (code === "EEXIST") throw new OutputExistsError(input.outPath);
|
|
1674
|
+
if (code === "EPERM" || code === "ENOTSUP") throw new LinkUnsupportedError(input.outPath, code);
|
|
1675
|
+
throw error;
|
|
1676
|
+
}
|
|
1677
|
+
} finally {
|
|
1678
|
+
if (created) await deps.unlink(temporary).catch(() => void 0);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
const defaultModel = (provider) => provider === "codex" ? "gpt-5.6-luna" : "haiku";
|
|
1682
|
+
function showRequest(io, preview) {
|
|
1683
|
+
io.write(`Provider: ${preview.providerId}\nModel: ${preview.model}\nPayload: ${preview.byteLength} bytes\nResult limit: ${preview.maxResultBytes} bytes\nTimeout: ${preview.providerTimeoutMs}ms\nFingerprint: ${preview.fingerprint}\n전송 데이터: 사용자 요청, baseline suite, current candidate, 툴 이름·설명·inputSchema\n`);
|
|
1684
|
+
}
|
|
1685
|
+
const MAX_DIFF_BODY_LINES = 40;
|
|
1686
|
+
/**
|
|
1687
|
+
* leaf 경로와 JSON 값을 문서 순서(Object.keys 순, 배열 인덱스 순)로 모은다.
|
|
1688
|
+
* 정렬하지 않는다. 같은 입력에 항상 같은 출력이 나와야 하고 문서 순서로 그것이 만족된다.
|
|
1689
|
+
* 빈 객체와 빈 배열은 그 자체를 leaf로 본다. 그러지 않으면 경로가 조용히 사라진다.
|
|
1690
|
+
*/
|
|
1691
|
+
function leaves(value, prefix = "") {
|
|
1692
|
+
if (Array.isArray(value)) {
|
|
1693
|
+
if (value.length === 0) return [[prefix, "[]"]];
|
|
1694
|
+
return value.flatMap((item, index) => leaves(item, `${prefix}[${index}]`));
|
|
1695
|
+
}
|
|
1696
|
+
if (value !== null && typeof value === "object") {
|
|
1697
|
+
const entries = Object.entries(value);
|
|
1698
|
+
if (entries.length === 0) return [[prefix, "{}"]];
|
|
1699
|
+
return entries.flatMap(([key, nested]) => leaves(nested, prefix === "" ? key : `${prefix}.${key}`));
|
|
1700
|
+
}
|
|
1701
|
+
return [[prefix, JSON.stringify(value) ?? "undefined"]];
|
|
1702
|
+
}
|
|
1703
|
+
/** before/after의 leaf를 비교해 다른 경로만 남긴다. 같은 경로의 -와 +는 붙여서 쓴다. */
|
|
1704
|
+
function changedLeaves(before, after) {
|
|
1705
|
+
const afterLeaves = leaves(after);
|
|
1706
|
+
const afterByPath = new Map(afterLeaves);
|
|
1707
|
+
const lines = [];
|
|
1708
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1709
|
+
for (const [path, value] of leaves(before)) {
|
|
1710
|
+
seen.add(path);
|
|
1711
|
+
if (!afterByPath.has(path)) {
|
|
1712
|
+
lines.push(`- ${path}: ${value}`);
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
const next = afterByPath.get(path);
|
|
1716
|
+
if (next === value) continue;
|
|
1717
|
+
lines.push(`- ${path}: ${value}`, `+ ${path}: ${next}`);
|
|
1718
|
+
}
|
|
1719
|
+
for (const [path, value] of afterLeaves) if (!seen.has(path)) lines.push(`+ ${path}: ${value}`);
|
|
1720
|
+
return lines;
|
|
1721
|
+
}
|
|
1722
|
+
function diffBody(change) {
|
|
1723
|
+
switch (change.type) {
|
|
1724
|
+
case "addCase": return leaves(change.case).map(([path, value]) => `+ ${path}: ${value}`);
|
|
1725
|
+
case "removeCase": return leaves(change.case).map(([path, value]) => `- ${path}: ${value}`);
|
|
1726
|
+
case "caseOrder": return [`- ${change.before.join(", ")}`, `+ ${change.after.join(", ")}`];
|
|
1727
|
+
default: return changedLeaves(change.before, change.after);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
function showDiff(io, preview) {
|
|
1731
|
+
if (preview.changes.length === 0) {
|
|
1732
|
+
io.write("AI가 제안한 변경이 없습니다.\n → 원하는 케이스를 `AI 요청:`에 구체적으로 적어 다시 물어보세요.\n → 지금 상태로 저장하려면 save를 고르세요.\n");
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
for (const change of preview.changes) {
|
|
1736
|
+
const body = diffBody(change);
|
|
1737
|
+
const shown = body.length > MAX_DIFF_BODY_LINES ? [...body.slice(0, MAX_DIFF_BODY_LINES), `... 이하 ${body.length - MAX_DIFF_BODY_LINES}줄 생략. 전체는 저장 후 JSON을 확인하세요.`] : body;
|
|
1738
|
+
io.write(`${change.id} ${change.type}${"caseId" in change ? ` ${change.caseId}` : ""}\n` + shown.map((line) => ` ${line}\n`).join(""));
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* 머리글 하나와 그 아래 케이스별 문장을 찍는다. finding 이 없으면 아무것도 안 찍는다.
|
|
1743
|
+
* `byCase` 순회는 `Map` 삽입 순서이고 그것이 곧 `runner` 가 정한 finding 순서다. 정렬하지
|
|
1744
|
+
* 않는다. 여기서 다시 정렬하면 같은 명세가 화면마다 다른 순서로 보인다.
|
|
1745
|
+
*/
|
|
1746
|
+
function writeFindingBlock(io, diff, heading, findings) {
|
|
1747
|
+
if (findings.length === 0) return;
|
|
1748
|
+
const byCase = /* @__PURE__ */ new Map();
|
|
1749
|
+
for (const finding of findings) {
|
|
1750
|
+
const list = byCase.get(finding.caseId) ?? [];
|
|
1751
|
+
list.push(finding);
|
|
1752
|
+
byCase.set(finding.caseId, list);
|
|
1753
|
+
}
|
|
1754
|
+
io.write(`${heading} ${findings.length}건 (선택한 변경 기준)\n`);
|
|
1755
|
+
for (const [caseId, list] of byCase) {
|
|
1756
|
+
const change = diff.changes.find((item) => "caseId" in item && item.caseId === caseId);
|
|
1757
|
+
io.write(` → ${change?.id ?? ""} ${caseId}\n`);
|
|
1758
|
+
for (const finding of list) io.write(` ${(0, _mcpeak_runner.describeSpecFinding)(finding)}\n`);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* 선택한 change 의 caseId 집합에 걸린 finding 만 뽑는다. 순서는 재정렬하지 않는다.
|
|
1763
|
+
* runner 가 정한 순서가 곧 사양이고, 여기서 다시 정렬하면 화면마다 순서가 갈린다.
|
|
1764
|
+
*
|
|
1765
|
+
* caseId 가 없는 change 종류(suiteMetadata · caseOrder)는 집합에 아무것도 넣지 않는다.
|
|
1766
|
+
* 위반 케이스를 선택에서 뺐으면 경고할 이유가 없다.
|
|
1767
|
+
*/
|
|
1768
|
+
function findingsForSelection(candidate, preview, selectedChangeIds) {
|
|
1769
|
+
const ids = new Set(selectedChangeIds);
|
|
1770
|
+
const caseIds = new Set(preview.changes.filter((change) => ids.has(change.id) && "caseId" in change).map((change) => change.caseId));
|
|
1771
|
+
return [...candidate.specFindings.inputContract.findings, ...candidate.specFindings.assertionSubstance.findings].filter((finding) => caseIds.has(finding.caseId));
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* 선택한 변경에 걸린 위반을 두 블록으로 갈라 찍고, 위반이 있으면 재확인을 하나 더 받는다.
|
|
1775
|
+
* 반환값이 false 면 적용을 멈추고 메뉴로 돌아간다.
|
|
1776
|
+
*
|
|
1777
|
+
* 두 검사를 한 머리글 아래 합치지 않는다. `VACUOUS_MIN_LENGTH` 는 입력 문제가 아니라 단언
|
|
1778
|
+
* 문제인데 `입력 계약 위반` 아래 붙으면 읽는 사람이 입력을 고치러 간다. 화면에 찍히는 문장이
|
|
1779
|
+
* 곧 제품이므로 어디를 고쳐야 하는지가 머리글에서 갈려야 한다.
|
|
1780
|
+
*
|
|
1781
|
+
* 재확인은 하나만 받는다. 개수는 두 종류의 합이다. 종류마다 확인을 받으면 화면만 길어지고
|
|
1782
|
+
* 사용자가 내리는 판단은 여전히 "그래도 적용할까" 하나다.
|
|
1783
|
+
*
|
|
1784
|
+
* 거부하지 않고 재확인만 받는다. 서버가 inputSchema 를 느슨하게 선언하면 정상 명세도
|
|
1785
|
+
* UNDECLARED_FIELD 로 걸리므로, 거부하면 옳은 명세를 저장할 길이 막힌다.
|
|
1786
|
+
*/
|
|
1787
|
+
async function confirmSpecFindings(io, candidate, diff, selected) {
|
|
1788
|
+
const findings = findingsForSelection(candidate, diff, selected);
|
|
1789
|
+
const grouped = (group) => findings.filter((finding) => FINDING_GROUP[finding.code] === group);
|
|
1790
|
+
const inputContract = grouped("inputContract");
|
|
1791
|
+
const assertionSubstance = grouped("assertionSubstance");
|
|
1792
|
+
const rejectionIntent = grouped("rejectionIntent");
|
|
1793
|
+
const skipped = grouped("skipped").length;
|
|
1794
|
+
writeFindingBlock(io, diff, "입력 계약 위반", inputContract);
|
|
1795
|
+
writeFindingBlock(io, diff, "항상 통과하는 단언", assertionSubstance);
|
|
1796
|
+
writeFindingBlock(io, diff, "거절 근거가 불분명한 케이스", rejectionIntent);
|
|
1797
|
+
if (skipped > 0) io.write(` → 해석하지 못한 서버 스키마 ${skipped}건은 검사에서 빠졌습니다.\n`);
|
|
1798
|
+
const total = inputContract.length + assertionSubstance.length;
|
|
1799
|
+
if (total === 0) return true;
|
|
1800
|
+
return io.confirm(`위반 ${total}건이 남아 있습니다. 그래도 적용합니까?`);
|
|
1801
|
+
}
|
|
1802
|
+
function safeFailure(deps, code) {
|
|
1803
|
+
deps.writeStderr(`오류 [GENERATE_${code}]: AI 검토 요청을 완료하지 못했습니다.\n해결: 입력과 provider 상태를 확인한 뒤 메뉴에서 다시 요청하세요.\n`);
|
|
1804
|
+
}
|
|
1805
|
+
/** provider별 로그인 확인 명령. 두 provider의 명령을 함께 찍으면 사용자가 헛수고한다. */
|
|
1806
|
+
const authCommand = (provider) => provider === "codex" ? "codex login status" : "claude /status";
|
|
1807
|
+
/**
|
|
1808
|
+
* nonZeroExit의 reason별 안내. reason은 nonZeroExit 경로에서만 채워지는 닫힌 enum이며,
|
|
1809
|
+
* 값이 없으면 원인을 모르는 것이므로 추측하지 않고 확인할 것을 모두 알려준다.
|
|
1810
|
+
*
|
|
1811
|
+
* 문구 규칙: 변수 바로 뒤에 조사를 붙이지 않는다. 한국어 조사는 앞말의 받침에 따라 형태가
|
|
1812
|
+
* 갈리는데(을/를, 으로/로) 모델 이름과 종료 코드는 어떤 값이 올지 모른다. 어느 쪽으로 고정해도
|
|
1813
|
+
* 반드시 틀리는 경우가 생기므로 `모델: {model}`처럼 라벨을 붙이거나 `명령으로`처럼 고정된
|
|
1814
|
+
* 명사를 끼워 조사가 앞말에 의존하지 않게 한다.
|
|
1815
|
+
*/
|
|
1816
|
+
function exitMessage(failure, model) {
|
|
1817
|
+
const id = failure.providerId;
|
|
1818
|
+
const fallback = defaultModel(id);
|
|
1819
|
+
switch (failure.reason) {
|
|
1820
|
+
case "unknownModel": return `오류 [GENERATE_PROVIDER_MODEL]: ${id}가 이 모델을 사용할 수 없습니다. 모델: ${model}\n해결: 모델 이름을 확인하세요. 이 계정에서 쓸 수 없는 모델일 수도 있습니다. ${id} 기본값은 ${fallback}입니다.\n`;
|
|
1821
|
+
case "notAuthenticated": return `오류 [GENERATE_PROVIDER_AUTH]: ${id} 인증이 유효하지 않습니다.\n해결: \`${authCommand(id)}\` 명령으로 로그인 상태를 확인한 뒤 다시 요청하세요.\n`;
|
|
1822
|
+
case "rateLimited": return `오류 [GENERATE_PROVIDER_RATE_LIMIT]: ${id}가 요청 한도를 초과했습니다.\n해결: 잠시 뒤 다시 요청하세요. 반복되면 도구 수를 줄여 payload를 줄이세요.\n`;
|
|
1823
|
+
case "badRequest": return `오류 [GENERATE_PROVIDER_REQUEST]: ${id}가 요청을 거절했습니다. 모델: ${model}\n해결: 두 가지를 확인하세요.\n 1. 모델 이름이 이 계정에서 쓸 수 있는지. ${id} 기본값은 ${fallback}입니다.\n 2. provider가 전송 schema를 받아들이는지. 반복되면 다른 provider로 시도하세요.\n`;
|
|
1824
|
+
case "serverError": return `오류 [GENERATE_PROVIDER_SERVER]: ${id} 쪽 서버 오류입니다.\n해결: 잠시 뒤 다시 요청하세요. 계속되면 provider 상태 페이지를 확인하세요.\n`;
|
|
1825
|
+
default: return `오류 [GENERATE_PROVIDER_EXIT]: ${id}가 종료했습니다. ${failure.exitCode === void 0 ? "" : `종료 코드: ${failure.exitCode}, `}모델: ${model}\n해결: \`${authCommand(id)}\` 명령으로 로그인 상태를 확인하고, 모델 이름이 맞는지 확인하세요.\n`;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* provider 실패를 원인별 안내로 분기한다. 사용자 조치가 다르므로 문구를 나눈다.
|
|
1830
|
+
* 먼저 failure.code로 갈리고, nonZeroExit은 exitMessage에서 reason으로 한 겹 더 갈린다.
|
|
1831
|
+
* failure를 알 수 없는 경로(dispatch 자체가 throw)는 기존 GENERATE_PROVIDER_FAILED를 유지한다.
|
|
1832
|
+
*/
|
|
1833
|
+
function providerFailure(deps, failure, model) {
|
|
1834
|
+
if (!failure) {
|
|
1835
|
+
safeFailure(deps, "PROVIDER_FAILED");
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
const id = failure.providerId;
|
|
1839
|
+
const message = (() => {
|
|
1840
|
+
switch (failure.code) {
|
|
1841
|
+
case "providerUnavailable": return `오류 [GENERATE_PROVIDER_UNAVAILABLE]: ${id} CLI를 실행할 수 없습니다.\n해결: \`${id} --version\` 명령으로 설치와 PATH를 확인한 뒤 다시 요청하세요.\n`;
|
|
1842
|
+
case "nonZeroExit": return exitMessage(failure, model);
|
|
1843
|
+
case "timedOut": return `오류 [GENERATE_PROVIDER_TIMEOUT]: ${id} 응답이 ${failure.timeoutMs}ms 안에 오지 않았습니다.\n해결: 도구 수를 줄이거나 timeout을 늘려 다시 요청하세요.\n`;
|
|
1844
|
+
case "schemaMismatch": return `오류 [GENERATE_PROVIDER_SCHEMA]: ${id}가 요구한 형식과 다른 결과를 돌려줬습니다.\n해결: 다시 요청하세요. 반복되면 다른 provider로 바꿔 시도하세요.\n`;
|
|
1845
|
+
case "cancelled": return "오류 [GENERATE_PROVIDER_CANCELLED]: AI 검토 요청이 취소됐습니다.\n해결: 메뉴에서 다시 요청하세요.\n";
|
|
1846
|
+
default: return;
|
|
1847
|
+
}
|
|
1848
|
+
})();
|
|
1849
|
+
if (message === void 0) safeFailure(deps, "PROVIDER_FAILED");
|
|
1850
|
+
else deps.writeStderr(message);
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* 시험 실행 화면이 쓰는 서버 stderr 줄 수. `test` 의 `--stderr-lines` 기본값과 같은 값이다.
|
|
1854
|
+
* `generate` 에는 그 옵션이 없으므로 기본값 하나만 둔다.
|
|
1855
|
+
*/
|
|
1856
|
+
const DRY_RUN_STDERR_LINES = 20;
|
|
1857
|
+
/**
|
|
1858
|
+
* 실패 케이스 머리글. 결과 화면(§8.2)과 분류 화면(dry-run-review.ts, §8.3)이 **같은 모양**이어야
|
|
1859
|
+
* 한다. 두 화면이 같은 케이스를 다르게 부르면 사용자가 둘을 대조하지 못한다.
|
|
1860
|
+
*/
|
|
1861
|
+
const failureHeading = (index, caseName) => ` [${index + 1}] ${caseName}\n`;
|
|
1862
|
+
/**
|
|
1863
|
+
* `renderReport` 가 만든 케이스 블록을 이 화면 들여쓰기로 옮긴다. 두 칸을 앞에 붙이기만 하고
|
|
1864
|
+
* 문장은 건드리지 않는다. dry-run-review.ts 의 같은 처리와 규칙이 같다.
|
|
1865
|
+
*/
|
|
1866
|
+
const detailBlock = (detail) => detail === "" ? "" : `${detail.split("\n").map((line) => ` ${line}`).join("\n")}\n`;
|
|
1867
|
+
/** 시험 실행 고지(§8.1). 카세트와 초기화는 값이 있을 때만 줄이 나간다. */
|
|
1868
|
+
function writeDryRunNotice(io, notice) {
|
|
1869
|
+
io.write(`시험 실행: 케이스 ${notice.caseCount}개를 실제 서버에 보냅니다.\n`);
|
|
1870
|
+
io.write(` 대상: ${notice.target}\n`);
|
|
1871
|
+
if (notice.cassette !== void 0) io.write(` 카세트: ${notice.cassette.path} (${notice.cassette.fresh ? "신규 녹화" : "재생"})\n`);
|
|
1872
|
+
if (notice.resetCmd !== void 0) io.write(` 초기화: ${notice.resetCmd}\n`);
|
|
1873
|
+
if (notice.repair) io.write(" 실패한 케이스는 값을 고쳐 최대 2회까지 다시 호출합니다.\n");
|
|
1874
|
+
io.write("\n이 실행은 서버 상태를 바꿀 수 있습니다. 입력 검증이 없는 서버라면 외부 API 호출도\n그대로 나갑니다.\n");
|
|
1875
|
+
}
|
|
1876
|
+
/** 시험 실행 결과(§8.2). 0건인 종류는 찍지 않는다. */
|
|
1877
|
+
function writeDryRunResult(io, result) {
|
|
1878
|
+
const failures = result.outcomes.filter((outcome) => outcome.status !== "passed");
|
|
1879
|
+
const passed = result.outcomes.length - failures.length;
|
|
1880
|
+
io.write("\n");
|
|
1881
|
+
if (passed > 0) io.write(` ✓ 통과 ${passed}건\n`);
|
|
1882
|
+
if (failures.length > 0) io.write(` ✗ 실패 ${failures.length}건\n`);
|
|
1883
|
+
for (const [index, outcome] of failures.entries()) {
|
|
1884
|
+
io.write("\n");
|
|
1885
|
+
io.write(failureHeading(index, outcome.caseName));
|
|
1886
|
+
io.write(detailBlock(outcome.detail));
|
|
1887
|
+
}
|
|
1888
|
+
if (failures.length > 0) io.write("\n");
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* 거절 근거 미확인 목록 (#89 · 설계 문서 §5.2). 시험 실행 결과 블록 바로 아래에 붙는다.
|
|
1892
|
+
*
|
|
1893
|
+
* **이 케이스들은 통과했다.** 목록은 판정도 저장 여부도 바꾸지 않는다. `unverified` 는
|
|
1894
|
+
* "거절이 아니다" 가 아니라 "확인하지 못했다" 는 뜻이라, 문장이 실패나 결함이라고 말하지 않고
|
|
1895
|
+
* 무엇을 확인하지 못했는지만 적는다. 0건이면 아무것도 안 찍는다.
|
|
1896
|
+
*
|
|
1897
|
+
* 응답 본문은 `escapeTerminalText` 를 그대로 쓴다. 그 함수가 개행(0x0a)까지 이스케이프하므로
|
|
1898
|
+
* 여러 줄 응답이 한 줄이 되고 제어 문자도 함께 무해해진다. 자르기는 `runner` 가 이미 진단 값과
|
|
1899
|
+
* 같은 상한에서 했다(`clampObservedText`). 여기서 규칙을 새로 만들지 않는다.
|
|
1900
|
+
*/
|
|
1901
|
+
function writeRejectionUnverified(io, result) {
|
|
1902
|
+
const unverified = result.outcomes.filter((outcome) => outcome.rejectionBasis === "unverified");
|
|
1903
|
+
if (unverified.length === 0) return;
|
|
1904
|
+
const ids = unverified.map((outcome) => escapeTerminalText$1(outcome.caseId));
|
|
1905
|
+
const column = Math.max(...ids.map((id) => Array.from(id).length));
|
|
1906
|
+
io.write(`\n거절 근거 미확인 ${unverified.length}건\n`);
|
|
1907
|
+
for (const [index, outcome] of unverified.entries()) {
|
|
1908
|
+
const id = ids[index] ?? "";
|
|
1909
|
+
const pad = " ".repeat(Math.max(0, column - Array.from(id).length));
|
|
1910
|
+
const body = outcome.rejectionBody === void 0 ? "(본문 없음)" : escapeTerminalText$1(outcome.rejectionBody);
|
|
1911
|
+
io.write(` → ${id}${pad} 응답: ${body}\n`);
|
|
1912
|
+
}
|
|
1913
|
+
io.write(" 이 응답이 서버의 정상 거절인지 내부 오류인지 확인하지 못했습니다.\n\n");
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* `verdict` 를 화면 문구로 옮긴다. `Record` 라서 `RejectionVerdict` 가 늘면 여기서 타입 오류가
|
|
1917
|
+
* 난다. 문자열 배열로 두면 새 값이 조용히 빠지고, 이 화면에서 누락은 "판단이 없었다" 로 읽힌다.
|
|
1918
|
+
*/
|
|
1919
|
+
const VERDICT_LABEL = {
|
|
1920
|
+
rejected: "거절로 보임",
|
|
1921
|
+
crashed: "서버 내부 오류로 보임",
|
|
1922
|
+
unsure: "판단 불가"
|
|
1923
|
+
};
|
|
1924
|
+
/**
|
|
1925
|
+
* AI 진단 결과를 찍는다. 문안은 설계 문서 §6.4 가 고정한다.
|
|
1926
|
+
*
|
|
1927
|
+
* **마지막 줄을 빼면 안 된다.** AI 답변이 판정으로 읽히면 사용자가 초록·빨강을 잘못 해석한다.
|
|
1928
|
+
*/
|
|
1929
|
+
function writeRejectionDiagnosis(io, requested, results) {
|
|
1930
|
+
io.write(`\n거절 근거 미확인 ${requested}건에 대해 AI 진단을 요청했습니다.\n\n`);
|
|
1931
|
+
const ids = results.map((item) => escapeTerminalText$1(item.caseId));
|
|
1932
|
+
const column = Math.max(0, ...ids.map((id) => Array.from(id).length));
|
|
1933
|
+
for (const [index, item] of results.entries()) {
|
|
1934
|
+
const id = ids[index] ?? "";
|
|
1935
|
+
const pad = " ".repeat(Math.max(0, column - Array.from(id).length));
|
|
1936
|
+
io.write(` ${id}${pad} ${VERDICT_LABEL[item.verdict]}\n`);
|
|
1937
|
+
io.write(` → ${escapeTerminalText$1(item.reason)}\n`);
|
|
1938
|
+
}
|
|
1939
|
+
io.write("\n이 진단은 참고입니다. 케이스 판정과 저장 여부를 바꾸지 않습니다.\n\n");
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* 확인 못 한 케이스를 AI 에게 물을지 사람에게 묻고, 답을 받으면 화면에 찍는다 (#89 · §6).
|
|
1943
|
+
*
|
|
1944
|
+
* 아무것도 안 하고 조용히 지나가는 경우가 셋이다. provider 가 없을 때(대다수 사용자),
|
|
1945
|
+
* 확인 못 한 케이스가 없을 때, 물어볼 본문이 하나도 없을 때다. 그때는 질문 자체를 안 한다.
|
|
1946
|
+
*
|
|
1947
|
+
* **본문이 없는 케이스는 진단에서 뺀다.** 호출이 오류로 끝나 응답이 아예 없는 케이스가 있는데
|
|
1948
|
+
* (설계 문서 §4.2), 그것을 빈 문자열로 채워 물으면 AI 에게 판단 재료가 없고 지어낸 `verdict`
|
|
1949
|
+
* 만 돌아온다. 뺀 사실은 화면에 남긴다 — 몇 건을 왜 못 물었는지가 사용자의 판단 재료다.
|
|
1950
|
+
*
|
|
1951
|
+
* 실패는 흐름을 끊지 않는다. 안내만 찍고 승인 화면이 이어진다. 진단은 참고이지 전제가 아니다.
|
|
1952
|
+
*/
|
|
1953
|
+
async function askRejectionDiagnosis(options) {
|
|
1954
|
+
const { io, deps, provider } = options;
|
|
1955
|
+
const prepare = deps.prepareRejectionDiagnosisRequests;
|
|
1956
|
+
const dispatch = deps.dispatchRejectionDiagnosis;
|
|
1957
|
+
if (provider === void 0 || prepare === void 0 || dispatch === void 0) return;
|
|
1958
|
+
const unverified = options.result.outcomes.filter((outcome) => outcome.rejectionBasis === "unverified");
|
|
1959
|
+
if (unverified.length === 0) return;
|
|
1960
|
+
const cases = unverified.flatMap((outcome) => {
|
|
1961
|
+
if (outcome.rejectionBody === void 0) return [];
|
|
1962
|
+
const spec = options.suite.cases.find((item) => item.id === outcome.caseId);
|
|
1963
|
+
if (spec === void 0 || !isCallTool(spec)) return [];
|
|
1964
|
+
const schema = options.tools.find((tool) => tool.name === spec.operation.tool)?.inputSchema;
|
|
1965
|
+
return [{
|
|
1966
|
+
caseId: outcome.caseId,
|
|
1967
|
+
tool: spec.operation.tool,
|
|
1968
|
+
input: spec.operation.input,
|
|
1969
|
+
inputSchema: typeof schema === "object" && schema !== null && !Array.isArray(schema) ? schema : {},
|
|
1970
|
+
responseBody: outcome.rejectionBody,
|
|
1971
|
+
basis: "unverified"
|
|
1972
|
+
}];
|
|
1973
|
+
});
|
|
1974
|
+
const skipped = unverified.length - cases.length;
|
|
1975
|
+
if (cases.length === 0) {
|
|
1976
|
+
io.write(` 응답 본문이 없어 ${skipped}건 전부를 AI 에게 물을 수 없습니다. 진단을 건너뜁니다.\n\n`);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
if (skipped > 0) io.write(` 응답 본문이 없는 ${skipped}건은 진단에서 제외합니다. AI 에게 줄 근거가 없습니다.\n`);
|
|
1980
|
+
io.write(" ※ 응답 본문은 서버가 자유롭게 쓰는 텍스트라 경로·토큰·데이터가 섞일 수 있습니다.\n 값 치환을 적용하지 않습니다.\n");
|
|
1981
|
+
if (!await io.confirm(` 나머지 ${cases.length}건의 진단을 AI 에게 요청할까요?`)) return;
|
|
1982
|
+
let requests;
|
|
1983
|
+
try {
|
|
1984
|
+
requests = prepare({ cases });
|
|
1985
|
+
} catch (error) {
|
|
1986
|
+
if (!(error instanceof RangeError)) throw error;
|
|
1987
|
+
io.write(` 진단 요청이 크기 상한(256KB)을 넘어 보내지 못했습니다. 미확인 ${cases.length}건의 입력 스키마와 응답을 합친 크기입니다.\n 해결: 툴 수가 적은 명세로 나눠 생성한 뒤 다시 시도하세요. 케이스 판정과 저장에는 영향이 없습니다.
|
|
1988
|
+
|
|
1989
|
+
`);
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
const dispatched = await dispatch({
|
|
1993
|
+
provider,
|
|
1994
|
+
requests
|
|
1995
|
+
});
|
|
1996
|
+
if (dispatched.type === "failed") {
|
|
1997
|
+
providerFailure(deps, dispatched.failure, options.model);
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
writeRejectionDiagnosis(io, cases.length, dispatched.results);
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* 반영 요약(§8.8). 저장 확인 직전에 찍는다. 교정이 0건이면 아무것도 찍지 않는다.
|
|
2004
|
+
* 지문이 바뀐 이유가 화면에 남아야 사용자가 나중에 diff 를 보고 놀라지 않는다(§5.4).
|
|
2005
|
+
*/
|
|
2006
|
+
function writeRepairSummary(io, repairedCases, changes) {
|
|
2007
|
+
if (repairedCases === 0) return;
|
|
2008
|
+
io.write(` 입력값 교정 ${repairedCases}건이 명세에 반영되었습니다.\n`);
|
|
2009
|
+
for (const change of changes) io.write(` ${change.tool}.${change.field}: ${JSON.stringify(change.before)} → ${JSON.stringify(change.after)}\n`);
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* 교정 대상 판별에 넘길 provenance. `approvedDraft.provenance` 가 유일한 출처다.
|
|
2013
|
+
* 여기서 비면 사용자가 손으로 쓴 케이스까지 교정 대상이 되므로(§4.2 의 기본값이
|
|
2014
|
+
* `schemaBaseline` 이다) 세션이 실제로 싣는 값을 그대로 쓴다.
|
|
2015
|
+
*/
|
|
2016
|
+
const originsOf = (session) => new Map(session.approvedDraft.provenance.map((item) => [item.caseId, item.origin]));
|
|
2017
|
+
/**
|
|
2018
|
+
* 입력값을 가진 케이스인가. 중첩 필드로는 판별 유니온이 좁혀지지 않아 술어로 뽑는다.
|
|
2019
|
+
* 교정은 `callTool` 케이스에만 있다(§4.2).
|
|
2020
|
+
*/
|
|
2021
|
+
const isCallTool = (spec) => spec.operation.type === "callTool";
|
|
2022
|
+
/** 실제로 값이 바뀐 필드만 §8.8 줄로 만든다. 안 바뀐 필드까지 적으면 요약이 사실과 달라진다. */
|
|
2023
|
+
const repairApplications = (suite, repaired) => {
|
|
2024
|
+
const changes = [];
|
|
2025
|
+
for (const item of suite.cases) {
|
|
2026
|
+
const input = repaired.get(item.id);
|
|
2027
|
+
if (input === void 0 || !isCallTool(item)) continue;
|
|
2028
|
+
for (const [field, after] of Object.entries(input)) {
|
|
2029
|
+
const before = item.operation.input[field];
|
|
2030
|
+
if (JSON.stringify(before) === JSON.stringify(after)) continue;
|
|
2031
|
+
changes.push({
|
|
2032
|
+
tool: item.operation.tool,
|
|
2033
|
+
field,
|
|
2034
|
+
before,
|
|
2035
|
+
after
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
return changes;
|
|
2040
|
+
};
|
|
2041
|
+
/** 교정으로 통과한 값을 케이스에 얹은 후보 명세. `cli` 는 이 객체를 만들기만 하고 반영은 §5 의 3단 경로가 한다. */
|
|
2042
|
+
const withRepairedInputs = (suite, repaired) => ({
|
|
2043
|
+
...suite,
|
|
2044
|
+
cases: suite.cases.map((item) => {
|
|
2045
|
+
const input = repaired.get(item.id);
|
|
2046
|
+
if (input === void 0 || !isCallTool(item)) return item;
|
|
2047
|
+
return {
|
|
2048
|
+
...item,
|
|
2049
|
+
operation: {
|
|
2050
|
+
...item.operation,
|
|
2051
|
+
input
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
})
|
|
2055
|
+
});
|
|
2056
|
+
/**
|
|
2057
|
+
* 시험 실행이 끝까지 못 간 경우(§8.4). stderr 블록은 단계 1 의 `renderProcessDiagnostics` 를
|
|
2058
|
+
* 그대로 쓴다. 여기서 새 렌더러를 만들지 않는다.
|
|
2059
|
+
*
|
|
2060
|
+
* `outcomes.length` 가 곧 "몇 번째에서 끊겼는가" 다. dry-run.ts 가 끊긴 케이스까지 담아 주므로
|
|
2061
|
+
* 여기서 더하거나 빼지 않는다.
|
|
2062
|
+
*/
|
|
2063
|
+
function writeDryRunAborted(io, result, totalCases, diagnostics) {
|
|
2064
|
+
const aborted = result.aborted;
|
|
2065
|
+
if (aborted === void 0) return;
|
|
2066
|
+
const progress = `${result.outcomes.length}/${totalCases}`;
|
|
2067
|
+
io.write(aborted.reason === "connectionLost" ? `✗ 시험 실행을 마치지 못했습니다. ${progress} 케이스에서 연결이 끊겼습니다.\n` : aborted.reason === "stopped" ? `✗ 시험 실행을 마치지 못했습니다. ${progress} 케이스에서 멈췄습니다.\n` : "✗ 시험 실행을 마치지 못했습니다.\n");
|
|
2068
|
+
io.write(` → ${aborted.detail}\n`);
|
|
2069
|
+
if (diagnostics !== void 0 && hasDiagnosticContent(diagnostics)) {
|
|
2070
|
+
const block = renderProcessDiagnostics(diagnostics, { maxLines: DRY_RUN_STDERR_LINES });
|
|
2071
|
+
if (block !== "") io.write(`\n${block}`);
|
|
2072
|
+
}
|
|
2073
|
+
io.write("\n저장하지 않았습니다. 서버를 고친 뒤 다시 save 를 고르세요.\n");
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* 분류가 저장을 막았을 때의 안내(§8.3). 마지막 줄은 **실제 카세트 모드**로 갈린다.
|
|
2077
|
+
*
|
|
2078
|
+
* 재생은 `auto` 에서만 일어난다. 신규 녹화(`record`)는 회차마다 전량을 다시 서버로 보내므로
|
|
2079
|
+
* "나머지는 카세트에서 재생됩니다" 가 거짓이 된다. 그래서 경로 유무가 아니라 `wireCassette`
|
|
2080
|
+
* 가 정한 모드를 받는다.
|
|
2081
|
+
*/
|
|
2082
|
+
function writeReviewBlocked(io, specErrors, caseCount, cassetteMode) {
|
|
2083
|
+
io.write("\n");
|
|
2084
|
+
if (specErrors > 0) {
|
|
2085
|
+
io.write(` 명세 오류 ${specErrors}건이 있어 저장할 수 없습니다.\n`);
|
|
2086
|
+
io.write(" → 검토 메뉴의 revise 또는 edit 으로 고친 뒤 다시 save 를 고르세요.\n");
|
|
2087
|
+
} else io.write(" 분류하지 않은 케이스가 있어 저장할 수 없습니다.\n");
|
|
2088
|
+
if (cassetteMode === "auto") {
|
|
2089
|
+
io.write(" → 고친 케이스만 서버에 다시 나갑니다. 나머지는 카세트에서 재생됩니다.\n");
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
io.write(` → 다시 save 를 고르면 케이스 ${caseCount}개가 모두 서버에 다시 나갑니다.\n`);
|
|
2093
|
+
io.write(cassetteMode === void 0 ? " 반복 비용이 부담되면 --cassette 를 쓰세요.\n" : " 신규 녹화 중이라 이번 세션에서는 재생하지 않습니다.\n");
|
|
2094
|
+
}
|
|
2095
|
+
/** 초기화 명령 실패 안내. stderr 은 마지막 3줄만 보여준다. 설계 문서 §6. */
|
|
2096
|
+
function writeResetFailure(io, error) {
|
|
2097
|
+
const exit = error.exitCode === null ? "없음" : String(error.exitCode);
|
|
2098
|
+
io.write(`✗ 초기화 명령이 실패했습니다. 명령: ${error.command} (종료 코드: ${exit})\n`);
|
|
2099
|
+
for (const line of error.stderr.split("\n").filter(Boolean).slice(-3)) io.write(` → ${line}\n`);
|
|
2100
|
+
io.write("저장하지 않았습니다. 초기화 명령을 고친 뒤 다시 save 를 고르세요.\n");
|
|
2101
|
+
}
|
|
2102
|
+
async function runInteractiveReview(input, tools, session, deps, connection, skippedTools = []) {
|
|
2103
|
+
const io = deps.reviewIO;
|
|
2104
|
+
const prepare = deps.prepareAuthoringRequest;
|
|
2105
|
+
const dispatch = deps.dispatchAuthoringRequest;
|
|
2106
|
+
const makeDiff = deps.createAuthoringDiff;
|
|
2107
|
+
const apply = deps.applyAuthoringChanges;
|
|
2108
|
+
const reviewLocal = deps.reviewLocalAuthoringCandidate;
|
|
2109
|
+
if (!io?.interactive || !prepare || !dispatch || !makeDiff || !apply || !reviewLocal) {
|
|
2110
|
+
safeFailure(deps, "INTERACTIVE_REQUIRED");
|
|
2111
|
+
return 1;
|
|
2112
|
+
}
|
|
2113
|
+
let candidate = session.workingCandidate;
|
|
2114
|
+
let preferred = input.provider;
|
|
2115
|
+
let model = input.model;
|
|
2116
|
+
/**
|
|
2117
|
+
* 카세트 배선은 검토 세션에 하나뿐이고 `save` 를 여러 번 골라도 같은 것을 쓴다. 시도마다 새로
|
|
2118
|
+
* 만들면 저장이 막힌 첫 시도의 녹화가 통째로 버려지고, "고친 케이스만 서버에 다시 나갑니다"
|
|
2119
|
+
* (§8.3)가 거짓이 된다. flush 는 저장에 성공한 뒤 한 번만 부른다.
|
|
2120
|
+
*/
|
|
2121
|
+
let cassette;
|
|
2122
|
+
/** 이미 화면에 낸 카세트 경고 수. 배선이 세션당 하나라 경고도 누적된다. 회차마다 새것만 낸다. */
|
|
2123
|
+
let printedWarnings = 0;
|
|
2124
|
+
/** 진단 읽기가 판정을 바꾸면 안 된다. getDiagnostics 가 던지면 삼킨다. */
|
|
2125
|
+
const diagnostics = () => {
|
|
2126
|
+
try {
|
|
2127
|
+
return connection.getDiagnostics();
|
|
2128
|
+
} catch {
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
};
|
|
2132
|
+
try {
|
|
2133
|
+
while (true) {
|
|
2134
|
+
const action = await io.choose("검토 메뉴", [
|
|
2135
|
+
"codex",
|
|
2136
|
+
"claude",
|
|
2137
|
+
"apply-all",
|
|
2138
|
+
"select",
|
|
2139
|
+
"revise",
|
|
2140
|
+
"edit",
|
|
2141
|
+
"save",
|
|
2142
|
+
"cancel"
|
|
2143
|
+
]);
|
|
2144
|
+
if (action === "cancel") return 0;
|
|
2145
|
+
if (action === "save") {
|
|
2146
|
+
const dryRunSuite = session.approvedDraft.suite;
|
|
2147
|
+
const caseCount = dryRunSuite.cases.length;
|
|
2148
|
+
let approvals = [];
|
|
2149
|
+
/** 교정으로 통과한 케이스 수와 바뀐 값. 반영 요약(§8.8)이 쓴다. */
|
|
2150
|
+
let repairedCases = 0;
|
|
2151
|
+
let repairChanges = [];
|
|
2152
|
+
if (!input.dryRun) {
|
|
2153
|
+
io.write(`⚠ 시험 실행을 건너뜁니다. 케이스 ${caseCount}건이 실제 서버에서 확인되지 않은 채 저장됩니다.\n 저장된 명세에 승인 기록(approval.cases)이 남지 않습니다.
|
|
2154
|
+
`);
|
|
2155
|
+
if (!await io.confirm(" 계속할까요?")) continue;
|
|
2156
|
+
} else {
|
|
2157
|
+
const path = input.cassettePath;
|
|
2158
|
+
writeDryRunNotice(io, {
|
|
2159
|
+
caseCount,
|
|
2160
|
+
target: [input.command, ...input.args].join(" "),
|
|
2161
|
+
...path === void 0 ? {} : { cassette: {
|
|
2162
|
+
path,
|
|
2163
|
+
fresh: cassette === void 0 ? input.forceRecord || !await deps.exists(path) : cassette.mode === "record"
|
|
2164
|
+
} },
|
|
2165
|
+
resetCmd: input.resetCmd,
|
|
2166
|
+
repair: input.repair
|
|
2167
|
+
});
|
|
2168
|
+
if (!await io.confirm("계속할까요?")) continue;
|
|
2169
|
+
if (input.resetCmd !== void 0) {
|
|
2170
|
+
try {
|
|
2171
|
+
await runResetCommand(input.resetCmd);
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
if (!(error instanceof ResetCommandError)) throw error;
|
|
2174
|
+
writeResetFailure(io, error);
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2177
|
+
io.write(`▸ 초기화: ${input.resetCmd}\n`);
|
|
2178
|
+
}
|
|
2179
|
+
if (cassette === void 0) cassette = await wireCassette({
|
|
2180
|
+
inner: connection.client,
|
|
2181
|
+
path: input.cassettePath,
|
|
2182
|
+
forceRecord: input.forceRecord,
|
|
2183
|
+
io: deps.cassetteIo
|
|
2184
|
+
});
|
|
2185
|
+
io.write(`▸ 시험 실행 중... ${caseCount}/${caseCount}\n`);
|
|
2186
|
+
const result = await runDryRun({
|
|
2187
|
+
client: cassette.client,
|
|
2188
|
+
suite: dryRunSuite
|
|
2189
|
+
});
|
|
2190
|
+
for (const warning of cassette.warnings.slice(printedWarnings)) io.write(`${warning}\n`);
|
|
2191
|
+
printedWarnings = cassette.warnings.length;
|
|
2192
|
+
if (result.aborted !== void 0) {
|
|
2193
|
+
writeDryRunAborted(io, result, caseCount, diagnostics());
|
|
2194
|
+
continue;
|
|
2195
|
+
}
|
|
2196
|
+
writeDryRunResult(io, result);
|
|
2197
|
+
writeRejectionUnverified(io, result);
|
|
2198
|
+
await askRejectionDiagnosis({
|
|
2199
|
+
io,
|
|
2200
|
+
deps,
|
|
2201
|
+
result,
|
|
2202
|
+
suite: dryRunSuite,
|
|
2203
|
+
tools,
|
|
2204
|
+
provider: preferred === void 0 ? void 0 : deps.rejectionProviders?.[preferred]?.(model ?? defaultModel(preferred)),
|
|
2205
|
+
model: model ?? (preferred === void 0 ? "" : defaultModel(preferred))
|
|
2206
|
+
});
|
|
2207
|
+
const repairProvider = preferred === void 0 ? void 0 : deps.providers?.[preferred]?.(model ?? defaultModel(preferred));
|
|
2208
|
+
const wired = cassette;
|
|
2209
|
+
const targets = input.repair ? selectRepairTargets({
|
|
2210
|
+
suite: dryRunSuite,
|
|
2211
|
+
outcomes: result.outcomes,
|
|
2212
|
+
origins: originsOf(session)
|
|
2213
|
+
}) : [];
|
|
2214
|
+
let attempts;
|
|
2215
|
+
let effective = result;
|
|
2216
|
+
if (targets.length > 0) {
|
|
2217
|
+
const repairs = await repairInputs({
|
|
2218
|
+
io,
|
|
2219
|
+
suite: dryRunSuite,
|
|
2220
|
+
targets,
|
|
2221
|
+
tools,
|
|
2222
|
+
rerun: async (caseId, value) => {
|
|
2223
|
+
const spec = dryRunSuite.cases.find((item) => item.id === caseId);
|
|
2224
|
+
if (spec === void 0 || !isCallTool(spec)) return {
|
|
2225
|
+
passed: false,
|
|
2226
|
+
detail: ""
|
|
2227
|
+
};
|
|
2228
|
+
const one = await runDryRun({
|
|
2229
|
+
client: wired.client,
|
|
2230
|
+
suite: {
|
|
2231
|
+
...dryRunSuite,
|
|
2232
|
+
cases: [{
|
|
2233
|
+
...spec,
|
|
2234
|
+
operation: {
|
|
2235
|
+
...spec.operation,
|
|
2236
|
+
input: value
|
|
2237
|
+
}
|
|
2238
|
+
}]
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
if (one.aborted !== void 0) return {
|
|
2242
|
+
passed: false,
|
|
2243
|
+
detail: ""
|
|
2244
|
+
};
|
|
2245
|
+
const outcome = one.outcomes[0];
|
|
2246
|
+
return {
|
|
2247
|
+
passed: outcome?.status === "passed",
|
|
2248
|
+
detail: outcome?.detail ?? ""
|
|
2249
|
+
};
|
|
2250
|
+
},
|
|
2251
|
+
propose: repairProvider === void 0 ? void 0 : (target) => proposeRepair({
|
|
2252
|
+
target,
|
|
2253
|
+
session,
|
|
2254
|
+
tools,
|
|
2255
|
+
provider: repairProvider,
|
|
2256
|
+
prepare,
|
|
2257
|
+
dispatch
|
|
2258
|
+
})
|
|
2259
|
+
});
|
|
2260
|
+
const repaired = new Map(repairs.filter((item) => item.repaired && item.input !== void 0).map((item) => [item.caseId, item.input]));
|
|
2261
|
+
attempts = new Map(repairs.filter((item) => !item.repaired && item.attempts.length > 0).map((item) => [item.caseId, item.attempts]));
|
|
2262
|
+
if (repaired.size > 0) {
|
|
2263
|
+
repairedCases = repaired.size;
|
|
2264
|
+
repairChanges = repairApplications(dryRunSuite, repaired);
|
|
2265
|
+
const local = reviewLocal({
|
|
2266
|
+
session,
|
|
2267
|
+
candidate: withRepairedInputs(dryRunSuite, repaired),
|
|
2268
|
+
tools
|
|
2269
|
+
});
|
|
2270
|
+
if (local.status !== "preview") {
|
|
2271
|
+
io.write("교정한 값을 명세에 반영하지 못했습니다.\n");
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
const diff = makeDiff({
|
|
2275
|
+
session,
|
|
2276
|
+
candidate: local.preview
|
|
2277
|
+
});
|
|
2278
|
+
if (!apply({
|
|
2279
|
+
session,
|
|
2280
|
+
preview: diff,
|
|
2281
|
+
selectedChangeIds: diff.changes.map((change) => change.id),
|
|
2282
|
+
approval: {
|
|
2283
|
+
approved: true,
|
|
2284
|
+
fingerprint: diff.candidateFingerprint
|
|
2285
|
+
}
|
|
2286
|
+
}).applied) {
|
|
2287
|
+
io.write("교정한 값을 명세에 반영하지 못했습니다.\n");
|
|
2288
|
+
continue;
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
effective = {
|
|
2292
|
+
...result,
|
|
2293
|
+
outcomes: result.outcomes.map((outcome) => repaired.has(outcome.caseId) ? {
|
|
2294
|
+
...outcome,
|
|
2295
|
+
status: "passed",
|
|
2296
|
+
detail: ""
|
|
2297
|
+
} : outcome)
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2300
|
+
for (const warning of cassette.warnings.slice(printedWarnings)) io.write(`${warning}\n`);
|
|
2301
|
+
printedWarnings = cassette.warnings.length;
|
|
2302
|
+
const review = await reviewDryRun(io, effective, attempts);
|
|
2303
|
+
if (!review.cleared) {
|
|
2304
|
+
writeReviewBlocked(io, review.specErrors.length, caseCount, cassette.mode);
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
approvals = review.approvals;
|
|
2308
|
+
}
|
|
2309
|
+
const fingerprint = session.approvedDraft.suiteFingerprint;
|
|
2310
|
+
io.write(`Final fingerprint: ${fingerprint}\n`);
|
|
2311
|
+
writeRepairSummary(io, repairedCases, repairChanges);
|
|
2312
|
+
if (!await io.confirm("최종 JSON을 저장할까요?")) continue;
|
|
2313
|
+
const final = deps.finalizeAuthoringDraft({
|
|
2314
|
+
session,
|
|
2315
|
+
approval: {
|
|
2316
|
+
approved: true,
|
|
2317
|
+
fingerprint
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
if (!final.finalized) {
|
|
2321
|
+
safeFailure(deps, "FINALIZE_FAILED");
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2324
|
+
try {
|
|
2325
|
+
const finalSuite = deps.getAuthoringExecutionSuite(final.snapshot);
|
|
2326
|
+
await saveSuite(input, finalSuite, final.snapshot.fingerprint, deps, approvals);
|
|
2327
|
+
let cassetteSaved = false;
|
|
2328
|
+
try {
|
|
2329
|
+
await cassette?.flush();
|
|
2330
|
+
cassetteSaved = true;
|
|
2331
|
+
} catch {
|
|
2332
|
+
io.write("⚠ 카세트를 저장하지 못했습니다. 명세는 저장됐습니다.\n");
|
|
2333
|
+
}
|
|
2334
|
+
if (cassetteSaved && cassette !== void 0) {
|
|
2335
|
+
for (const warning of cassette.warnings.slice(printedWarnings)) io.write(`${warning}\n`);
|
|
2336
|
+
printedWarnings = cassette.warnings.length;
|
|
2337
|
+
}
|
|
2338
|
+
const skippedIndexes = new Set(skippedTools.map((tool) => tool.index));
|
|
2339
|
+
reportCoverageSafely(deps, () => deps.computeCoverage?.({
|
|
2340
|
+
suite: finalSuite,
|
|
2341
|
+
tools: tools.filter((_tool, index) => !skippedIndexes.has(index))
|
|
2342
|
+
}), finalSuite, skippedTools);
|
|
2343
|
+
return 0;
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
if (error instanceof OutputExistsError) outputExistsFailure(deps, error.path);
|
|
2346
|
+
else if (error instanceof OutputReplaceError) outputReplaceFailure(deps, error.path, error.code);
|
|
2347
|
+
else if (error instanceof LinkUnsupportedError) linkUnsupportedFailure(deps, error.path, error.code);
|
|
2348
|
+
else safeFailure(deps, "SAVE_FAILED");
|
|
2349
|
+
continue;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
if (action === "apply-all" || action === "select") {
|
|
2353
|
+
if (!candidate) {
|
|
2354
|
+
io.write("적용할 candidate가 없습니다.\n");
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
const diff = makeDiff({
|
|
2358
|
+
session,
|
|
2359
|
+
candidate
|
|
2360
|
+
});
|
|
2361
|
+
showDiff(io, diff);
|
|
2362
|
+
const selected = action === "apply-all" ? diff.changes.map((change) => change.id) : (await io.input("적용할 change ID를 쉼표로 입력하세요: ")).split(",").map((id) => id.trim()).filter(Boolean);
|
|
2363
|
+
if (!await confirmSpecFindings(io, candidate, diff, selected)) continue;
|
|
2364
|
+
if (!await io.confirm("선택한 변경을 적용할까요?")) continue;
|
|
2365
|
+
const result = apply({
|
|
2366
|
+
session,
|
|
2367
|
+
preview: diff,
|
|
2368
|
+
selectedChangeIds: selected,
|
|
2369
|
+
approval: {
|
|
2370
|
+
approved: true,
|
|
2371
|
+
fingerprint: diff.candidateFingerprint
|
|
2372
|
+
}
|
|
2373
|
+
});
|
|
2374
|
+
if (!result.applied) io.write(`변경을 적용하지 않았습니다: ${result.reason}\n`);
|
|
2375
|
+
else io.write(`revision ${result.draft.revision}을 승인했습니다.\n`);
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
if (action === "edit") {
|
|
2379
|
+
const path = await io.input("편집한 JSON 파일 경로: ");
|
|
2380
|
+
try {
|
|
2381
|
+
const result = reviewLocal({
|
|
2382
|
+
session,
|
|
2383
|
+
candidate: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await deps.readFile(path))),
|
|
2384
|
+
tools
|
|
2385
|
+
});
|
|
2386
|
+
if (result.status === "preview") {
|
|
2387
|
+
candidate = result.preview;
|
|
2388
|
+
showDiff(io, makeDiff({
|
|
2389
|
+
session,
|
|
2390
|
+
candidate
|
|
2391
|
+
}));
|
|
2392
|
+
} else io.write("편집한 JSON을 candidate로 사용할 수 없습니다.\n");
|
|
2393
|
+
} catch {
|
|
2394
|
+
safeFailure(deps, "LOCAL_JSON_INVALID");
|
|
2395
|
+
}
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
const providerId = action === "revise" ? preferred : action;
|
|
2399
|
+
if (providerId !== "codex" && providerId !== "claude") {
|
|
2400
|
+
io.write("지원하지 않는 메뉴입니다.\n");
|
|
2401
|
+
continue;
|
|
2402
|
+
}
|
|
2403
|
+
if (action !== "revise" && providerId !== preferred) model = input.provider === providerId ? input.model : void 0;
|
|
2404
|
+
preferred = providerId;
|
|
2405
|
+
model = (model ?? await io.input(`${providerId} model (${defaultModel(providerId)}): `)) || defaultModel(providerId);
|
|
2406
|
+
const provider = deps.providers?.[providerId]?.(model);
|
|
2407
|
+
if (!provider) {
|
|
2408
|
+
io.write(`${providerId} provider를 사용할 수 없습니다.\n`);
|
|
2409
|
+
continue;
|
|
2410
|
+
}
|
|
2411
|
+
const instruction = await io.input(action === "revise" ? "피드백: " : "AI 요청: ");
|
|
2412
|
+
const preview = prepare({
|
|
2413
|
+
mode: action === "revise" ? "revise" : "initial",
|
|
2414
|
+
instruction,
|
|
2415
|
+
baseline: session.baseline.suite,
|
|
2416
|
+
candidate: candidate?.result.suite ?? session.baseline.suite,
|
|
2417
|
+
tools,
|
|
2418
|
+
providerId,
|
|
2419
|
+
model
|
|
2420
|
+
});
|
|
2421
|
+
showRequest(io, preview);
|
|
2422
|
+
if (!await io.confirm("이 요청을 전송할까요?")) continue;
|
|
2423
|
+
let result;
|
|
2424
|
+
try {
|
|
2425
|
+
result = await dispatch({
|
|
2426
|
+
provider,
|
|
2427
|
+
preview,
|
|
2428
|
+
approval: {
|
|
2429
|
+
approved: true,
|
|
2430
|
+
fingerprint: preview.fingerprint
|
|
2431
|
+
},
|
|
2432
|
+
session
|
|
2433
|
+
});
|
|
2434
|
+
} catch {
|
|
2435
|
+
providerFailure(deps, void 0, model);
|
|
2436
|
+
continue;
|
|
2437
|
+
}
|
|
2438
|
+
if (result.status === "preview") {
|
|
2439
|
+
candidate = result.preview;
|
|
2440
|
+
showDiff(io, makeDiff({
|
|
2441
|
+
session,
|
|
2442
|
+
candidate
|
|
2443
|
+
}));
|
|
2444
|
+
} else if (result.status === "questions") io.write(`질문:\n${result.questions.join("\n")}\n`);
|
|
2445
|
+
else if (result.status === "providerFailed") providerFailure(deps, result.failure, model);
|
|
2446
|
+
else io.write("AI 결과를 검토 후보로 사용할 수 없습니다.\n");
|
|
2447
|
+
}
|
|
2448
|
+
} catch (error) {
|
|
2449
|
+
if (!isReviewInputClosed(error)) throw error;
|
|
2450
|
+
deps.writeStdout("입력이 종료되어 검토를 취소했습니다. 저장하지 않았습니다.\n");
|
|
2451
|
+
return 0;
|
|
2452
|
+
} finally {
|
|
2453
|
+
io.close?.();
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* 축 종류를 사람 문장으로 바꾼다. `Record<ContractAxisKind, string>` 이라서 `runner` 가 축을
|
|
2458
|
+
* 늘리면 여기서 타입 오류가 난다. 문자열 배열로 두면 새 축이 화면에서 조용히 사라지고,
|
|
2459
|
+
* **이 화면에서 누락은 "검증했다" 로 읽힌다.** `FINDING_GROUP` 이 같은 이유로 같은 형태다.
|
|
2460
|
+
*/
|
|
2461
|
+
const AXIS_LABEL = {
|
|
2462
|
+
HAPPY_PATH: "선언을 지킨 입력에 정상 응답",
|
|
2463
|
+
REQUIRED_OMITTED: "필수 필드 누락 거절",
|
|
2464
|
+
TYPE_VIOLATION: "타입 위반 거절",
|
|
2465
|
+
ENUM_VIOLATION: "선언되지 않은 값 거절",
|
|
2466
|
+
RANGE_VIOLATION: "선언된 범위 밖 값 거절"
|
|
2467
|
+
};
|
|
2468
|
+
/**
|
|
2469
|
+
* runner 보고서 상한(1MB)에 닿기 전에 알리는 임계.
|
|
2470
|
+
*
|
|
2471
|
+
* 케이스당 보고서가 관측 범위에서 300~600 바이트다. 600 으로 계산해도 1500 케이스면 900KB 로
|
|
2472
|
+
* DEFAULT_MAX_REPORT_BYTES(1MB) 안에 들어간다. 그보다 크면 사용자가 조치할 시간이 필요하다.
|
|
2473
|
+
* 이 상한은 올릴 수 없다(resolvePayloadLimits 가 기본값을 최대치로 쓴다).
|
|
2474
|
+
*/
|
|
2475
|
+
const CASE_COUNT_WARNING_THRESHOLD = 1500;
|
|
2476
|
+
/**
|
|
2477
|
+
* 터미널 표시 폭. 한글은 두 칸을 차지하므로 문자 수로 맞추면 열이 어긋난다.
|
|
2478
|
+
* 화면 정렬이 목적이므로 코드 포인트 단위로 세고 넓은 글자만 2로 계산한다.
|
|
2479
|
+
*/
|
|
2480
|
+
function displayWidth(text) {
|
|
2481
|
+
let width = 0;
|
|
2482
|
+
for (const char of text) {
|
|
2483
|
+
const code = char.codePointAt(0) ?? 0;
|
|
2484
|
+
width += code >= 4352 && code <= 4447 || code >= 11904 && code <= 42191 || code >= 44032 && code <= 55203 || code >= 63744 && code <= 64255 || code >= 65072 && code <= 65135 || code >= 65280 && code <= 65376 || code >= 65504 && code <= 65510 ? 2 : 1;
|
|
2485
|
+
}
|
|
2486
|
+
return width;
|
|
2487
|
+
}
|
|
2488
|
+
/** 표시 폭 기준 오른쪽 공백 채우기. 이미 넓으면 그대로 둔다. */
|
|
2489
|
+
function padToWidth(text, width) {
|
|
2490
|
+
const padding = width - displayWidth(text);
|
|
2491
|
+
return padding > 0 ? text + " ".repeat(padding) : text;
|
|
2492
|
+
}
|
|
2493
|
+
/** 축 하나를 사람이 읽는 문장으로. 필드가 없는 축(HAPPY_PATH)은 종류만 적는다. */
|
|
2494
|
+
function axisLabel(kind, field) {
|
|
2495
|
+
return field === null ? AXIS_LABEL[kind] : `${field} 의 ${AXIS_LABEL[kind]}`;
|
|
2496
|
+
}
|
|
2497
|
+
/** 툴 한 개의 줄과 그 아래 들여쓴 줄들. */
|
|
2498
|
+
function coverageToolLines(tool, nameWidth) {
|
|
2499
|
+
const head = ` ${padToWidth(tool.tool, nameWidth)}`;
|
|
2500
|
+
if (!tool.analyzable) return [
|
|
2501
|
+
`${head}해석 불가`,
|
|
2502
|
+
` → 입력 스키마를 해석하지 못해 이 툴의 축을 세지 못했습니다 (${tool.unanalyzableReason ?? "schema"})`,
|
|
2503
|
+
" → 이 툴은 커버리지 숫자에 들어가지 않습니다"
|
|
2504
|
+
];
|
|
2505
|
+
const labels = tool.axes.filter((axis) => axis.caseId === null).map((axis) => axisLabel(axis.kind, axis.field));
|
|
2506
|
+
const labelWidth = Math.max(0, ...labels.map(displayWidth)) + 5;
|
|
2507
|
+
const lines = [`${head}${tool.verified}/${tool.total}`];
|
|
2508
|
+
for (const label of labels) lines.push(` ? ${padToWidth(label, labelWidth)}미검증`);
|
|
2509
|
+
if (tool.unanalyzedFields.length > 0) lines.push(` → 해석 못 한 필드 ${tool.unanalyzedFields.length}개: ${tool.unanalyzedFields.join(", ")}. 이 필드의 축은 세지 않았습니다`);
|
|
2510
|
+
return lines;
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* 커버리지 화면. 순수 함수라서 테스트가 문자열을 그대로 비교한다.
|
|
2514
|
+
*
|
|
2515
|
+
* 전부 검증되면 한 줄이다. 기본 생성이 축을 다 채우므로 그것이 대다수 실행의 모습이고,
|
|
2516
|
+
* 거기서 툴 30개를 나열하면 매 실행 30줄이 영구 소음이 된다.
|
|
2517
|
+
*
|
|
2518
|
+
* `total` 이 0 이면 `verified === total` 이 참이지만 "전부 검증" 이라고 쓰지 않는다. 축을
|
|
2519
|
+
* 하나도 세지 못한 것이지 전부 확인한 것이 아니다. 그 화면은 거짓이다.
|
|
2520
|
+
*/
|
|
2521
|
+
function renderCoverage(coverage) {
|
|
2522
|
+
if (coverage.tools.length === 0) return "";
|
|
2523
|
+
const count = coverage.tools.length;
|
|
2524
|
+
const nothingHidden = coverage.tools.every((tool) => tool.analyzable && tool.unanalyzedFields.length === 0);
|
|
2525
|
+
if (coverage.total > 0 && coverage.verified === coverage.total && nothingHidden) return `커버리지 ${count} tools, ${coverage.total} axes 전부 검증\n`;
|
|
2526
|
+
const nameWidth = Math.max(0, ...coverage.tools.map((tool) => displayWidth(tool.tool))) + 3;
|
|
2527
|
+
const lines = [`커버리지 ${count} tools, ${coverage.verified}/${coverage.total} axes 검증`];
|
|
2528
|
+
for (const tool of coverage.tools) lines.push(...coverageToolLines(tool, nameWidth));
|
|
2529
|
+
if (coverage.tools.some((tool) => tool.axes.some((axis) => axis.kind === "RANGE_VIOLATION" && axis.caseId === null))) lines.push(" → 범위 제약(minimum·maxItems 등)이 이번 버전부터 검증 축에 들어갑니다. 이전보다 숫자가 낮으면 새로 드러난 빈틈입니다");
|
|
2530
|
+
return `${lines.join("\n")}\n`;
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* 건너뛴 툴 고지. 없으면 빈 문자열이다. 순수 함수라서 테스트가 문자열을 그대로 비교한다.
|
|
2534
|
+
*
|
|
2535
|
+
* 종전에는 미지원 키워드 하나가 서버 전체를 거절했다(도그푸딩 실측: 공식 서버 7개 중 5개).
|
|
2536
|
+
* 이제 그 툴만 건너뛰므로, 건너뛴 사실이 안 보이면 "이 서버의 툴을 전부 검증했다" 로 읽힌다.
|
|
2537
|
+
* 커버리지 화면과 같은 이유로 침묵이 거짓말이 되는 자리다.
|
|
2538
|
+
*/
|
|
2539
|
+
function renderSkippedTools(skipped) {
|
|
2540
|
+
if (skipped.length === 0) return "";
|
|
2541
|
+
const lines = [`건너뜀 ${skipped.length} tools — 지원하지 않는 입력 스키마`];
|
|
2542
|
+
for (const tool of skipped) lines.push(` ${tool.name} ${tool.path}: ${tool.message}`);
|
|
2543
|
+
lines.push(" → 이 툴의 케이스는 생성되지 않았습니다. 필요하면 명세에 케이스를 손으로 추가하세요.");
|
|
2544
|
+
return `${lines.join("\n")}\n`;
|
|
2545
|
+
}
|
|
2546
|
+
/**
|
|
2547
|
+
* 사전보완 요청의 전송 전 확인 화면.
|
|
2548
|
+
*
|
|
2549
|
+
* authoring 화면의 어법을 따르되 **전송 데이터 목록은 사실대로** 적는다. 여기서 나가는 것은
|
|
2550
|
+
* suite 전량이 아니라 툴 선언과 baseline 이 넣은 값뿐이다. authoring 문안을 그대로 태우면
|
|
2551
|
+
* 사용자가 승인하는 내용과 실제로 나가는 내용이 달라진다.
|
|
2552
|
+
*/
|
|
2553
|
+
function showPreFillRequest(io, preview) {
|
|
2554
|
+
const { request } = preview;
|
|
2555
|
+
const fields = request.cases.reduce((sum, item) => sum + item.assistFields.length, 0);
|
|
2556
|
+
io.write(`AI 사전보완 요청\nProvider: ${preview.providerId}\nModel: ${preview.model}\nPayload: ${preview.byteLength} bytes\nResult limit: ${preview.maxResultBytes} bytes\nTimeout: ${preview.providerTimeoutMs}ms\nFingerprint: ${preview.fingerprint}\n대상: 툴 ${request.tools.length}개, 케이스 ${request.cases.length}개, 채울 필드 ${fields}개\n전송 데이터: 툴 이름·설명·inputSchema, baseline 이 넣은 값, 그 값의 출처
|
|
2557
|
+
받는 것: 값 제안뿐입니다. 케이스를 더하거나 구조를 바꾸지 않습니다.
|
|
2558
|
+
`);
|
|
2559
|
+
if (request.omitted.tools > 0) io.write(`⚠ 크기 상한 때문에 툴 ${request.omitted.tools}개를 요청에서 뺐습니다. 그 툴은 baseline 값 그대로 갑니다.\n`);
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* AI 없이 표 밖 `format` 툴을 건너뛸 때의 고지. 없으면 빈 문자열이다.
|
|
2563
|
+
*
|
|
2564
|
+
* **"지원하지 않는다" 로 끝내지 않는다.** 그러면 사용자가 할 수 있는 일이 없다. 해결 수단을
|
|
2565
|
+
* 같은 고지 안에 적는다. 이 프로젝트에서 실패 메시지는 곧 제품이다(설계서 §3.4).
|
|
2566
|
+
*/
|
|
2567
|
+
function renderUnknownFormatSkips(skips) {
|
|
2568
|
+
if (skips.length === 0) return "";
|
|
2569
|
+
const lines = [];
|
|
2570
|
+
for (const skip of skips) {
|
|
2571
|
+
lines.push(`경고: 툴 '${skip.tool}' 를 건너뜁니다.`);
|
|
2572
|
+
lines.push(skip.format === "" ? ` '${skip.field}' 의 format 은 AI 없이 채울 수 없습니다.` : ` format '${skip.format}' 은 AI 없이 채울 수 없습니다.`);
|
|
2573
|
+
lines.push(" AI 검토(--baseline-only 없이 실행)를 켜면 생성됩니다.");
|
|
2574
|
+
}
|
|
2575
|
+
return `${lines.join("\n")}\n`;
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* AI 사전보완 결과 요약. 대상이 없으면 빈 문자열이다.
|
|
2579
|
+
*
|
|
2580
|
+
* **`버림` 은 사유와 대상을 반드시 적는다.** 개수만 적으면 사용자가 무엇을 잃었는지 모른다
|
|
2581
|
+
* (이슈 #120 이 `discarded` 가 개수뿐이라고 지적한 것과 같은 계열이다). 버림이 0건이면 그 줄을
|
|
2582
|
+
* 아예 찍지 않는다.
|
|
2583
|
+
*/
|
|
2584
|
+
function renderPreFillSummary(options) {
|
|
2585
|
+
const { toolCount, proposedToolCount, adopted, notAdopted, discarded } = options;
|
|
2586
|
+
if (proposedToolCount === 0 && discarded.length === 0) return "";
|
|
2587
|
+
const lines = [
|
|
2588
|
+
`AI 사전보완: 툴 ${toolCount}개 중 ${proposedToolCount}개에 값 제안을 받았습니다.`,
|
|
2589
|
+
` 채택 ${adopted} (실제 서버에서 baseline 값이 실패하고 제안 값이 통과)`,
|
|
2590
|
+
` 미채택 ${notAdopted} (baseline 값이 이미 통과)`
|
|
2591
|
+
];
|
|
2592
|
+
for (const item of discarded) lines.push(` 버림 1 (${item.reason}: ${item.caseId}.${item.field})`);
|
|
2593
|
+
return `${lines.join("\n")}\n`;
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* 케이스 수 고지. 임계 아래면 빈 문자열이다.
|
|
2597
|
+
* 생성을 막지 않는다. 이미 존재하는 상한을 사용자에게 보이게 하는 것이 목적이다.
|
|
2598
|
+
*/
|
|
2599
|
+
function renderCaseCountNotice(caseCount) {
|
|
2600
|
+
if (caseCount < CASE_COUNT_WARNING_THRESHOLD) return "";
|
|
2601
|
+
return `→ 케이스 ${caseCount}개를 만들었습니다. runner 보고서 상한(1MB)에 가까워 test 실행이\n RunnerPayloadLimitError 로 실패할 수 있습니다.
|
|
2602
|
+
→ 툴을 나눠 여러 명세 파일로 생성하면 피할 수 있습니다.
|
|
2603
|
+
`;
|
|
2604
|
+
}
|
|
2605
|
+
/** 커버리지·건너뜀·케이스 수 고지를 stdout 에 찍는다. 전부 빈 문자열이면 아무것도 안 찍는다. */
|
|
2606
|
+
function writeCoverageReport(deps, coverage, suite, skippedTools) {
|
|
2607
|
+
if (coverage !== void 0) {
|
|
2608
|
+
const text = renderCoverage(coverage);
|
|
2609
|
+
if (text !== "") deps.writeStdout(text);
|
|
2610
|
+
}
|
|
2611
|
+
const skippedText = renderSkippedTools(skippedTools);
|
|
2612
|
+
if (skippedText !== "") deps.writeStdout(skippedText);
|
|
2613
|
+
const notice = renderCaseCountNotice(suite.cases.length);
|
|
2614
|
+
if (notice !== "") deps.writeStdout(notice);
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* 커버리지 보고를 저장과 다른 오류 경계에 둔다.
|
|
2618
|
+
*
|
|
2619
|
+
* 파일을 저장한 **뒤에** 커버리지 계산이나 렌더링이 실패할 수 있다. 그것을 저장 실패로 보고하면
|
|
2620
|
+
* 사용자가 저장을 다시 시도하고 이번에는 `OUTPUT_EXISTS` 를 만난다. 저장은 성공했고 부가 정보만
|
|
2621
|
+
* 못 만든 것이므로 종료 코드를 바꾸지 않고 경고만 낸다.
|
|
2622
|
+
*
|
|
2623
|
+
* coverage 를 값이 아니라 thunk 로 받는 이유는 `computeCoverage` 자체가 던지는 경우까지 이 경계
|
|
2624
|
+
* 안에 넣기 위해서다.
|
|
2625
|
+
*/
|
|
2626
|
+
function reportCoverageSafely(deps, coverage, suite, skippedTools = []) {
|
|
2627
|
+
try {
|
|
2628
|
+
writeCoverageReport(deps, coverage(), suite, skippedTools);
|
|
2629
|
+
} catch {
|
|
2630
|
+
deps.writeStderr("경고 [GENERATE_COVERAGE_UNAVAILABLE]: 명세는 저장했지만 커버리지를 계산하지 못했습니다.\n해결: 저장된 명세는 그대로 `mcpeak test` 로 쓸 수 있습니다. 커버리지만 다시 보려면 다른 --out 경로로 generate 를 실행하세요.\n");
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* AI 사전보완 한 회차. baseline 의 빈틈만 메운다.
|
|
2635
|
+
*
|
|
2636
|
+
* **자동이다.** 사용자의 자연어 요구를 받는 authoring 층과 목적이 다르므로 검토 메뉴가 아니라
|
|
2637
|
+
* 그 앞에서 한 번 돈다(설계서 §4.2). 사용자에게 묻는 것은 전송 승인 하나뿐이다.
|
|
2638
|
+
*
|
|
2639
|
+
* **provider 가 죽어도 툴을 건너뛰지 않는다.** provider 실패는 사용자 서버의 문제가 아니라
|
|
2640
|
+
* 우리 쪽 사정이고, 그것 때문에 케이스를 잃는 손해가 더 크다. baseline 값으로 진행하고
|
|
2641
|
+
* 그 사실을 화면에 적는다.
|
|
2642
|
+
*/
|
|
2643
|
+
async function runPreFill(input, tools, baseline, deps, client) {
|
|
2644
|
+
const skip = {
|
|
2645
|
+
suite: baseline.suite,
|
|
2646
|
+
preFilledCaseIds: []
|
|
2647
|
+
};
|
|
2648
|
+
const io = deps.reviewIO;
|
|
2649
|
+
const prepare = deps.preparePreFillRequest;
|
|
2650
|
+
const preview = deps.previewPreFillRequest;
|
|
2651
|
+
const dispatch = deps.dispatchPreFillRequest;
|
|
2652
|
+
const providerId = input.provider;
|
|
2653
|
+
if (io === void 0 || !prepare || !preview || !dispatch || providerId === void 0) return skip;
|
|
2654
|
+
const model = input.model ?? defaultModel(providerId);
|
|
2655
|
+
const provider = deps.preFillProviders?.[providerId]?.(model);
|
|
2656
|
+
if (provider === void 0) return skip;
|
|
2657
|
+
const request = prepare({
|
|
2658
|
+
tools,
|
|
2659
|
+
provenance: baseline.provenance,
|
|
2660
|
+
baseline: baseline.suite
|
|
2661
|
+
});
|
|
2662
|
+
if (request === null) return skip;
|
|
2663
|
+
let view;
|
|
2664
|
+
try {
|
|
2665
|
+
view = preview({
|
|
2666
|
+
request,
|
|
2667
|
+
providerId,
|
|
2668
|
+
model
|
|
2669
|
+
});
|
|
2670
|
+
} catch {
|
|
2671
|
+
io.write("⚠ AI 사전보완 요청이 크기 상한을 넘어 건너뜁니다. baseline 값으로 진행합니다.\n");
|
|
2672
|
+
return skip;
|
|
2673
|
+
}
|
|
2674
|
+
showPreFillRequest(io, view);
|
|
2675
|
+
if (!await io.confirm("이 요청을 전송할까요?")) return skip;
|
|
2676
|
+
const dispatched = await dispatch({
|
|
2677
|
+
provider,
|
|
2678
|
+
preview: view,
|
|
2679
|
+
approval: {
|
|
2680
|
+
approved: true,
|
|
2681
|
+
fingerprint: view.fingerprint
|
|
2682
|
+
}
|
|
2683
|
+
});
|
|
2684
|
+
if (dispatched.status !== "proposals") {
|
|
2685
|
+
io.write(`⚠ AI 사전보완을 쓰지 못했습니다 (${dispatched.status}). baseline 값으로 진행합니다.\n`);
|
|
2686
|
+
return skip;
|
|
2687
|
+
}
|
|
2688
|
+
const applied = await applyPreFill({
|
|
2689
|
+
client,
|
|
2690
|
+
baseline: baseline.suite,
|
|
2691
|
+
preFill: dispatched.result
|
|
2692
|
+
});
|
|
2693
|
+
io.write(renderPreFillSummary({
|
|
2694
|
+
toolCount: tools.length,
|
|
2695
|
+
proposedToolCount: request.tools.length,
|
|
2696
|
+
adopted: applied.adopted,
|
|
2697
|
+
notAdopted: applied.notAdopted,
|
|
2698
|
+
discarded: dispatched.result.discarded
|
|
2699
|
+
}));
|
|
2700
|
+
return {
|
|
2701
|
+
suite: applied.suite,
|
|
2702
|
+
preFilledCaseIds: applied.cases.filter((item) => item.source === "ai").map((item) => item.caseId)
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
async function runGenerateCommand(argv, deps) {
|
|
2706
|
+
let input;
|
|
2707
|
+
try {
|
|
2708
|
+
input = parseGenerateCommand(argv[0] === "generate" ? argv.slice(1) : argv);
|
|
2709
|
+
} catch (error) {
|
|
2710
|
+
const message = error instanceof UsageError$1 ? error.message : "generate 입력을 해석할 수 없습니다.";
|
|
2711
|
+
deps.writeStderr(`오류 [CLI_USAGE]: ${message}\n해결: ${GENERATE_USAGE_HINT}\n`);
|
|
2712
|
+
return 1;
|
|
2713
|
+
}
|
|
2714
|
+
try {
|
|
2715
|
+
if (!input.force && await deps.exists(input.outPath)) {
|
|
2716
|
+
outputExistsFailure(deps, input.outPath, "start");
|
|
2717
|
+
return 1;
|
|
2718
|
+
}
|
|
2719
|
+
} catch (error) {
|
|
2720
|
+
const code = error?.code;
|
|
2721
|
+
const suffix = typeof code === "string" ? ` (${code})` : "";
|
|
2722
|
+
deps.writeStderr(`오류 [GENERATE_OUTPUT_CHECK_FAILED]: 출력 경로를 확인하지 못해 시작하지 않았습니다. 경로: ${input.outPath}${suffix}\n해결: 그 경로와 상위 디렉터리의 권한을 확인하세요. 다른 \`--out\` 경로를 지정해도 됩니다.\n`);
|
|
2723
|
+
return 1;
|
|
2724
|
+
}
|
|
2725
|
+
if (!input.baselineOnly && !deps.reviewIO?.interactive) {
|
|
2726
|
+
deps.writeStderr("오류 [GENERATE_INTERACTIVE_REQUIRED]: AI 검토에는 TTY가 필요합니다.\n해결: `--baseline-only`를 지정하거나 대화형 터미널에서 실행하세요.\n");
|
|
2727
|
+
return 1;
|
|
2728
|
+
}
|
|
2729
|
+
let connection;
|
|
2730
|
+
/**
|
|
2731
|
+
* 대화형 검토는 아래 try 밖에서 돌린다. 검토가 던지는 오류를 여기 catch 가 삼키면
|
|
2732
|
+
* `GENERATE_FAILED` 로 뭉개지는데, 그 경로는 원래 호출자에게 그대로 올라가야 한다.
|
|
2733
|
+
*/
|
|
2734
|
+
let review;
|
|
2735
|
+
try {
|
|
2736
|
+
connection = await deps.connect({
|
|
2737
|
+
command: input.command,
|
|
2738
|
+
args: input.args
|
|
2739
|
+
});
|
|
2740
|
+
const active = connection;
|
|
2741
|
+
const tools = await active.client.listTools();
|
|
2742
|
+
if (input.baselineOnly) {
|
|
2743
|
+
await active.close();
|
|
2744
|
+
connection = void 0;
|
|
2745
|
+
}
|
|
2746
|
+
const baseline = deps.createBaselineSuite(tools, {
|
|
2747
|
+
suiteId: input.suiteId,
|
|
2748
|
+
suiteName: input.name
|
|
2749
|
+
});
|
|
2750
|
+
let sessionSuite = baseline.suite;
|
|
2751
|
+
let preFilledCaseIds = [];
|
|
2752
|
+
if (input.baselineOnly) {
|
|
2753
|
+
const skips = unknownFormatSkips(tools, baseline.provenance);
|
|
2754
|
+
if (skips.length > 0) {
|
|
2755
|
+
deps.writeStdout(renderUnknownFormatSkips(skips));
|
|
2756
|
+
sessionSuite = dropSkippedTools(baseline.suite, skips);
|
|
2757
|
+
}
|
|
2758
|
+
} else {
|
|
2759
|
+
const outcome = await runPreFill(input, tools, baseline, deps, active.client);
|
|
2760
|
+
sessionSuite = outcome.suite;
|
|
2761
|
+
preFilledCaseIds = outcome.preFilledCaseIds;
|
|
2762
|
+
}
|
|
2763
|
+
const session = deps.createAuthoringSession(sessionSuite === baseline.suite ? baseline : {
|
|
2764
|
+
...baseline,
|
|
2765
|
+
suite: sessionSuite
|
|
2766
|
+
}, { preFilledCaseIds });
|
|
2767
|
+
if (!input.baselineOnly) {
|
|
2768
|
+
connection = void 0;
|
|
2769
|
+
review = {
|
|
2770
|
+
active,
|
|
2771
|
+
session,
|
|
2772
|
+
tools,
|
|
2773
|
+
skippedTools: baseline.skippedTools
|
|
2774
|
+
};
|
|
2775
|
+
} else {
|
|
2776
|
+
const final = deps.finalizeAuthoringDraft({
|
|
2777
|
+
session,
|
|
2778
|
+
approval: {
|
|
2779
|
+
approved: true,
|
|
2780
|
+
fingerprint: session.approvedDraft.suiteFingerprint
|
|
2781
|
+
}
|
|
2782
|
+
});
|
|
2783
|
+
if (!final.finalized) throw new Error("finalize failed");
|
|
2784
|
+
const finalSuite = deps.getAuthoringExecutionSuite(final.snapshot);
|
|
2785
|
+
await saveSuite(input, finalSuite, final.snapshot.fingerprint, deps);
|
|
2786
|
+
deps.writeStdout(`baseline suite를 저장했습니다: ${input.outPath}\n`);
|
|
2787
|
+
reportCoverageSafely(deps, () => baseline.coverage, finalSuite, baseline.skippedTools);
|
|
2788
|
+
return 0;
|
|
2789
|
+
}
|
|
2790
|
+
} catch (error) {
|
|
2791
|
+
if (connection !== void 0) await connection.forceClose().catch(() => void 0);
|
|
2792
|
+
if (error instanceof OutputExistsError) outputExistsFailure(deps, error.path);
|
|
2793
|
+
else if (error instanceof OutputReplaceError) outputReplaceFailure(deps, error.path, error.code);
|
|
2794
|
+
else if (error instanceof LinkUnsupportedError) linkUnsupportedFailure(deps, error.path, error.code);
|
|
2795
|
+
else if (deps.GenerateTestsError !== void 0 && error instanceof deps.GenerateTestsError) generateTestsFailure(deps, error);
|
|
2796
|
+
else if (isCoreClientError(error)) deps.writeStderr(`오류 [GENERATE_CONNECT_FAILED/${error.code}]: ${error.message}\n해결: ${error.hint}\n`);
|
|
2797
|
+
else deps.writeStderr("오류 [GENERATE_FAILED]: baseline suite를 생성하거나 저장하지 못했습니다.\n해결: MCP 서버와 출력 경로를 확인한 뒤 다시 실행하세요.\n");
|
|
2798
|
+
return 1;
|
|
2799
|
+
}
|
|
2800
|
+
try {
|
|
2801
|
+
return await runInteractiveReview(input, review.tools, review.session, deps, review.active, review.skippedTools);
|
|
2802
|
+
} finally {
|
|
2803
|
+
await review.active.close().catch(() => void 0);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
const nodeGenerateDependencies = () => ({
|
|
2807
|
+
exists: async (path) => (0, node_fs_promises.access)(path).then(() => true).catch(() => false),
|
|
2808
|
+
openTemp: (path) => (0, node_fs_promises.open)(path, "wx"),
|
|
2809
|
+
readFile: node_fs_promises.readFile,
|
|
2810
|
+
link: node_fs_promises.link,
|
|
2811
|
+
unlink: node_fs_promises.unlink,
|
|
2812
|
+
writeStdout: (text) => process.stdout.write(text),
|
|
2813
|
+
writeStderr: (text) => process.stderr.write(text)
|
|
2814
|
+
});
|
|
2815
|
+
function nodeReviewIO(input = process.stdin, output = process.stdout) {
|
|
2816
|
+
/**
|
|
2817
|
+
* readline은 첫 질문에서 만든다. createInterface는 만드는 즉시 입력 스트림을 참조해
|
|
2818
|
+
* 닫기 전까지 이벤트 루프를 붙잡는다. `--baseline-only`처럼 아무것도 묻지 않는 경로에서
|
|
2819
|
+
* 미리 만들면 실제 TTY에서 프로세스가 종료되지 않는다(파이프로 돌리면 stdin이 끝나
|
|
2820
|
+
* 우연히 종료돼 그동안 드러나지 않았다). 만들지 않으면 닫기를 잊을 여지도 없다.
|
|
2821
|
+
*/
|
|
2822
|
+
let readline;
|
|
2823
|
+
let closed = false;
|
|
2824
|
+
let rejectPending;
|
|
2825
|
+
const ensureReadline = () => {
|
|
2826
|
+
if (readline !== void 0) return readline;
|
|
2827
|
+
const created = (0, node_readline_promises.createInterface)({
|
|
2828
|
+
input,
|
|
2829
|
+
output
|
|
2830
|
+
});
|
|
2831
|
+
created.on("close", () => {
|
|
2832
|
+
closed = true;
|
|
2833
|
+
rejectPending?.();
|
|
2834
|
+
});
|
|
2835
|
+
readline = created;
|
|
2836
|
+
return created;
|
|
2837
|
+
};
|
|
2838
|
+
/**
|
|
2839
|
+
* EOF에는 두 모양이 있고 둘 다 스택 노출이나 무한 대기로 끝난다. 둘 다 sentinel로 바꾼다.
|
|
2840
|
+
* 1) 이미 닫힌 뒤 부르면 Node가 ERR_USE_AFTER_CLOSE를 던진다.
|
|
2841
|
+
* 2) 대기 중에 닫히면 question promise가 영영 settle되지 않는다. close 이벤트와 race시킨다.
|
|
2842
|
+
*/
|
|
2843
|
+
const question = async (prompt) => {
|
|
2844
|
+
if (closed) throw new ReviewInputClosedError();
|
|
2845
|
+
const active = ensureReadline();
|
|
2846
|
+
const closedSignal = new Promise((_, reject) => {
|
|
2847
|
+
rejectPending = () => reject(new ReviewInputClosedError());
|
|
2848
|
+
});
|
|
2849
|
+
try {
|
|
2850
|
+
return await Promise.race([active.question(prompt), closedSignal]);
|
|
2851
|
+
} catch (error) {
|
|
2852
|
+
if (isReviewInputClosed(error)) throw new ReviewInputClosedError();
|
|
2853
|
+
throw error;
|
|
2854
|
+
} finally {
|
|
2855
|
+
rejectPending = void 0;
|
|
2856
|
+
}
|
|
2857
|
+
};
|
|
2858
|
+
return {
|
|
2859
|
+
interactive: Boolean(input.isTTY && output.isTTY),
|
|
2860
|
+
input: (message) => question(message),
|
|
2861
|
+
choose: async (message, choices) => {
|
|
2862
|
+
return (await question(`${message} [${choices.join("/")}]: `)).trim();
|
|
2863
|
+
},
|
|
2864
|
+
confirm: async (message) => (await question(`${message} [y/N] `)).trim().toLowerCase() === "y",
|
|
2865
|
+
write: (text) => {
|
|
2866
|
+
output.write(text);
|
|
2867
|
+
},
|
|
2868
|
+
close: () => readline?.close()
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
//#endregion
|
|
2872
|
+
//#region src/spec-approval.ts
|
|
2873
|
+
/** 표시용 축약 길이. 64자는 줄을 넘겨 읽히지 않고, 12자면 눈으로 다르다는 것을 알 수 있다. */
|
|
2874
|
+
const DISPLAY_LENGTH = 12;
|
|
2875
|
+
const short = (value) => `${value.slice(0, DISPLAY_LENGTH)}…`;
|
|
2876
|
+
function checkSpecApproval(suite) {
|
|
2877
|
+
const fingerprint = (0, _mcpeak_runner.suiteFingerprint)(suite);
|
|
2878
|
+
const approved = suite.approval?.fingerprint;
|
|
2879
|
+
if (approved === void 0) return {
|
|
2880
|
+
state: "absent",
|
|
2881
|
+
fingerprint
|
|
2882
|
+
};
|
|
2883
|
+
return {
|
|
2884
|
+
state: approved === fingerprint ? "matched" : "mismatched",
|
|
2885
|
+
fingerprint,
|
|
2886
|
+
approvedFingerprint: approved
|
|
2887
|
+
};
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* 승인 시점 케이스 판정의 조회표. `approval.cases` 가 없으면 빈 표다.
|
|
2891
|
+
* 케이스마다 배열을 훑지 않으려고 한 번만 만든다.
|
|
2892
|
+
*
|
|
2893
|
+
* 실재하지 않는 id 가 들어 있어도 그대로 담는다. 검증이 이미 그것을 통과시켰고(설계 문서 §7.3),
|
|
2894
|
+
* 조회하는 쪽은 보고서의 케이스 id 로만 묻기 때문에 여분 항목은 닿지 않는다.
|
|
2895
|
+
*/
|
|
2896
|
+
function caseApprovalStatuses(suite) {
|
|
2897
|
+
return new Map((suite.approval?.cases ?? []).map((entry) => [entry.id, entry.status]));
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* `serverDefect` 로 표시된 케이스가 다시 실패했을 때 붙이는 한 줄. 설계 문서 §9.
|
|
2901
|
+
* 들여쓰기는 보고서의 케이스 블록과 맞춘다. 개행으로 끝나고 호출자가 앞에 빈 줄을 붙인다.
|
|
2902
|
+
*
|
|
2903
|
+
* 종료 코드는 바뀌지 않는다. 알려진 결함이어도 실패는 실패이고, 초록으로 만들면 고칠 이유가
|
|
2904
|
+
* 사라진다.
|
|
2905
|
+
*/
|
|
2906
|
+
const SERVER_DEFECT_NOTE_LINE = " 참고: 승인 시점에 서버 결함으로 표시된 케이스입니다. 서버가 아직 고쳐지지 않았습니다.\n";
|
|
2907
|
+
/**
|
|
2908
|
+
* 표시 여부. 설계 문서 §7.1.
|
|
2909
|
+
* 전부 통과일 때는 불일치만 알린다. 매 실행 한 줄은 손으로 명세를 쓰는 사용자에게 영구
|
|
2910
|
+
* 소음이고, 그러면 정작 필요할 때 그 줄을 안 읽는다.
|
|
2911
|
+
* 전부 통과 + 불일치만 예외인 이유는 승인받지 않은 명세로 초록불이 뜬 상태라서다.
|
|
2912
|
+
*/
|
|
2913
|
+
function shouldShowSpecApproval(result, allPassed) {
|
|
2914
|
+
return allPassed ? result.state === "mismatched" : true;
|
|
2915
|
+
}
|
|
2916
|
+
/** 반환은 개행으로 끝난다. 호출자가 앞에 빈 줄을 붙인다. 설계 문서 §7.2. */
|
|
2917
|
+
function renderSpecApproval(result) {
|
|
2918
|
+
if (result.state === "matched") return `명세: 승인 시점과 동일 (${short(result.fingerprint)})\n`;
|
|
2919
|
+
if (result.state === "absent") return "명세: 승인 지문이 없습니다 (미고정)\n → mcpeak generate 로 승인한 명세가 아니거나 승인 이전 버전으로 만든 파일입니다.\n";
|
|
2920
|
+
return `명세: 승인 시점 이후 변경됨
|
|
2921
|
+
→ 승인 ${short(result.approvedFingerprint ?? "")} 현재 ${short(result.fingerprint)}\n → 실패 원인에서 명세 변경을 배제할 수 없습니다. 명세 diff 를 먼저 확인하세요.
|
|
2922
|
+
`;
|
|
2923
|
+
}
|
|
2924
|
+
/** 번들에 적는 CLI 식별자. `mcpeak --version` 이 찍는 것과 같은 출처를 쓴다. */
|
|
2925
|
+
const REPAIR_BUNDLE_GENERATED_BY = `mcpeak ${version}`;
|
|
2926
|
+
/**
|
|
2927
|
+
* 케이스 하나의 진단을 순서대로 모은다.
|
|
2928
|
+
*
|
|
2929
|
+
* `operation.diagnostic` 이 먼저이고 그다음이 `assertions[].diagnostic` 이다. 첫 번째만 담으면
|
|
2930
|
+
* 실제 원인이 두 번째에 있을 때 근거가 사라진다. 설계서 §4.2.
|
|
2931
|
+
*/
|
|
2932
|
+
function diagnosticsOf(item) {
|
|
2933
|
+
const sources = [item.operation.diagnostic, ...item.assertions.map((assertion) => assertion.diagnostic)];
|
|
2934
|
+
const collected = [];
|
|
2935
|
+
for (const source of sources) {
|
|
2936
|
+
if (source === void 0) continue;
|
|
2937
|
+
const diagnostic = {
|
|
2938
|
+
code: source.code,
|
|
2939
|
+
message: source.message
|
|
2940
|
+
};
|
|
2941
|
+
if ("expected" in source) diagnostic.expected = source.expected;
|
|
2942
|
+
if ("actual" in source) diagnostic.actual = source.actual;
|
|
2943
|
+
if (source.notes !== void 0) diagnostic.notes = [...source.notes];
|
|
2944
|
+
collected.push(diagnostic);
|
|
2945
|
+
}
|
|
2946
|
+
return collected;
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* 실행 결과에서 repair 번들을 만든다.
|
|
2950
|
+
*
|
|
2951
|
+
* 통과한 케이스는 담지 않는다. 이 파일은 AI 에게 줄 근거 묶음이고 `--json` 보고서와 용도가
|
|
2952
|
+
* 다르다. 실패가 하나도 없으면 `undefined` 를 돌려주고, 호출 지점이 파일을 안 만든다.
|
|
2953
|
+
* 설계서 §4.2.
|
|
2954
|
+
*/
|
|
2955
|
+
function buildRepairBundle(options) {
|
|
2956
|
+
const failed = options.report.cases.filter((item) => item.status !== "passed");
|
|
2957
|
+
if (failed.length === 0) return void 0;
|
|
2958
|
+
const approvals = caseApprovalStatuses(options.suite);
|
|
2959
|
+
const failures = failed.map((item) => {
|
|
2960
|
+
const failure = {
|
|
2961
|
+
caseId: item.spec.id,
|
|
2962
|
+
caseName: item.spec.name,
|
|
2963
|
+
status: item.status,
|
|
2964
|
+
diagnostics: diagnosticsOf(item)
|
|
2965
|
+
};
|
|
2966
|
+
if (item.spec.operation.type === "callTool") {
|
|
2967
|
+
failure.tool = item.spec.operation.tool;
|
|
2968
|
+
failure.input = item.spec.operation.input;
|
|
2969
|
+
}
|
|
2970
|
+
const approvedAs = approvals.get(item.spec.id);
|
|
2971
|
+
if (approvedAs !== void 0) failure.approvedAs = approvedAs;
|
|
2972
|
+
return failure;
|
|
2973
|
+
});
|
|
2974
|
+
const spec = {
|
|
2975
|
+
suiteId: options.report.suite.id,
|
|
2976
|
+
suiteName: options.report.suite.name,
|
|
2977
|
+
approval: options.specApproval.state,
|
|
2978
|
+
fingerprint: options.specApproval.fingerprint
|
|
2979
|
+
};
|
|
2980
|
+
if (options.specApproval.approvedFingerprint !== void 0) spec.approvedFingerprint = options.specApproval.approvedFingerprint;
|
|
2981
|
+
const bundle = {
|
|
2982
|
+
bundleVersion: 1,
|
|
2983
|
+
generatedBy: options.cliVersion ?? REPAIR_BUNDLE_GENERATED_BY,
|
|
2984
|
+
spec,
|
|
2985
|
+
failures
|
|
2986
|
+
};
|
|
2987
|
+
/**
|
|
2988
|
+
* 내용이 있을 때만 담는다. 판정은 화면이 쓰는 `hasDiagnosticContent` 와 **같은 함수**다.
|
|
2989
|
+
* 규칙이 갈라지면 화면에는 안 뜨는 것이 번들에는 들어간다. 설계서 §4.2.
|
|
2990
|
+
*/
|
|
2991
|
+
if (options.processDiagnostics !== void 0 && hasDiagnosticContent(options.processDiagnostics)) bundle.process = options.processDiagnostics;
|
|
2992
|
+
return bundle;
|
|
2993
|
+
}
|
|
2994
|
+
/**
|
|
2995
|
+
* 실패가 없어 번들을 안 만들었을 때 화면에 남기는 한 줄. 조용히 넘어가면 사용자는 파일이
|
|
2996
|
+
* 없는 이유를 모른다. 개행으로 끝나고 호출자가 앞에 빈 줄을 붙인다.
|
|
2997
|
+
*/
|
|
2998
|
+
const REPAIR_BUNDLE_EMPTY_LINE = "repair 번들: 실패한 케이스가 없어 파일을 만들지 않았습니다.\n";
|
|
2999
|
+
/** 번들 직렬화의 단일 구현. 파일에 쓰는 쪽과 테스트가 같은 형식을 본다. */
|
|
3000
|
+
function serializeRepairBundle(bundle) {
|
|
3001
|
+
return `${JSON.stringify(bundle, null, 2)}\n`;
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* 거절 사유마다 다른 문장을 돌려준다. 무엇이 왜 다른지와 다음에 할 일이 한 줄씩 있어야 한다.
|
|
3005
|
+
* "잘못된 파일입니다" 로 뭉치면 사용자는 파일을 다시 만들어야 하는지 경로를 잘못 준 것인지
|
|
3006
|
+
* 구분할 수 없다.
|
|
3007
|
+
*/
|
|
3008
|
+
function describeRepairBundleInvalid(reason) {
|
|
3009
|
+
switch (reason) {
|
|
3010
|
+
case "notJson": return "번들 파일이 JSON 이 아닙니다. `--repair-bundle` 이 만든 파일이 맞는지, 편집 중에 깨지지 않았는지 확인하세요.";
|
|
3011
|
+
case "notObject": return "번들 최상위가 JSON 객체가 아닙니다. 배열이나 문자열이 담긴 다른 파일을 가리키고 있지 않은지 경로를 확인하세요.";
|
|
3012
|
+
case "versionMismatch": return `번들 형식 버전이 이 CLI 가 아는 1 이 아닙니다. 최신 \`mcpeak test --repair-bundle\` 로 다시 만드세요.`;
|
|
3013
|
+
case "missingField": return "번들에 필요한 항목이 없거나 값이 형식과 다릅니다. `spec` 의 `suiteId`·`suiteName`·`approval`, 각 실패의 `caseId`·`caseName`·`status`·`diagnostics`, 각 진단의 `code`·`message` 가 있어야 합니다. `mcpeak test --repair-bundle` 로 다시 만드세요.";
|
|
3014
|
+
case "emptyFailures": return "번들에 실패한 케이스가 없습니다. 진단할 근거가 없으므로 provider 를 부르지 않습니다. 실패가 있는 실행에서 번들을 다시 만드세요.";
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
const plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3018
|
+
/**
|
|
3019
|
+
* 번들 파일 내용을 읽어 검증한다.
|
|
3020
|
+
*
|
|
3021
|
+
* 모르는 버전은 거절한다. 앞으로 호환을 흉내 내면 낡은 번들에서 키가 빠졌을 때 조용히
|
|
3022
|
+
* 반쪽으로 돈다. 설계서 §4.2.
|
|
3023
|
+
*/
|
|
3024
|
+
/** 화면과 진단 요청이 실제로 읽는 값들이다. 여기 없는 값은 검사하지 않는다. */
|
|
3025
|
+
const APPROVAL_STATES = [
|
|
3026
|
+
"matched",
|
|
3027
|
+
"mismatched",
|
|
3028
|
+
"absent"
|
|
3029
|
+
];
|
|
3030
|
+
const FAILURE_STATUSES = [
|
|
3031
|
+
"failed",
|
|
3032
|
+
"timedOut",
|
|
3033
|
+
"cancelled",
|
|
3034
|
+
"notRun"
|
|
3035
|
+
];
|
|
3036
|
+
const APPROVED_AS = ["passed", "serverDefect"];
|
|
3037
|
+
const isOneOf = (value, allowed) => typeof value === "string" && allowed.includes(value);
|
|
3038
|
+
/**
|
|
3039
|
+
* 소비하는 필드가 실제로 있고 형식이 맞는지 본다.
|
|
3040
|
+
*
|
|
3041
|
+
* 얕게 보면 `caseName` 이나 `status` 가 빠진 JSON 도 통과해 그대로 provider 요청까지 나간다.
|
|
3042
|
+
* 사용자는 확인 화면에서 빈 값을 보고도 무엇이 잘못됐는지 모른다. 반대로 우리가 안 읽는 값까지
|
|
3043
|
+
* 요구하면 다른 버전이 만든 정상 번들을 거절하게 되므로, 기준은 **읽는 값**이다.
|
|
3044
|
+
*/
|
|
3045
|
+
function failureShapeValid(failure) {
|
|
3046
|
+
if (!plainObject(failure)) return false;
|
|
3047
|
+
if (typeof failure.caseId !== "string" || failure.caseId === "") return false;
|
|
3048
|
+
if (typeof failure.caseName !== "string") return false;
|
|
3049
|
+
if (!isOneOf(failure.status, FAILURE_STATUSES)) return false;
|
|
3050
|
+
if (failure.tool !== void 0 && typeof failure.tool !== "string") return false;
|
|
3051
|
+
if (failure.approvedAs !== void 0 && !isOneOf(failure.approvedAs, APPROVED_AS)) return false;
|
|
3052
|
+
if (failure.input !== void 0 && !plainObject(failure.input)) return false;
|
|
3053
|
+
if (!Array.isArray(failure.diagnostics)) return false;
|
|
3054
|
+
for (const diagnostic of failure.diagnostics) {
|
|
3055
|
+
if (!plainObject(diagnostic)) return false;
|
|
3056
|
+
if (typeof diagnostic.code !== "string") return false;
|
|
3057
|
+
if (typeof diagnostic.message !== "string") return false;
|
|
3058
|
+
if (diagnostic.notes !== void 0 && !Array.isArray(diagnostic.notes)) return false;
|
|
3059
|
+
}
|
|
3060
|
+
return true;
|
|
3061
|
+
}
|
|
3062
|
+
function specShapeValid(spec) {
|
|
3063
|
+
if (!plainObject(spec)) return false;
|
|
3064
|
+
if (typeof spec.suiteId !== "string") return false;
|
|
3065
|
+
if (typeof spec.suiteName !== "string") return false;
|
|
3066
|
+
if (!isOneOf(spec.approval, APPROVAL_STATES)) return false;
|
|
3067
|
+
if (typeof spec.fingerprint !== "string") return false;
|
|
3068
|
+
return true;
|
|
3069
|
+
}
|
|
3070
|
+
function readRepairBundle(text) {
|
|
3071
|
+
let parsed;
|
|
3072
|
+
try {
|
|
3073
|
+
parsed = JSON.parse(text);
|
|
3074
|
+
} catch {
|
|
3075
|
+
return {
|
|
3076
|
+
status: "invalid",
|
|
3077
|
+
reason: "notJson"
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
if (!plainObject(parsed)) return {
|
|
3081
|
+
status: "invalid",
|
|
3082
|
+
reason: "notObject"
|
|
3083
|
+
};
|
|
3084
|
+
if (parsed.bundleVersion !== 1) return {
|
|
3085
|
+
status: "invalid",
|
|
3086
|
+
reason: "versionMismatch"
|
|
3087
|
+
};
|
|
3088
|
+
if (!specShapeValid(parsed.spec)) return {
|
|
3089
|
+
status: "invalid",
|
|
3090
|
+
reason: "missingField"
|
|
3091
|
+
};
|
|
3092
|
+
if (!Array.isArray(parsed.failures)) return {
|
|
3093
|
+
status: "invalid",
|
|
3094
|
+
reason: "missingField"
|
|
3095
|
+
};
|
|
3096
|
+
for (const failure of parsed.failures) if (!failureShapeValid(failure)) return {
|
|
3097
|
+
status: "invalid",
|
|
3098
|
+
reason: "missingField"
|
|
3099
|
+
};
|
|
3100
|
+
if (parsed.failures.length === 0) return {
|
|
3101
|
+
status: "invalid",
|
|
3102
|
+
reason: "emptyFailures"
|
|
3103
|
+
};
|
|
3104
|
+
return {
|
|
3105
|
+
status: "ok",
|
|
3106
|
+
bundle: parsed
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
3109
|
+
var UsageError = class extends Error {};
|
|
3110
|
+
const optionNames = /* @__PURE__ */ new Set([
|
|
3111
|
+
"--provider",
|
|
3112
|
+
"--model",
|
|
3113
|
+
"--max-cases",
|
|
3114
|
+
"--no-stderr",
|
|
3115
|
+
"--yes"
|
|
3116
|
+
]);
|
|
3117
|
+
/** 값을 받지 않는 옵션. `=` 를 붙여 쓸 수 없고 두 번 쓸 수 없다. */
|
|
3118
|
+
const flagNames = /* @__PURE__ */ new Set(["--no-stderr", "--yes"]);
|
|
3119
|
+
function optionValue(argv, index, option) {
|
|
3120
|
+
const item = argv[index];
|
|
3121
|
+
const equals = item.indexOf("=");
|
|
3122
|
+
if (equals > 0) return [item.slice(equals + 1), index];
|
|
3123
|
+
const value = argv[index + 1];
|
|
3124
|
+
if (value === void 0 || value.startsWith("--")) throw new UsageError(`\`${option}\` 옵션 값이 필요합니다.`);
|
|
3125
|
+
return [value, index + 1];
|
|
3126
|
+
}
|
|
3127
|
+
function parseRepairCommand(argv) {
|
|
3128
|
+
let bundlePath;
|
|
3129
|
+
const values = /* @__PURE__ */ new Map();
|
|
3130
|
+
const flags = /* @__PURE__ */ new Set();
|
|
3131
|
+
for (let index = 0; index < argv.length; index++) {
|
|
3132
|
+
const item = argv[index];
|
|
3133
|
+
if (item === void 0) continue;
|
|
3134
|
+
if (!item.startsWith("--")) {
|
|
3135
|
+
if (bundlePath !== void 0) throw new UsageError(`추가 위치 인자 '${item}'는 허용되지 않습니다.`);
|
|
3136
|
+
bundlePath = item;
|
|
3137
|
+
continue;
|
|
3138
|
+
}
|
|
3139
|
+
const option = item.includes("=") ? item.slice(0, item.indexOf("=")) : item;
|
|
3140
|
+
if (!optionNames.has(option)) throw new UsageError(`지원하지 않는 repair 옵션 '${option}'입니다.`);
|
|
3141
|
+
if (flagNames.has(option)) {
|
|
3142
|
+
if (item !== option || flags.has(option)) throw new UsageError(`\`${option}\`는 한 번만 사용할 수 있습니다.`);
|
|
3143
|
+
flags.add(option);
|
|
3144
|
+
continue;
|
|
3145
|
+
}
|
|
3146
|
+
const [value, consumed] = optionValue(argv, index, option);
|
|
3147
|
+
index = consumed;
|
|
3148
|
+
if (value === "") throw new UsageError(`\`${option}\` 옵션 값이 필요합니다.`);
|
|
3149
|
+
if (values.has(option)) throw new UsageError(`\`${option}\`는 한 번만 사용할 수 있습니다.`);
|
|
3150
|
+
values.set(option, value);
|
|
3151
|
+
}
|
|
3152
|
+
if (bundlePath === void 0) throw new UsageError("repair 번들 JSON 경로가 필요합니다.");
|
|
3153
|
+
const rawProvider = values.get("--provider");
|
|
3154
|
+
if (rawProvider === void 0) throw new UsageError("`--provider` 옵션이 필요합니다.");
|
|
3155
|
+
if (rawProvider !== "codex" && rawProvider !== "claude") throw new UsageError("`--provider`는 codex 또는 claude여야 합니다.");
|
|
3156
|
+
const model = values.get("--model");
|
|
3157
|
+
if (model === void 0) throw new UsageError("`--model` 옵션이 필요합니다.");
|
|
3158
|
+
const rawMaxCases = values.get("--max-cases");
|
|
3159
|
+
let maxCases = 10;
|
|
3160
|
+
if (rawMaxCases !== void 0) {
|
|
3161
|
+
if (!/^\d+$/.test(rawMaxCases)) throw new UsageError("`--max-cases` 값은 1 이상의 정수여야 합니다.");
|
|
3162
|
+
maxCases = Number.parseInt(rawMaxCases, 10);
|
|
3163
|
+
if (!Number.isSafeInteger(maxCases) || maxCases < 1) throw new UsageError("`--max-cases` 값은 1 이상의 정수여야 합니다.");
|
|
3164
|
+
}
|
|
3165
|
+
return Object.freeze({
|
|
3166
|
+
bundlePath,
|
|
3167
|
+
providerId: rawProvider,
|
|
3168
|
+
model,
|
|
3169
|
+
yes: flags.has("--yes"),
|
|
3170
|
+
includeStderr: !flags.has("--no-stderr"),
|
|
3171
|
+
maxCases
|
|
3172
|
+
});
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* `repair` 명령. 번들을 읽고, 전송 내용을 확인받고, provider 에게 진단을 물어 화면에 찍는다.
|
|
3176
|
+
* 파일도 명세도 고치지 않는다. 종료 코드는 운영 실패에만 1 이다(설계서 §7.1).
|
|
3177
|
+
*/
|
|
3178
|
+
async function runRepairCommand(argv, deps) {
|
|
3179
|
+
let input;
|
|
3180
|
+
try {
|
|
3181
|
+
input = parseRepairCommand(argv[0] === "repair" ? argv.slice(1) : argv);
|
|
3182
|
+
} catch (error) {
|
|
3183
|
+
const message = error instanceof UsageError ? error.message : "repair 입력을 해석할 수 없습니다.";
|
|
3184
|
+
deps.writeStderr(`오류 [CLI_USAGE]: ${message}\n해결: ${REPAIR_USAGE_HINT}\n`);
|
|
3185
|
+
return 1;
|
|
3186
|
+
}
|
|
3187
|
+
let text;
|
|
3188
|
+
try {
|
|
3189
|
+
text = await deps.readFile(input.bundlePath);
|
|
3190
|
+
} catch {
|
|
3191
|
+
deps.writeStderr(`오류 [REPAIR_BUNDLE_READ_FAILED]: repair 번들 파일을 읽지 못했습니다. 경로: ${escapeTerminalText$1(input.bundlePath)}\n해결: 경로와 읽기 권한을 확인하세요. 번들은 \`mcpeak test --repair-bundle <path>\` 가 만듭니다.\n`);
|
|
3192
|
+
return 1;
|
|
3193
|
+
}
|
|
3194
|
+
const read = readRepairBundle(text);
|
|
3195
|
+
if (read.status !== "ok") {
|
|
3196
|
+
deps.writeStderr(`오류 [REPAIR_BUNDLE_INVALID]: ${describeRepairBundleInvalid(read.reason)}\n`);
|
|
3197
|
+
return 1;
|
|
3198
|
+
}
|
|
3199
|
+
const bundle = read.bundle;
|
|
3200
|
+
if (deps.diagnosis === void 0) {
|
|
3201
|
+
deps.writeStderr("오류 [REPAIR_RUNTIME_UNAVAILABLE]: 진단 통로를 쓸 수 없습니다.\n해결: 의존성을 설치한 뒤 다시 실행하세요.\n");
|
|
3202
|
+
return 1;
|
|
3203
|
+
}
|
|
3204
|
+
const provider = deps.diagnosis.providers[input.providerId](input.model);
|
|
3205
|
+
const preview = deps.diagnosis.prepare({
|
|
3206
|
+
specApproved: bundle.spec.approval === "matched",
|
|
3207
|
+
suite: {
|
|
3208
|
+
id: bundle.spec.suiteId,
|
|
3209
|
+
name: bundle.spec.suiteName
|
|
3210
|
+
},
|
|
3211
|
+
failures: bundle.failures.map((failure) => ({
|
|
3212
|
+
caseId: failure.caseId,
|
|
3213
|
+
caseName: failure.caseName,
|
|
3214
|
+
...failure.tool === void 0 ? {} : { tool: failure.tool },
|
|
3215
|
+
...failure.input === void 0 ? {} : { input: failure.input },
|
|
3216
|
+
...failure.approvedAs === void 0 ? {} : { approvedAs: failure.approvedAs },
|
|
3217
|
+
diagnostics: failure.diagnostics.map((diagnostic) => ({ ...diagnostic }))
|
|
3218
|
+
})),
|
|
3219
|
+
...bundle.process === void 0 ? {} : { processDiagnostics: bundle.process },
|
|
3220
|
+
tools: [],
|
|
3221
|
+
providerId: input.providerId,
|
|
3222
|
+
model: input.model,
|
|
3223
|
+
maxCases: input.maxCases,
|
|
3224
|
+
includeStderr: input.includeStderr
|
|
3225
|
+
});
|
|
3226
|
+
const confirmView = {
|
|
3227
|
+
providerId: input.providerId,
|
|
3228
|
+
model: input.model,
|
|
3229
|
+
totalFailures: bundle.failures.length,
|
|
3230
|
+
sentFailures: preview.request.failures.length,
|
|
3231
|
+
omittedFailures: preview.omitted.failures,
|
|
3232
|
+
maxCases: input.maxCases,
|
|
3233
|
+
approval: bundle.spec.approval,
|
|
3234
|
+
includeStderr: input.includeStderr,
|
|
3235
|
+
...preview.request.processDiagnostics === void 0 ? {} : { stderr: preview.request.processDiagnostics.stderr },
|
|
3236
|
+
requestBytes: preview.byteLength
|
|
3237
|
+
};
|
|
3238
|
+
if (!input.yes) {
|
|
3239
|
+
if (deps.reviewIO?.interactive !== true) {
|
|
3240
|
+
deps.writeStdout(renderRepairConfirm(confirmView));
|
|
3241
|
+
deps.writeStderr(REPAIR_CONFIRM_REQUIRED_LINE);
|
|
3242
|
+
return 1;
|
|
3243
|
+
}
|
|
3244
|
+
deps.reviewIO.write(renderRepairConfirm(confirmView));
|
|
3245
|
+
if (!await deps.reviewIO.confirm("보내시겠습니까?")) {
|
|
3246
|
+
deps.writeStdout(REPAIR_CANCELLED_LINE);
|
|
3247
|
+
return 0;
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
const dispatched = await deps.diagnosis.dispatch({
|
|
3251
|
+
provider,
|
|
3252
|
+
preview,
|
|
3253
|
+
approval: {
|
|
3254
|
+
approved: true,
|
|
3255
|
+
fingerprint: preview.fingerprint
|
|
3256
|
+
}
|
|
3257
|
+
});
|
|
3258
|
+
if (dispatched.status === "providerFailed") {
|
|
3259
|
+
deps.writeStderr(renderRepairProviderFailure(dispatched.failure));
|
|
3260
|
+
return 1;
|
|
3261
|
+
}
|
|
3262
|
+
if (dispatched.status === "invalid") {
|
|
3263
|
+
deps.writeStderr(REPAIR_INVALID_RESULT_LINE);
|
|
3264
|
+
return 1;
|
|
3265
|
+
}
|
|
3266
|
+
if (dispatched.status === "resultLimitExceeded") {
|
|
3267
|
+
deps.writeStderr(REPAIR_RESULT_LIMIT_LINE);
|
|
3268
|
+
return 1;
|
|
3269
|
+
}
|
|
3270
|
+
if (dispatched.status !== "diagnosis") {
|
|
3271
|
+
deps.writeStderr(REPAIR_APPROVAL_INVALIDATED_LINE);
|
|
3272
|
+
return 1;
|
|
3273
|
+
}
|
|
3274
|
+
deps.writeStdout(renderRepairResult({
|
|
3275
|
+
bundle,
|
|
3276
|
+
result: dispatched.result,
|
|
3277
|
+
providerId: input.providerId,
|
|
3278
|
+
model: input.model
|
|
3279
|
+
}));
|
|
3280
|
+
return 0;
|
|
3281
|
+
}
|
|
3282
|
+
//#endregion
|
|
3283
|
+
//#region src/replay-command.ts
|
|
3284
|
+
/**
|
|
3285
|
+
* 런타임 모듈을 못 불러 fallback 의존성이 쓰일 때 던진다.
|
|
3286
|
+
*
|
|
3287
|
+
* 타입을 따로 두는 이유는 이 실패가 **명세·카세트 문제와 구분돼야** 하기 때문이다. 그냥
|
|
3288
|
+
* 던지면 `validateSuite` 자리에서 `CLI_INTERNAL_ERROR` 로 잡혀 "다시 실행한 뒤 재현 정보와
|
|
3289
|
+
* 함께 이슈를 보고하세요" 가 나간다. 사용자는 자기 설치 문제로 버그 리포트를 쓰게 된다.
|
|
3290
|
+
* `repair` 가 `REPAIR_RUNTIME_UNAVAILABLE` 로 이미 가르고 있는 것과 같은 처리다.
|
|
3291
|
+
*/
|
|
3292
|
+
var ReplayRuntimeUnavailableError = class extends Error {
|
|
3293
|
+
constructor() {
|
|
3294
|
+
super("replay 실행에 필요한 모듈을 로드하지 못했습니다.");
|
|
3295
|
+
this.name = "ReplayRuntimeUnavailableError";
|
|
3296
|
+
}
|
|
3297
|
+
};
|
|
3298
|
+
/**
|
|
3299
|
+
* 명세 로드 실패 문안은 `test-command.ts` 의 `dictionary` 와 같은 값이다. 두 커맨드가 같은
|
|
3300
|
+
* 파일을 같은 이유로 거절하므로 문장이 갈리면 안 된다. 지금 복제인 것은 그 표가 module-private
|
|
3301
|
+
* 이기 때문이고, 공용 모듈로 빼는 것은 `test` 서브커맨드 소유자와 합의가 필요한 별도 작업이다.
|
|
3302
|
+
*/
|
|
3303
|
+
const dictionary$1 = {
|
|
3304
|
+
REPLAY_RUNTIME_UNAVAILABLE: {
|
|
3305
|
+
message: "replay 에 필요한 @mcpeak/runner · @mcpeak/record 를 로드하지 못했습니다.",
|
|
3306
|
+
hint: "의존성을 설치한 뒤 다시 실행하세요. 명세나 카세트 파일의 문제가 아닙니다."
|
|
3307
|
+
},
|
|
3308
|
+
SUITE_FORMAT_UNSUPPORTED: {
|
|
3309
|
+
message: "테스트 명세 형식을 지원하지 않습니다.",
|
|
3310
|
+
hint: "UTF-8로 저장한 .json 명세 파일을 사용하세요."
|
|
3311
|
+
},
|
|
3312
|
+
SUITE_READ_FAILED: {
|
|
3313
|
+
message: "테스트 명세 파일을 읽지 못했습니다.",
|
|
3314
|
+
hint: "명세 경로와 읽기 권한을 확인하세요."
|
|
3315
|
+
},
|
|
3316
|
+
SUITE_ENCODING_INVALID: {
|
|
3317
|
+
message: "테스트 명세 파일이 유효한 UTF-8이 아닙니다.",
|
|
3318
|
+
hint: "명세를 UTF-8 JSON으로 다시 저장하세요."
|
|
3319
|
+
},
|
|
3320
|
+
SUITE_JSON_INVALID: {
|
|
3321
|
+
message: "테스트 명세의 JSON 문법이 유효하지 않습니다.",
|
|
3322
|
+
hint: "JSON 문법과 쉼표, 따옴표를 확인하세요."
|
|
3323
|
+
},
|
|
3324
|
+
SUITE_VALIDATION_FAILED: {
|
|
3325
|
+
message: "MCP 테스트 명세가 유효하지 않습니다.",
|
|
3326
|
+
hint: "아래 명세 오류를 모두 수정하세요."
|
|
3327
|
+
},
|
|
3328
|
+
CLI_INTERNAL_ERROR: {
|
|
3329
|
+
message: "예상하지 못한 CLI 내부 오류가 발생했습니다.",
|
|
3330
|
+
hint: "다시 실행한 뒤 재현 정보와 함께 이슈를 보고하세요."
|
|
3331
|
+
},
|
|
3332
|
+
CASSETTE_NOT_FOUND: {
|
|
3333
|
+
message: "카세트 파일이 없습니다.",
|
|
3334
|
+
hint: "`mcpeak generate --cassette <path> --record` 로 먼저 녹화하세요."
|
|
3335
|
+
},
|
|
3336
|
+
CASSETTE_READ_FAILED: {
|
|
3337
|
+
message: "카세트를 읽지 못했습니다.",
|
|
3338
|
+
hint: "카세트 파일이 손상되지 않았는지 확인하고, 필요하면 --record 로 다시 녹화하세요."
|
|
3339
|
+
},
|
|
3340
|
+
CASSETTE_INCOMPLETE: {
|
|
3341
|
+
message: "카세트에 이 명세의 응답이 모두 들어 있지 않습니다.",
|
|
3342
|
+
hint: "명세를 고쳤다면 `mcpeak generate --cassette <path> --record` 로 다시 녹화하세요."
|
|
3343
|
+
},
|
|
3344
|
+
RUNNER_EXECUTION_FAILED: {
|
|
3345
|
+
message: "Runner 실행을 시작하지 못했습니다.",
|
|
3346
|
+
hint: "테스트 명세와 카세트가 같은 서버에서 나온 것인지 확인하세요."
|
|
3347
|
+
},
|
|
3348
|
+
RUNNER_FINALIZATION_FAILED: {
|
|
3349
|
+
message: "Runner 실행을 마치지 못했습니다.",
|
|
3350
|
+
hint: "카세트에 필요한 응답이 모두 들어 있는지 확인하세요."
|
|
3351
|
+
}
|
|
3352
|
+
};
|
|
3353
|
+
var ReplayCommandError = class extends Error {
|
|
3354
|
+
failure;
|
|
3355
|
+
constructor(failure) {
|
|
3356
|
+
super(failure.message);
|
|
3357
|
+
this.failure = failure;
|
|
3358
|
+
}
|
|
3359
|
+
};
|
|
3360
|
+
const usage = (message) => {
|
|
3361
|
+
throw new ReplayCommandError({
|
|
3362
|
+
code: "CLI_USAGE",
|
|
3363
|
+
message,
|
|
3364
|
+
hint: REPLAY_USAGE_HINT
|
|
3365
|
+
});
|
|
3366
|
+
};
|
|
3367
|
+
/** replay 에서 의미가 없는 옵션. 조용히 무시하지 않고 거절한다. ADR-0028. */
|
|
3368
|
+
const SERVER_ONLY_OPTIONS = /* @__PURE__ */ new Set([
|
|
3369
|
+
"--command",
|
|
3370
|
+
"--arg",
|
|
3371
|
+
"--stderr-lines"
|
|
3372
|
+
]);
|
|
3373
|
+
function parseReplayCommand(argv) {
|
|
3374
|
+
let suitePath;
|
|
3375
|
+
let cassettePath;
|
|
3376
|
+
for (let index = 0; index < argv.length; index++) {
|
|
3377
|
+
const token = argv[index] ?? "";
|
|
3378
|
+
const [name] = token.startsWith("--") ? token.split("=", 1) : [token];
|
|
3379
|
+
if (SERVER_ONLY_OPTIONS.has(name ?? "")) usage(`replay 는 서버를 띄우지 않습니다. ${name} 는 test 명령에서 쓰세요.`);
|
|
3380
|
+
if (token === "--cassette" || token.startsWith("--cassette=")) {
|
|
3381
|
+
const value = token.startsWith("--cassette=") ? token.slice(11) : argv[++index];
|
|
3382
|
+
if (value === void 0 || value === "") usage("--cassette 에 카세트 파일 경로가 필요합니다.");
|
|
3383
|
+
cassettePath = value;
|
|
3384
|
+
continue;
|
|
3385
|
+
}
|
|
3386
|
+
if (token.startsWith("--")) usage(`알 수 없는 옵션입니다: ${token}`);
|
|
3387
|
+
if (suitePath !== void 0) usage(`테스트 명세는 하나만 받습니다: ${token}`);
|
|
3388
|
+
suitePath = token;
|
|
3389
|
+
}
|
|
3390
|
+
if (suitePath === void 0 || suitePath === "") usage("테스트 명세 JSON 경로가 필요합니다.");
|
|
3391
|
+
if (cassettePath === void 0) usage("--cassette 에 재생할 카세트 파일 경로가 필요합니다.");
|
|
3392
|
+
return Object.freeze({
|
|
3393
|
+
suitePath,
|
|
3394
|
+
cassettePath
|
|
3395
|
+
});
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* 실서버 자리를 채우는 오프라인 클라이언트. replay 모드의 `cassetteClient` 는 `listTools` 와
|
|
3399
|
+
* `callTool` 을 가로채므로 이 둘은 도달 불가다. 조용히 빈 값을 돌려주면 모드가 새어도 화면이
|
|
3400
|
+
* 정상처럼 보이므로 던진다. ADR-0028.
|
|
3401
|
+
*/
|
|
3402
|
+
const offlineClient = {
|
|
3403
|
+
async listTools() {
|
|
3404
|
+
throw new Error("replay 는 MCP 서버를 호출하지 않습니다.");
|
|
3405
|
+
},
|
|
3406
|
+
async callTool() {
|
|
3407
|
+
throw new Error("replay 는 MCP 서버를 호출하지 않습니다.");
|
|
3408
|
+
},
|
|
3409
|
+
async close() {}
|
|
3410
|
+
};
|
|
3411
|
+
/**
|
|
3412
|
+
* 카세트가 이 명세를 덮는지 실행 전에 확인한다.
|
|
3413
|
+
*
|
|
3414
|
+
* 재생 중에 나는 미스는 `record` 가 어느 필드가 다른지까지 적어 주지만, 그 문장이 화면까지
|
|
3415
|
+
* 오지 못한다. `runner` 가 호출 실패를 `OPERATION_FAILED` 로 감싸면서 "MCP 서버 프로세스와
|
|
3416
|
+
* 연결 상태를 확인하세요" 로 바꾸기 때문이다. replay 에는 확인할 서버가 없으므로 그 안내는
|
|
3417
|
+
* 사용자를 없는 곳으로 보낸다. 그래서 시작 전에 우리가 말한다.
|
|
3418
|
+
*/
|
|
3419
|
+
function missingFromCassette(suite, cassette) {
|
|
3420
|
+
const keys = new Set(cassette.interactions.map((interaction) => interaction.key));
|
|
3421
|
+
const missing = [];
|
|
3422
|
+
for (const testCase of suite.cases) {
|
|
3423
|
+
if (testCase.operation.type === "listTools") {
|
|
3424
|
+
if (cassette.tools === void 0) missing.push(`${testCase.id}: listTools 응답`);
|
|
3425
|
+
continue;
|
|
3426
|
+
}
|
|
3427
|
+
const { tool, input } = testCase.operation;
|
|
3428
|
+
if (!keys.has((0, _mcpeak_record.matchKey)(tool, input))) missing.push(`${testCase.id}: ${tool}(${JSON.stringify(input)})`);
|
|
3429
|
+
}
|
|
3430
|
+
return missing;
|
|
3431
|
+
}
|
|
3432
|
+
/** 경고 한 줄이 화면을 덮지 않도록 상한을 둔다. */
|
|
3433
|
+
const MAX_REDACTED_PATHS = 5;
|
|
3434
|
+
/**
|
|
3435
|
+
* 마스킹된 값의 JSON 경로를 모은다. 판정이 읽는 자리는 `response.content` 하나이므로 거기만 본다.
|
|
3436
|
+
* 값이 JSON 문자열이면 그 안까지 들어간다 — 카세트가 문자열 본문도 구조화해 마스킹하기 때문이다.
|
|
3437
|
+
*/
|
|
3438
|
+
function redactedPaths(cassette) {
|
|
3439
|
+
const paths = [];
|
|
3440
|
+
let total = 0;
|
|
3441
|
+
const add = (path) => {
|
|
3442
|
+
total++;
|
|
3443
|
+
if (paths.length < MAX_REDACTED_PATHS) paths.push(path);
|
|
3444
|
+
};
|
|
3445
|
+
const visit = (value, path) => {
|
|
3446
|
+
if (typeof value === "string") {
|
|
3447
|
+
if (value === _mcpeak_record.REDACTED) add(path);
|
|
3448
|
+
else if (value.includes(_mcpeak_record.REDACTED)) try {
|
|
3449
|
+
visit(JSON.parse(value), path);
|
|
3450
|
+
} catch {
|
|
3451
|
+
add(path);
|
|
3452
|
+
}
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
if (Array.isArray(value)) {
|
|
3456
|
+
for (const [index, item] of value.entries()) visit(item, `${path}[${index}]`);
|
|
3457
|
+
return;
|
|
3458
|
+
}
|
|
3459
|
+
if (typeof value === "object" && value !== null) for (const [key, item] of Object.entries(value)) visit(item, `${path}.${key}`);
|
|
3460
|
+
};
|
|
3461
|
+
for (const [index, interaction] of cassette.interactions.entries()) visit(interaction.response.content, `interactions[${index}].response.content`);
|
|
3462
|
+
return {
|
|
3463
|
+
paths,
|
|
3464
|
+
total
|
|
3465
|
+
};
|
|
3466
|
+
}
|
|
3467
|
+
const detail = (error) => error instanceof Error && error.message !== "" ? `\n${error.message}` : "";
|
|
3468
|
+
/** 런타임 미가용이면 그 코드를, 아니면 `undefined`. 호출부가 자기 기본 코드를 고른다. */
|
|
3469
|
+
const runtimeCode = (error) => error instanceof ReplayRuntimeUnavailableError ? "REPLAY_RUNTIME_UNAVAILABLE" : void 0;
|
|
3470
|
+
const format$1 = (failure) => {
|
|
3471
|
+
const issues = (failure.issues ?? []).map((issue) => `\n- [${issue.code}] ${issue.path}: ${issue.message}`);
|
|
3472
|
+
return `오류 [${failure.code}]: ${failure.message}${issues.join("")}\n해결: ${failure.hint}\n`;
|
|
3473
|
+
};
|
|
3474
|
+
const writeFailure$1 = (dependencies, failure) => {
|
|
3475
|
+
dependencies.writeStderr(format$1(failure));
|
|
3476
|
+
return 1;
|
|
3477
|
+
};
|
|
3478
|
+
async function runReplayCommand(argv, dependencies) {
|
|
3479
|
+
let input;
|
|
3480
|
+
try {
|
|
3481
|
+
input = parseReplayCommand(argv);
|
|
3482
|
+
} catch (error) {
|
|
3483
|
+
if (error instanceof ReplayCommandError) return writeFailure$1(dependencies, error.failure);
|
|
3484
|
+
throw error;
|
|
3485
|
+
}
|
|
3486
|
+
if (!input.suitePath.toLowerCase().endsWith(".json")) return writeFailure$1(dependencies, {
|
|
3487
|
+
code: "SUITE_FORMAT_UNSUPPORTED",
|
|
3488
|
+
...dictionary$1.SUITE_FORMAT_UNSUPPORTED
|
|
3489
|
+
});
|
|
3490
|
+
let bytes;
|
|
3491
|
+
try {
|
|
3492
|
+
bytes = await dependencies.readFile(input.suitePath);
|
|
3493
|
+
} catch {
|
|
3494
|
+
return writeFailure$1(dependencies, {
|
|
3495
|
+
code: "SUITE_READ_FAILED",
|
|
3496
|
+
...dictionary$1.SUITE_READ_FAILED
|
|
3497
|
+
});
|
|
3498
|
+
}
|
|
3499
|
+
let text;
|
|
3500
|
+
try {
|
|
3501
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
3502
|
+
} catch {
|
|
3503
|
+
return writeFailure$1(dependencies, {
|
|
3504
|
+
code: "SUITE_ENCODING_INVALID",
|
|
3505
|
+
...dictionary$1.SUITE_ENCODING_INVALID
|
|
3506
|
+
});
|
|
3507
|
+
}
|
|
3508
|
+
let parsed;
|
|
3509
|
+
try {
|
|
3510
|
+
parsed = JSON.parse(text);
|
|
3511
|
+
} catch {
|
|
3512
|
+
return writeFailure$1(dependencies, {
|
|
3513
|
+
code: "SUITE_JSON_INVALID",
|
|
3514
|
+
...dictionary$1.SUITE_JSON_INVALID
|
|
3515
|
+
});
|
|
3516
|
+
}
|
|
3517
|
+
let validated;
|
|
3518
|
+
try {
|
|
3519
|
+
validated = dependencies.validateSuite(parsed);
|
|
3520
|
+
} catch (error) {
|
|
3521
|
+
const code = runtimeCode(error) ?? "CLI_INTERNAL_ERROR";
|
|
3522
|
+
return writeFailure$1(dependencies, {
|
|
3523
|
+
code,
|
|
3524
|
+
...dictionary$1[code]
|
|
3525
|
+
});
|
|
3526
|
+
}
|
|
3527
|
+
if (!validated.valid) return writeFailure$1(dependencies, {
|
|
3528
|
+
code: "SUITE_VALIDATION_FAILED",
|
|
3529
|
+
...dictionary$1.SUITE_VALIDATION_FAILED,
|
|
3530
|
+
issues: validated.issues
|
|
3531
|
+
});
|
|
3532
|
+
let cassette;
|
|
3533
|
+
try {
|
|
3534
|
+
cassette = await dependencies.loadCassette(input.cassettePath);
|
|
3535
|
+
} catch (error) {
|
|
3536
|
+
const runtime = runtimeCode(error);
|
|
3537
|
+
return writeFailure$1(dependencies, runtime === void 0 ? {
|
|
3538
|
+
code: "CASSETTE_READ_FAILED",
|
|
3539
|
+
message: `${dictionary$1.CASSETTE_READ_FAILED.message}${detail(error)}`,
|
|
3540
|
+
hint: dictionary$1.CASSETTE_READ_FAILED.hint
|
|
3541
|
+
} : {
|
|
3542
|
+
code: runtime,
|
|
3543
|
+
...dictionary$1[runtime]
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
if (cassette === null) return writeFailure$1(dependencies, {
|
|
3547
|
+
code: "CASSETTE_NOT_FOUND",
|
|
3548
|
+
message: `카세트 파일이 없습니다: ${input.cassettePath}`,
|
|
3549
|
+
hint: dictionary$1.CASSETTE_NOT_FOUND.hint
|
|
3550
|
+
});
|
|
3551
|
+
const missing = missingFromCassette(validated.value, cassette);
|
|
3552
|
+
if (missing.length > 0) return writeFailure$1(dependencies, {
|
|
3553
|
+
code: "CASSETTE_INCOMPLETE",
|
|
3554
|
+
message: [`카세트에 없는 케이스가 ${missing.length}개 있습니다: ${input.cassettePath}`, ...missing.map((item) => ` ${item}`)].join("\n"),
|
|
3555
|
+
hint: dictionary$1.CASSETTE_INCOMPLETE.hint
|
|
3556
|
+
});
|
|
3557
|
+
/**
|
|
3558
|
+
* 마스킹된 값은 판정을 바꿀 수 있다. 거부하지 않고 알린다 — 대부분의 카세트는 마스킹 대상이
|
|
3559
|
+
* 없어 재생이 라이브와 정확히 일치한다. ADR-0028.
|
|
3560
|
+
*/
|
|
3561
|
+
const redacted = redactedPaths(cassette);
|
|
3562
|
+
if (redacted.total > 0) dependencies.writeStderr([
|
|
3563
|
+
"→ 카세트에 마스킹된 값이 있습니다. 이 자리의 판정은 실제 서버와 다를 수 있습니다.",
|
|
3564
|
+
...redacted.paths.map((path) => ` ${path}`),
|
|
3565
|
+
...redacted.total > redacted.paths.length ? [` → 마스킹된 경로 ${redacted.total}개 중 ${redacted.paths.length}개만 표시합니다.`] : [],
|
|
3566
|
+
" → 이 케이스의 결과를 근거로 쓰기 전에 test 명령으로 한 번 확인하세요.",
|
|
3567
|
+
""
|
|
3568
|
+
].join("\n"));
|
|
3569
|
+
const client = (0, _mcpeak_record.cassetteClient)(offlineClient, {
|
|
3570
|
+
cassette,
|
|
3571
|
+
mode: "replay",
|
|
3572
|
+
cassettePath: input.cassettePath
|
|
3573
|
+
});
|
|
3574
|
+
let execution;
|
|
3575
|
+
try {
|
|
3576
|
+
execution = dependencies.startRunner({
|
|
3577
|
+
client,
|
|
3578
|
+
suite: validated.value
|
|
3579
|
+
});
|
|
3580
|
+
} catch {
|
|
3581
|
+
return writeFailure$1(dependencies, {
|
|
3582
|
+
code: "RUNNER_EXECUTION_FAILED",
|
|
3583
|
+
...dictionary$1.RUNNER_EXECUTION_FAILED
|
|
3584
|
+
});
|
|
3585
|
+
}
|
|
3586
|
+
let report;
|
|
3587
|
+
try {
|
|
3588
|
+
report = await dependencies.finalize({
|
|
3589
|
+
execution,
|
|
3590
|
+
shutdown: {
|
|
3591
|
+
client,
|
|
3592
|
+
close: () => client.close(),
|
|
3593
|
+
forceClose: () => client.close()
|
|
3594
|
+
}
|
|
3595
|
+
});
|
|
3596
|
+
} catch (error) {
|
|
3597
|
+
return writeFailure$1(dependencies, {
|
|
3598
|
+
code: "RUNNER_FINALIZATION_FAILED",
|
|
3599
|
+
message: `${dictionary$1.RUNNER_FINALIZATION_FAILED.message}${detail(error)}`,
|
|
3600
|
+
hint: dictionary$1.RUNNER_FINALIZATION_FAILED.hint
|
|
3601
|
+
});
|
|
3602
|
+
}
|
|
3603
|
+
dependencies.writeStdout(dependencies.renderReport(report, { color: dependencies.colorEnabled }));
|
|
3604
|
+
return report.status === "passed" ? 0 : 1;
|
|
3605
|
+
}
|
|
3606
|
+
//#endregion
|
|
3607
|
+
//#region src/determinism-capture.ts
|
|
3608
|
+
/**
|
|
3609
|
+
* 관찰용 평문 사본. `checkDeterminism` 은 `canonicalJson` 으로 비교하는데 그것은 undefined
|
|
3610
|
+
* 필드·비평문 인스턴스·순환 참조를 TypeError 로 거절한다. 캡처 시점에 한 번 통과시켜 두면
|
|
3611
|
+
* 비교 단계에서 터지지 않는다. executor 의 `clone` 과 같은 방식이다.
|
|
3612
|
+
*
|
|
3613
|
+
* 실패하면(순환 참조 등) null 을 돌려주고 호출 측이 캡처를 버린다. 여기서 던지면 부수적
|
|
3614
|
+
* 관찰 때문에 1회차 시험 실행이 죽는다.
|
|
3615
|
+
*/
|
|
3616
|
+
const plainCopy = (value) => {
|
|
3617
|
+
try {
|
|
3618
|
+
return { value: JSON.parse(JSON.stringify(value)) };
|
|
3619
|
+
} catch {
|
|
3620
|
+
return null;
|
|
3621
|
+
}
|
|
3622
|
+
};
|
|
3623
|
+
function createDeterminismCapture(inner) {
|
|
3624
|
+
const slots = [];
|
|
3625
|
+
let current = -1;
|
|
3626
|
+
const record = (index, response) => {
|
|
3627
|
+
const slot = slots[index];
|
|
3628
|
+
if (slot === void 0 || slot.captured) return;
|
|
3629
|
+
const copied = plainCopy(response);
|
|
3630
|
+
if (copied === null) return;
|
|
3631
|
+
slot.captured = true;
|
|
3632
|
+
slot.response = copied.value;
|
|
3633
|
+
};
|
|
3634
|
+
const onEvent = (event) => {
|
|
3635
|
+
if (event.type === "caseStarted") {
|
|
3636
|
+
current = event.caseIndex;
|
|
3637
|
+
slots[event.caseIndex] = {
|
|
3638
|
+
caseId: event.caseId,
|
|
3639
|
+
caseName: event.case.name,
|
|
3640
|
+
toolName: event.case.operation.type === "callTool" ? event.case.operation.tool : null,
|
|
3641
|
+
status: "notRun",
|
|
3642
|
+
assertionStatuses: [],
|
|
3643
|
+
captured: false
|
|
3644
|
+
};
|
|
3645
|
+
} else if (event.type === "caseCompleted") {
|
|
3646
|
+
const slot = slots[event.caseIndex];
|
|
3647
|
+
if (slot === void 0) return;
|
|
3648
|
+
slot.status = event.result.status;
|
|
3649
|
+
slot.assertionStatuses = event.result.assertions.map((assertion) => assertion.status);
|
|
3650
|
+
} else if (event.type === "suiteCompleted") event.report.cases.forEach((result, index) => {
|
|
3651
|
+
if (slots[index] !== void 0) return;
|
|
3652
|
+
slots[index] = {
|
|
3653
|
+
caseId: result.spec.id,
|
|
3654
|
+
caseName: result.spec.name,
|
|
3655
|
+
toolName: result.spec.operation.type === "callTool" ? result.spec.operation.tool : null,
|
|
3656
|
+
status: result.status,
|
|
3657
|
+
assertionStatuses: result.assertions.map((assertion) => assertion.status),
|
|
3658
|
+
captured: false
|
|
3659
|
+
};
|
|
3660
|
+
});
|
|
3661
|
+
};
|
|
3662
|
+
return {
|
|
3663
|
+
client: {
|
|
3664
|
+
listTools: () => {
|
|
3665
|
+
const at = current;
|
|
3666
|
+
return inner.listTools().then((tools) => {
|
|
3667
|
+
record(at, tools);
|
|
3668
|
+
return tools;
|
|
3669
|
+
});
|
|
3670
|
+
},
|
|
3671
|
+
callTool: (name, args) => {
|
|
3672
|
+
const at = current;
|
|
3673
|
+
return inner.callTool(name, args).then((result) => {
|
|
3674
|
+
record(at, result);
|
|
3675
|
+
return result;
|
|
3676
|
+
});
|
|
3677
|
+
},
|
|
3678
|
+
close: () => inner.close()
|
|
3679
|
+
},
|
|
3680
|
+
onEvent,
|
|
3681
|
+
observations: () => slots.map((slot) => ({
|
|
3682
|
+
caseId: slot.caseId,
|
|
3683
|
+
caseName: slot.caseName,
|
|
3684
|
+
toolName: slot.toolName,
|
|
3685
|
+
status: slot.status,
|
|
3686
|
+
assertionStatuses: slot.assertionStatuses,
|
|
3687
|
+
...slot.captured ? { response: slot.response } : {}
|
|
3688
|
+
}))
|
|
3689
|
+
};
|
|
3690
|
+
}
|
|
3691
|
+
//#endregion
|
|
3692
|
+
//#region src/test-command.ts
|
|
3693
|
+
/** --stderr-lines 기본값. 설계 문서 §6. */
|
|
3694
|
+
const DEFAULT_STDERR_LINES = 20;
|
|
3695
|
+
const dictionary = {
|
|
3696
|
+
SUITE_FORMAT_UNSUPPORTED: {
|
|
3697
|
+
message: "테스트 명세 형식을 지원하지 않습니다.",
|
|
3698
|
+
hint: "UTF-8로 저장한 .json 명세 파일을 사용하세요."
|
|
3699
|
+
},
|
|
3700
|
+
SUITE_READ_FAILED: {
|
|
3701
|
+
message: "테스트 명세 파일을 읽지 못했습니다.",
|
|
3702
|
+
hint: "명세 경로와 읽기 권한을 확인하세요."
|
|
3703
|
+
},
|
|
3704
|
+
SUITE_ENCODING_INVALID: {
|
|
3705
|
+
message: "테스트 명세 파일이 유효한 UTF-8이 아닙니다.",
|
|
3706
|
+
hint: "명세를 UTF-8 JSON으로 다시 저장하세요."
|
|
3707
|
+
},
|
|
3708
|
+
SUITE_JSON_INVALID: {
|
|
3709
|
+
message: "테스트 명세의 JSON 문법이 유효하지 않습니다.",
|
|
3710
|
+
hint: "JSON 문법과 쉼표, 따옴표를 확인하세요."
|
|
3711
|
+
},
|
|
3712
|
+
SUITE_VALIDATION_FAILED: {
|
|
3713
|
+
message: "MCP 테스트 명세가 유효하지 않습니다.",
|
|
3714
|
+
hint: "아래 명세 오류를 모두 수정하세요."
|
|
3715
|
+
},
|
|
3716
|
+
MCP_CONNECTION_FAILED: {
|
|
3717
|
+
message: "MCP 서버 연결에 실패했습니다.",
|
|
3718
|
+
hint: "command 실행 가능 여부와 stdio MCP 서버 설정을 확인하세요."
|
|
3719
|
+
},
|
|
3720
|
+
RUNNER_EXECUTION_FAILED: {
|
|
3721
|
+
message: "Runner 실행을 시작하지 못했습니다.",
|
|
3722
|
+
hint: "테스트 명세와 Runner 설정을 확인하세요."
|
|
3723
|
+
},
|
|
3724
|
+
RUNNER_FINALIZATION_FAILED: {
|
|
3725
|
+
message: "Runner 실행 또는 MCP 서버 종료에 실패했습니다.",
|
|
3726
|
+
hint: "서버 응답과 종료 상태를 확인하세요."
|
|
3727
|
+
},
|
|
3728
|
+
JUNIT_WRITE_FAILED: {
|
|
3729
|
+
message: "JUnit XML 파일을 쓰지 못했습니다.",
|
|
3730
|
+
hint: "`--junit` 경로의 디렉터리가 존재하는지와 쓰기 권한을 확인하세요."
|
|
3731
|
+
},
|
|
3732
|
+
REPAIR_BUNDLE_WRITE_FAILED: {
|
|
3733
|
+
message: "repair 번들 파일을 쓰지 못했습니다.",
|
|
3734
|
+
hint: "`--repair-bundle` 경로의 디렉터리가 존재하는지와 쓰기 권한을 확인하세요."
|
|
3735
|
+
},
|
|
3736
|
+
RESET_COMMAND_FAILED: {
|
|
3737
|
+
message: "초기화 명령이 실패했습니다.",
|
|
3738
|
+
hint: "`--reset-cmd` 명령이 단독으로 성공하는지 확인한 뒤 다시 실행하세요."
|
|
3739
|
+
},
|
|
3740
|
+
CLI_INTERNAL_ERROR: {
|
|
3741
|
+
message: "예상하지 못한 CLI 내부 오류가 발생했습니다.",
|
|
3742
|
+
hint: "다시 실행한 뒤 재현 정보와 함께 이슈를 보고하세요."
|
|
3743
|
+
}
|
|
3744
|
+
};
|
|
3745
|
+
var CliCommandError = class extends Error {
|
|
3746
|
+
failure;
|
|
3747
|
+
constructor(failure) {
|
|
3748
|
+
super(failure.message);
|
|
3749
|
+
this.failure = failure;
|
|
3750
|
+
}
|
|
3751
|
+
};
|
|
3752
|
+
const fail = (message) => {
|
|
3753
|
+
throw new CliCommandError({
|
|
3754
|
+
code: "CLI_USAGE",
|
|
3755
|
+
message,
|
|
3756
|
+
hint: TEST_USAGE_HINT
|
|
3757
|
+
});
|
|
3758
|
+
};
|
|
3759
|
+
function parseTestCommand(argv) {
|
|
3760
|
+
const suitePath = argv[0] ?? "";
|
|
3761
|
+
if (suitePath === "") fail("테스트 명세 JSON 경로가 필요합니다.");
|
|
3762
|
+
let command;
|
|
3763
|
+
let json = false;
|
|
3764
|
+
let junitPath;
|
|
3765
|
+
let repairBundlePath;
|
|
3766
|
+
let determinism = false;
|
|
3767
|
+
let resetCmd;
|
|
3768
|
+
let stderrLines;
|
|
3769
|
+
const args = [];
|
|
3770
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
3771
|
+
const token = argv[index] ?? "";
|
|
3772
|
+
if (token === "--command" || token.startsWith("--command=")) {
|
|
3773
|
+
if (command !== void 0) fail("`--command`는 한 번만 사용할 수 있습니다.");
|
|
3774
|
+
let value;
|
|
3775
|
+
if (token === "--command") {
|
|
3776
|
+
const next = argv[++index];
|
|
3777
|
+
if (next === void 0) throw new CliCommandError({
|
|
3778
|
+
code: "CLI_USAGE",
|
|
3779
|
+
message: "`--command` 옵션 값이 필요합니다.",
|
|
3780
|
+
hint: TEST_USAGE_HINT
|
|
3781
|
+
});
|
|
3782
|
+
value = next;
|
|
3783
|
+
} else value = token.slice(10);
|
|
3784
|
+
if (value === "") fail("`--command` 옵션 값이 필요합니다.");
|
|
3785
|
+
if (value.startsWith("--")) fail("`--command` 옵션 값이 필요합니다.");
|
|
3786
|
+
command = value;
|
|
3787
|
+
} else if (token === "--arg" || token.startsWith("--arg=")) {
|
|
3788
|
+
let value;
|
|
3789
|
+
if (token === "--arg") {
|
|
3790
|
+
const next = argv[++index];
|
|
3791
|
+
if (next === void 0) throw new CliCommandError({
|
|
3792
|
+
code: "CLI_USAGE",
|
|
3793
|
+
message: "`--arg` 옵션 값이 필요합니다.",
|
|
3794
|
+
hint: TEST_USAGE_HINT
|
|
3795
|
+
});
|
|
3796
|
+
value = next;
|
|
3797
|
+
} else value = token.slice(6);
|
|
3798
|
+
args.push(value);
|
|
3799
|
+
} else if (token === "--junit" || token.startsWith("--junit=")) {
|
|
3800
|
+
if (junitPath !== void 0) fail("`--junit`은 한 번만 사용할 수 있습니다.");
|
|
3801
|
+
let value;
|
|
3802
|
+
if (token === "--junit") {
|
|
3803
|
+
const next = argv[++index];
|
|
3804
|
+
if (next === void 0) throw new CliCommandError({
|
|
3805
|
+
code: "CLI_USAGE",
|
|
3806
|
+
message: "`--junit` 옵션 값이 필요합니다.",
|
|
3807
|
+
hint: TEST_USAGE_HINT
|
|
3808
|
+
});
|
|
3809
|
+
value = next;
|
|
3810
|
+
} else value = token.slice(8);
|
|
3811
|
+
if (value === "") fail("`--junit` 옵션 값이 필요합니다.");
|
|
3812
|
+
if (value.startsWith("--")) fail("`--junit` 옵션 값이 필요합니다.");
|
|
3813
|
+
junitPath = value;
|
|
3814
|
+
} else if (token === "--repair-bundle" || token.startsWith("--repair-bundle=")) {
|
|
3815
|
+
if (repairBundlePath !== void 0) fail("`--repair-bundle`은 한 번만 사용할 수 있습니다.");
|
|
3816
|
+
let value;
|
|
3817
|
+
if (token === "--repair-bundle") {
|
|
3818
|
+
const next = argv[++index];
|
|
3819
|
+
if (next === void 0) throw new CliCommandError({
|
|
3820
|
+
code: "CLI_USAGE",
|
|
3821
|
+
message: "`--repair-bundle` 옵션 값이 필요합니다.",
|
|
3822
|
+
hint: TEST_USAGE_HINT
|
|
3823
|
+
});
|
|
3824
|
+
value = next;
|
|
3825
|
+
} else value = token.slice(16);
|
|
3826
|
+
if (value === "") fail("`--repair-bundle` 옵션 값이 필요합니다.");
|
|
3827
|
+
if (value.startsWith("--")) fail("`--repair-bundle` 옵션 값이 필요합니다.");
|
|
3828
|
+
repairBundlePath = value;
|
|
3829
|
+
} else if (token === "--reset-cmd" || token.startsWith("--reset-cmd=")) {
|
|
3830
|
+
if (resetCmd !== void 0) fail("`--reset-cmd`는 한 번만 사용할 수 있습니다.");
|
|
3831
|
+
let value;
|
|
3832
|
+
if (token === "--reset-cmd") {
|
|
3833
|
+
const next = argv[++index];
|
|
3834
|
+
if (next === void 0) throw new CliCommandError({
|
|
3835
|
+
code: "CLI_USAGE",
|
|
3836
|
+
message: "`--reset-cmd` 옵션 값이 필요합니다.",
|
|
3837
|
+
hint: TEST_USAGE_HINT
|
|
3838
|
+
});
|
|
3839
|
+
value = next;
|
|
3840
|
+
} else value = token.slice(12);
|
|
3841
|
+
if (value.trim() === "") fail("`--reset-cmd` 옵션 값이 필요합니다.");
|
|
3842
|
+
if (value.startsWith("--")) fail("`--reset-cmd` 옵션 값이 필요합니다.");
|
|
3843
|
+
resetCmd = value;
|
|
3844
|
+
} else if (token === "--determinism") determinism = true;
|
|
3845
|
+
else if (token.startsWith("--determinism=")) fail("`--determinism`은 값을 받지 않습니다.");
|
|
3846
|
+
else if (token === "--stderr-lines" || token.startsWith("--stderr-lines=")) {
|
|
3847
|
+
if (stderrLines !== void 0) fail("`--stderr-lines`는 한 번만 사용할 수 있습니다.");
|
|
3848
|
+
let value;
|
|
3849
|
+
if (token === "--stderr-lines") {
|
|
3850
|
+
const next = argv[++index];
|
|
3851
|
+
if (next === void 0) throw new CliCommandError({
|
|
3852
|
+
code: "CLI_USAGE",
|
|
3853
|
+
message: "`--stderr-lines` 옵션 값이 필요합니다.",
|
|
3854
|
+
hint: TEST_USAGE_HINT
|
|
3855
|
+
});
|
|
3856
|
+
value = next;
|
|
3857
|
+
} else value = token.slice(15);
|
|
3858
|
+
if (!/^\d+$/.test(value)) fail("`--stderr-lines` 값은 0 이상의 정수여야 합니다.");
|
|
3859
|
+
const parsedLines = Number.parseInt(value, 10);
|
|
3860
|
+
if (!Number.isSafeInteger(parsedLines)) fail("`--stderr-lines` 값은 0 이상의 정수여야 합니다.");
|
|
3861
|
+
stderrLines = parsedLines;
|
|
3862
|
+
} else if (token === "--json") {
|
|
3863
|
+
if (json) fail("`--json`은 한 번만 사용할 수 있습니다.");
|
|
3864
|
+
json = true;
|
|
3865
|
+
} else if (token.startsWith("--json=")) fail("`--json`은 값을 받지 않습니다.");
|
|
3866
|
+
else if (token.startsWith("-")) fail(`지원하지 않는 test 옵션 '${token}'입니다.`);
|
|
3867
|
+
else fail(`추가 위치 인자 '${token}'는 허용되지 않습니다.`);
|
|
3868
|
+
}
|
|
3869
|
+
if (command === void 0) throw new CliCommandError({
|
|
3870
|
+
code: "CLI_USAGE",
|
|
3871
|
+
message: "`--command` 옵션이 필요합니다.",
|
|
3872
|
+
hint: TEST_USAGE_HINT
|
|
3873
|
+
});
|
|
3874
|
+
return Object.freeze({
|
|
3875
|
+
suitePath,
|
|
3876
|
+
command,
|
|
3877
|
+
args: Object.freeze(args),
|
|
3878
|
+
json,
|
|
3879
|
+
junitPath,
|
|
3880
|
+
repairBundlePath,
|
|
3881
|
+
determinism,
|
|
3882
|
+
resetCmd,
|
|
3883
|
+
stderrLines: stderrLines ?? DEFAULT_STDERR_LINES
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
const escapeTerminalText = (value) => Array.from(value, (character) => {
|
|
3887
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
3888
|
+
return codePoint <= 31 || codePoint >= 127 && codePoint <= 159 || codePoint === 8232 || codePoint === 8233 ? `\\u${codePoint.toString(16).padStart(4, "0")}` : character;
|
|
3889
|
+
}).join("");
|
|
3890
|
+
/**
|
|
3891
|
+
* 머리글은 검사 종류마다 다르다. `minLength: 0` 은 입력이 아니라 단언의 문제이고,
|
|
3892
|
+
* `SCHEMA_NOT_ANALYZABLE` 은 명세가 아니라 서버 스키마를 못 읽었다는 뜻이다. 둘 중 어느
|
|
3893
|
+
* 것이든 입력 머리글 아래 붙이면 읽는 사람이 멀쩡한 입력을 고치러 간다. 설계 문서 §7.2.
|
|
3894
|
+
*/
|
|
3895
|
+
const FINDING_HEADING = {
|
|
3896
|
+
inputContract: (caseId) => `참고: ${caseId} 의 입력이 서버 선언과 다릅니다`,
|
|
3897
|
+
assertionSubstance: (caseId) => `참고: ${caseId} 의 단언은 무엇이 와도 통과합니다`,
|
|
3898
|
+
rejectionIntent: (caseId) => `참고: ${caseId} 는 거절을 기대하지만 선언을 어기지 않습니다`,
|
|
3899
|
+
skipped: (caseId) => `참고: ${caseId} 의 입력 검사를 건너뛰었습니다`
|
|
3900
|
+
};
|
|
3901
|
+
/**
|
|
3902
|
+
* 위반이 먼저, 건너뜀이 맨 뒤다. 위반 사이에서는 입력 계약이 먼저다. 명세를 고칠 때 입력이
|
|
3903
|
+
* 먼저 맞아야 단언을 볼 수 있다. 건너뜀이 맨 뒤인 이유는 그것만 있을 때 위에 아무 위반도
|
|
3904
|
+
* 없다는 사실이 먼저 읽혀야 하기 때문이다.
|
|
3905
|
+
*/
|
|
3906
|
+
const FINDING_GROUP_ORDER = [
|
|
3907
|
+
"inputContract",
|
|
3908
|
+
"assertionSubstance",
|
|
3909
|
+
"rejectionIntent",
|
|
3910
|
+
"skipped"
|
|
3911
|
+
];
|
|
3912
|
+
/** 결정론성 결과 블록의 머리글. 설계 문서 §8. */
|
|
3913
|
+
const DETERMINISM_HEADING = "결정론성 확인";
|
|
3914
|
+
/** `(12/12)` 와 `(12/12, 제외 2: 실행되지 않은 케이스)`. 설계 문서 §8. */
|
|
3915
|
+
const determinismCounts = (result) => result.skipped === 0 ? `(${result.compared}/${result.compared})` : `(${result.compared}/${result.compared}, 제외 ${result.skipped}: 실행되지 않은 케이스)`;
|
|
3916
|
+
/**
|
|
3917
|
+
* 결정론성 블록 전문. 문구는 설계 문서 §8 이 사양이다. 케이스 블록은 runner 의
|
|
3918
|
+
* `describeDeterminismDifference` 가 만들고 여기서 들여쓰기를 덧붙이지 않는다. 그 함수가
|
|
3919
|
+
* 이미 앞 공백 2칸을 포함한 블록을 낸다.
|
|
3920
|
+
*/
|
|
3921
|
+
function renderDeterminism(outcome, options) {
|
|
3922
|
+
if (outcome.kind === "internal") return `${DETERMINISM_HEADING}\n→ 결정론성 비교에서 예상하지 못한 CLI 내부 오류가 발생했습니다. 시험 판정은 1회차 결과 그대로입니다.\n→ 다시 실행한 뒤 재현 정보와 함께 이슈를 보고하세요.\n`;
|
|
3923
|
+
if (outcome.kind === "incomplete") {
|
|
3924
|
+
const head = `${DETERMINISM_HEADING}\n→ 2회차 실행이 완주하지 못해 비교할 수 없습니다. (사유: ${escapeTerminalText(outcome.reason)})\n→ 1회차는 완주했으므로, 서버가 반복 실행 자체에 취약할 수 있습니다
|
|
3925
|
+
(이전 실행이 남긴 상태·잠금·포트 점유 등).
|
|
3926
|
+
`;
|
|
3927
|
+
if (options.stderrLines === 0 || outcome.diagnostics === void 0 || !hasDiagnosticContent(outcome.diagnostics)) return head;
|
|
3928
|
+
const block = renderProcessDiagnostics(outcome.diagnostics, { maxLines: options.stderrLines });
|
|
3929
|
+
return block === "" ? head : `${head}${block}`;
|
|
3930
|
+
}
|
|
3931
|
+
const { result } = outcome;
|
|
3932
|
+
if (result.conclusion === "deterministic") return `${DETERMINISM_HEADING}\n→ 같은 초기 상태에서 2회 실행한 결과가 모든 케이스에서 같습니다. ${determinismCounts(result)}\n`;
|
|
3933
|
+
if (result.conclusion === "consistentWithoutReset") return `${DETERMINISM_HEADING}\n→ 2회 실행 결과가 같았습니다. ${determinismCounts(result)}\n→ 단, 실행 사이에 상태를 복원하지 않았으므로 결정론성 확인은 아닙니다.
|
|
3934
|
+
--reset-cmd 로 초기 상태 복원 명령을 지정하면 확인이 됩니다.
|
|
3935
|
+
`;
|
|
3936
|
+
const suffix = result.skipped === 0 ? "" : ` (제외 ${result.skipped}: 실행되지 않은 케이스)`;
|
|
3937
|
+
const blocks = result.differences.map((difference) => (0, _mcpeak_runner.describeDeterminismDifference)(difference, { stateRestored: options.stateRestored })).join("\n\n");
|
|
3938
|
+
return `${DETERMINISM_HEADING}\n→ ${result.differences.length}/${result.compared} 케이스에서 2회 실행 결과가 다릅니다.${suffix}\n\n${blocks}\n`;
|
|
3939
|
+
}
|
|
3940
|
+
/** 초기화 명령 실패 안내. 한 줄로 유지한다. hint 의 개행은 format 이 이스케이프한다. */
|
|
3941
|
+
function resetFailure(error) {
|
|
3942
|
+
const exit = error.exitCode === null ? "없음" : String(error.exitCode);
|
|
3943
|
+
const tail = error.stderr.split("\n").filter(Boolean).slice(-3).join(" | ");
|
|
3944
|
+
const stderr = tail === "" ? "" : ` stderr 마지막 3줄: ${tail}`;
|
|
3945
|
+
return {
|
|
3946
|
+
code: "RESET_COMMAND_FAILED",
|
|
3947
|
+
message: `초기화 명령이 실패했습니다: ${error.command}`,
|
|
3948
|
+
hint: `종료 코드: ${exit}.${stderr} 명령이 단독으로 성공하는지 확인한 뒤 다시 실행하세요.`
|
|
3949
|
+
};
|
|
3950
|
+
}
|
|
3951
|
+
function format(failure) {
|
|
3952
|
+
const code = failure.coreCode === void 0 ? failure.code : `${failure.code}/${failure.coreCode}`;
|
|
3953
|
+
let result = `오류 [${escapeTerminalText(code)}]: ${escapeTerminalText(failure.message)}\n해결: ${escapeTerminalText(failure.hint)}`;
|
|
3954
|
+
for (const issue of failure.issues ?? []) result += `\n- [${escapeTerminalText(issue.code)}] ${escapeTerminalText(issue.path)}: ${escapeTerminalText(issue.message)}\n 해결: ${escapeTerminalText(issue.hint)}`;
|
|
3955
|
+
return `${result}\n`;
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* core 의 McpProcessDiagnostics 인지 구조로 확인한다. core 를 import 하지 않는다.
|
|
3959
|
+
* 하나라도 어긋나면 undefined 다. 설계 문서 §4.3.1.
|
|
3960
|
+
*/
|
|
3961
|
+
function processDiagnostics(value) {
|
|
3962
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
3963
|
+
if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
|
|
3964
|
+
if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
|
|
3965
|
+
if (!("exitCode" in value) || !(typeof value.exitCode === "number" || value.exitCode === null)) return void 0;
|
|
3966
|
+
if (!("signal" in value) || !(typeof value.signal === "string" || value.signal === null)) return void 0;
|
|
3967
|
+
return value;
|
|
3968
|
+
}
|
|
3969
|
+
function coreError(error) {
|
|
3970
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3971
|
+
const visit = (value) => {
|
|
3972
|
+
if (typeof value === "object" && value !== null && "name" in value && value.name === "McpClientError" && "code" in value && typeof value.code === "string" && "message" in value && typeof value.message === "string" && "hint" in value && typeof value.hint === "string") return Object.freeze({
|
|
3973
|
+
name: "McpClientError",
|
|
3974
|
+
code: value.code,
|
|
3975
|
+
message: value.message,
|
|
3976
|
+
hint: value.hint,
|
|
3977
|
+
diagnostics: processDiagnostics("diagnostics" in value ? value.diagnostics : void 0)
|
|
3978
|
+
});
|
|
3979
|
+
if (typeof value !== "object" || value === null || seen.has(value)) return void 0;
|
|
3980
|
+
seen.add(value);
|
|
3981
|
+
if (value instanceof AggregateError) for (const nested of value.errors) {
|
|
3982
|
+
const found = visit(nested);
|
|
3983
|
+
if (found !== void 0) return found;
|
|
3984
|
+
}
|
|
3985
|
+
};
|
|
3986
|
+
return visit(error);
|
|
3987
|
+
}
|
|
3988
|
+
function writeFailure(dependencies, failure) {
|
|
3989
|
+
dependencies.writeStderr(format(failure));
|
|
3990
|
+
return 1;
|
|
3991
|
+
}
|
|
3992
|
+
async function runCli(argv, dependencies) {
|
|
3993
|
+
if (argv.length === 0) return writeFailure(dependencies, {
|
|
3994
|
+
code: "CLI_USAGE",
|
|
3995
|
+
message: "실행할 CLI 명령이 없습니다.",
|
|
3996
|
+
hint: TEST_USAGE_HINT
|
|
3997
|
+
});
|
|
3998
|
+
if (argv[0] !== "test") {
|
|
3999
|
+
if ([
|
|
4000
|
+
"generate",
|
|
4001
|
+
"record",
|
|
4002
|
+
"mock"
|
|
4003
|
+
].includes(argv[0] ?? "")) return writeFailure(dependencies, {
|
|
4004
|
+
code: "COMMAND_NOT_IMPLEMENTED",
|
|
4005
|
+
message: `'${argv[0]}' 명령은 아직 구현되지 않았습니다.`,
|
|
4006
|
+
hint: "현재는 test 명령만 사용할 수 있습니다."
|
|
4007
|
+
});
|
|
4008
|
+
return writeFailure(dependencies, {
|
|
4009
|
+
code: "CLI_USAGE",
|
|
4010
|
+
message: `알 수 없는 CLI 명령 '${argv[0]}'입니다.`,
|
|
4011
|
+
hint: TEST_USAGE_HINT
|
|
4012
|
+
});
|
|
4013
|
+
}
|
|
4014
|
+
let input;
|
|
4015
|
+
try {
|
|
4016
|
+
input = parseTestCommand(argv.slice(1));
|
|
4017
|
+
} catch (error) {
|
|
4018
|
+
return error instanceof CliCommandError ? writeFailure(dependencies, error.failure) : writeFailure(dependencies, {
|
|
4019
|
+
code: "CLI_INTERNAL_ERROR",
|
|
4020
|
+
...dictionary.CLI_INTERNAL_ERROR
|
|
4021
|
+
});
|
|
4022
|
+
}
|
|
4023
|
+
if ((0, node_path.extname)(input.suitePath).toLowerCase() !== ".json") return writeFailure(dependencies, {
|
|
4024
|
+
code: "SUITE_FORMAT_UNSUPPORTED",
|
|
4025
|
+
...dictionary.SUITE_FORMAT_UNSUPPORTED
|
|
4026
|
+
});
|
|
4027
|
+
let bytes;
|
|
4028
|
+
try {
|
|
4029
|
+
bytes = await dependencies.readFile(input.suitePath);
|
|
4030
|
+
} catch {
|
|
4031
|
+
return writeFailure(dependencies, {
|
|
4032
|
+
code: "SUITE_READ_FAILED",
|
|
4033
|
+
...dictionary.SUITE_READ_FAILED
|
|
4034
|
+
});
|
|
4035
|
+
}
|
|
4036
|
+
let decoded;
|
|
4037
|
+
try {
|
|
4038
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
4039
|
+
} catch {
|
|
4040
|
+
return writeFailure(dependencies, {
|
|
4041
|
+
code: "SUITE_ENCODING_INVALID",
|
|
4042
|
+
...dictionary.SUITE_ENCODING_INVALID
|
|
4043
|
+
});
|
|
4044
|
+
}
|
|
4045
|
+
let parsed;
|
|
4046
|
+
try {
|
|
4047
|
+
parsed = JSON.parse(decoded);
|
|
4048
|
+
} catch {
|
|
4049
|
+
return writeFailure(dependencies, {
|
|
4050
|
+
code: "SUITE_JSON_INVALID",
|
|
4051
|
+
...dictionary.SUITE_JSON_INVALID
|
|
4052
|
+
});
|
|
4053
|
+
}
|
|
4054
|
+
let validated;
|
|
4055
|
+
try {
|
|
4056
|
+
validated = dependencies.validateSuite(parsed);
|
|
4057
|
+
} catch {
|
|
4058
|
+
return writeFailure(dependencies, {
|
|
4059
|
+
code: "CLI_INTERNAL_ERROR",
|
|
4060
|
+
...dictionary.CLI_INTERNAL_ERROR
|
|
4061
|
+
});
|
|
4062
|
+
}
|
|
4063
|
+
if (!validated.valid) return writeFailure(dependencies, {
|
|
4064
|
+
code: "SUITE_VALIDATION_FAILED",
|
|
4065
|
+
...dictionary.SUITE_VALIDATION_FAILED,
|
|
4066
|
+
issues: validated.issues
|
|
4067
|
+
});
|
|
4068
|
+
/**
|
|
4069
|
+
* 지문 대조는 서버 연결 전에 끝낸다. 연결이 실패해도 파일에 대한 사실은 변하지 않는다.
|
|
4070
|
+
* dependencies 에 주입 지점을 두지 않는다. 순수 함수이고 외부 자원을 안 쓰므로, 주입하면
|
|
4071
|
+
* 테스트가 실제 대조 로직을 안 거치게 된다. 설계 문서 §7.
|
|
4072
|
+
*/
|
|
4073
|
+
const specApproval = checkSpecApproval(validated.value);
|
|
4074
|
+
const runReset = dependencies.runResetCommand ?? runResetCommand;
|
|
4075
|
+
/**
|
|
4076
|
+
* 복원은 시험 실행을 **시작하기 전**이다. 실패하면 서버를 띄우지 않는다. 되돌리지 못한
|
|
4077
|
+
* 상태 위에서 돌린 결과는 판정 근거가 될 수 없기 때문이다(설계 문서 §5.2, ADR-0023).
|
|
4078
|
+
*/
|
|
4079
|
+
if (input.resetCmd !== void 0) try {
|
|
4080
|
+
await runReset(input.resetCmd);
|
|
4081
|
+
} catch (error) {
|
|
4082
|
+
return writeFailure(dependencies, error instanceof ResetCommandError ? resetFailure(error) : {
|
|
4083
|
+
code: "CLI_INTERNAL_ERROR",
|
|
4084
|
+
...dictionary.CLI_INTERNAL_ERROR
|
|
4085
|
+
});
|
|
4086
|
+
}
|
|
4087
|
+
let connection;
|
|
4088
|
+
try {
|
|
4089
|
+
connection = await dependencies.connect({
|
|
4090
|
+
command: input.command,
|
|
4091
|
+
args: input.args
|
|
4092
|
+
});
|
|
4093
|
+
} catch (error) {
|
|
4094
|
+
const core = coreError(error);
|
|
4095
|
+
const failed = writeFailure(dependencies, core === void 0 ? {
|
|
4096
|
+
code: "MCP_CONNECTION_FAILED",
|
|
4097
|
+
...dictionary.MCP_CONNECTION_FAILED
|
|
4098
|
+
} : {
|
|
4099
|
+
code: "MCP_CONNECTION_FAILED",
|
|
4100
|
+
message: core.message,
|
|
4101
|
+
hint: core.hint,
|
|
4102
|
+
coreCode: core.code
|
|
4103
|
+
});
|
|
4104
|
+
const diagnostics = core?.diagnostics;
|
|
4105
|
+
if (input.stderrLines > 0 && diagnostics !== void 0 && hasDiagnosticContent(diagnostics)) {
|
|
4106
|
+
const block = renderProcessDiagnostics(diagnostics, { maxLines: input.stderrLines });
|
|
4107
|
+
if (block !== "") dependencies.writeStderr(`\n${block}`);
|
|
4108
|
+
}
|
|
4109
|
+
return failed;
|
|
4110
|
+
}
|
|
4111
|
+
/** 진단 출력 실패가 판정을 바꾸면 안 된다. getDiagnostics 가 던지면 삼킨다. §4.3.1. */
|
|
4112
|
+
const snapshotDiagnostics = () => {
|
|
4113
|
+
try {
|
|
4114
|
+
return { value: connection.getDiagnostics() };
|
|
4115
|
+
} catch {
|
|
4116
|
+
return { value: void 0 };
|
|
4117
|
+
}
|
|
4118
|
+
};
|
|
4119
|
+
/**
|
|
4120
|
+
* 블록 앞에는 항상 빈 줄을 둔다. 오류 메시지 뒤든 보고서 뒤든 같은 터미널에 이어 나오므로
|
|
4121
|
+
* 경로마다 레이아웃이 달라질 이유가 없다. 설계 문서 §7.
|
|
4122
|
+
* snapshot 을 주면 그 값을 쓴다. 우리가 프로세스를 정리한 뒤의 상태를 서버 탓으로 보고하지
|
|
4123
|
+
* 않기 위해서다(설계 문서 §4.3).
|
|
4124
|
+
*/
|
|
4125
|
+
const writeDiagnostics = (snapshot) => {
|
|
4126
|
+
if (input.stderrLines === 0) return;
|
|
4127
|
+
const diagnostics = (snapshot ?? snapshotDiagnostics()).value;
|
|
4128
|
+
if (diagnostics === void 0) return;
|
|
4129
|
+
if (!hasDiagnosticContent(diagnostics)) return;
|
|
4130
|
+
const block = renderProcessDiagnostics(diagnostics, { maxLines: input.stderrLines });
|
|
4131
|
+
if (block === "") return;
|
|
4132
|
+
dependencies.writeStderr(`\n${block}`);
|
|
4133
|
+
};
|
|
4134
|
+
/**
|
|
4135
|
+
* 비차단 진단용 툴 목록. 실패하면 조용히 빈 배열로 둔다. 로그도 남기지 않는다. 진단이 실행을
|
|
4136
|
+
* 깨뜨리면 안 되고, 실패 원인과 무관한 줄이 보고서에 섞이면 정작 필요한 줄이 안 읽힌다.
|
|
4137
|
+
* 설계 문서 §7.1.
|
|
4138
|
+
*/
|
|
4139
|
+
const tools = await (async () => {
|
|
4140
|
+
try {
|
|
4141
|
+
return await connection.client.listTools();
|
|
4142
|
+
} catch {
|
|
4143
|
+
return [];
|
|
4144
|
+
}
|
|
4145
|
+
})();
|
|
4146
|
+
/**
|
|
4147
|
+
* 캡처 래퍼는 `--determinism` 일 때만 만든다. 플래그가 없으면 기존 경로와 호출·객체가
|
|
4148
|
+
* 완전히 같다. 캡처 비용 0 이 설계 문서 §5.1 의 조건이다.
|
|
4149
|
+
*/
|
|
4150
|
+
const firstCapture = input.determinism ? createDeterminismCapture(connection.client) : void 0;
|
|
4151
|
+
/**
|
|
4152
|
+
* 러너에 넘기는 client 다. **`shutdown.client` 는 반드시 이것과 같은 객체여야 한다.**
|
|
4153
|
+
* `finalizeRunnerExecution` 이 `runSuite` 에 바인딩된 client 와 대조해 다르면 TypeError 를
|
|
4154
|
+
* 던지고(`runner/src/shutdown.ts` 의 `boundClient`), 그러면 종료 절차가 통째로 건너뛰어져
|
|
4155
|
+
* 서버 프로세스가 남는다. 한쪽만 감싸면 정확히 그 일이 난다.
|
|
4156
|
+
*/
|
|
4157
|
+
const firstClient = firstCapture?.client ?? connection.client;
|
|
4158
|
+
const shutdown = {
|
|
4159
|
+
client: firstClient,
|
|
4160
|
+
close: () => connection.close(),
|
|
4161
|
+
forceClose: (_reason) => connection.forceClose()
|
|
4162
|
+
};
|
|
4163
|
+
let execution;
|
|
4164
|
+
try {
|
|
4165
|
+
execution = dependencies.startRunner(firstCapture === void 0 ? {
|
|
4166
|
+
client: firstClient,
|
|
4167
|
+
suite: validated.value
|
|
4168
|
+
} : {
|
|
4169
|
+
client: firstClient,
|
|
4170
|
+
suite: validated.value,
|
|
4171
|
+
onEvent: firstCapture.onEvent
|
|
4172
|
+
});
|
|
4173
|
+
} catch {
|
|
4174
|
+
const snapshot = snapshotDiagnostics();
|
|
4175
|
+
try {
|
|
4176
|
+
await connection.forceClose();
|
|
4177
|
+
} catch {}
|
|
4178
|
+
const failed = writeFailure(dependencies, {
|
|
4179
|
+
code: "RUNNER_EXECUTION_FAILED",
|
|
4180
|
+
...dictionary.RUNNER_EXECUTION_FAILED
|
|
4181
|
+
});
|
|
4182
|
+
writeDiagnostics(snapshot);
|
|
4183
|
+
return failed;
|
|
4184
|
+
}
|
|
4185
|
+
let finalReport;
|
|
4186
|
+
try {
|
|
4187
|
+
finalReport = await dependencies.finalize({
|
|
4188
|
+
execution,
|
|
4189
|
+
shutdown
|
|
4190
|
+
});
|
|
4191
|
+
} catch {
|
|
4192
|
+
const failed = writeFailure(dependencies, {
|
|
4193
|
+
code: "RUNNER_FINALIZATION_FAILED",
|
|
4194
|
+
...dictionary.RUNNER_FINALIZATION_FAILED
|
|
4195
|
+
});
|
|
4196
|
+
writeDiagnostics();
|
|
4197
|
+
return failed;
|
|
4198
|
+
}
|
|
4199
|
+
const allPassed = finalReport.status === "passed";
|
|
4200
|
+
/**
|
|
4201
|
+
* XML 파일을 stdout 보다 **먼저** 쓴다. stdout 은 `| head` 같은 파이프에서 EPIPE 로 깨질 수
|
|
4202
|
+
* 있는데, 그때 사용자가 `--junit` 으로 명시적으로 요청한 산출물까지 함께 잃을 이유가 없다.
|
|
4203
|
+
* `--junit` 을 주지 않으면 이 블록을 통째로 건너뛰므로 기존 순서와 동일하다. ADR-0019.
|
|
4204
|
+
*/
|
|
4205
|
+
if (input.junitPath !== void 0) {
|
|
4206
|
+
let xml;
|
|
4207
|
+
try {
|
|
4208
|
+
xml = dependencies.renderJUnit(finalReport);
|
|
4209
|
+
} catch {
|
|
4210
|
+
return writeFailure(dependencies, {
|
|
4211
|
+
code: "CLI_INTERNAL_ERROR",
|
|
4212
|
+
...dictionary.CLI_INTERNAL_ERROR
|
|
4213
|
+
});
|
|
4214
|
+
}
|
|
4215
|
+
try {
|
|
4216
|
+
await dependencies.writeFile(input.junitPath, xml);
|
|
4217
|
+
} catch {
|
|
4218
|
+
return writeFailure(dependencies, {
|
|
4219
|
+
code: "JUNIT_WRITE_FAILED",
|
|
4220
|
+
...dictionary.JUNIT_WRITE_FAILED
|
|
4221
|
+
});
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
/**
|
|
4225
|
+
* 2회차. `--determinism` 일 때만 돈다. **서버 프로세스를 새로 띄운다.** 1회차 연결은 위
|
|
4226
|
+
* `finalize` 의 종료 절차로 이미 닫혔고, 프로세스 내부 상태도 초기화 대상이라 연결을
|
|
4227
|
+
* 재사용하지 않는다(설계 문서 §5.2).
|
|
4228
|
+
*
|
|
4229
|
+
* 이 블록의 실패는 **CLI 오류로 던지지 않는다.** 1회차 판정과 종료 코드는 이미 정해졌고,
|
|
4230
|
+
* 관찰이 실패했다고 시험 판정을 뒤집으면 안 된다. 2회차 문제는 결정론성 블록 안의
|
|
4231
|
+
* 문장으로만 존재한다(설계 문서 §7).
|
|
4232
|
+
*/
|
|
4233
|
+
const determinismOutcome = await (async () => {
|
|
4234
|
+
if (firstCapture === void 0) return void 0;
|
|
4235
|
+
const snapshotOf = (target) => {
|
|
4236
|
+
try {
|
|
4237
|
+
return target.getDiagnostics();
|
|
4238
|
+
} catch {
|
|
4239
|
+
return;
|
|
4240
|
+
}
|
|
4241
|
+
};
|
|
4242
|
+
if (input.resetCmd !== void 0) try {
|
|
4243
|
+
await runReset(input.resetCmd);
|
|
4244
|
+
} catch (error) {
|
|
4245
|
+
return {
|
|
4246
|
+
kind: "incomplete",
|
|
4247
|
+
reason: error instanceof ResetCommandError ? `2회차 전 초기화 명령 실패: ${error.command}` : "2회차 전 초기화 명령 실패"
|
|
4248
|
+
};
|
|
4249
|
+
}
|
|
4250
|
+
let second;
|
|
4251
|
+
try {
|
|
4252
|
+
second = await dependencies.connect({
|
|
4253
|
+
command: input.command,
|
|
4254
|
+
args: input.args
|
|
4255
|
+
});
|
|
4256
|
+
} catch (error) {
|
|
4257
|
+
const core = coreError(error);
|
|
4258
|
+
return {
|
|
4259
|
+
kind: "incomplete",
|
|
4260
|
+
reason: "서버 연결 실패",
|
|
4261
|
+
...core?.diagnostics === void 0 ? {} : { diagnostics: core.diagnostics }
|
|
4262
|
+
};
|
|
4263
|
+
}
|
|
4264
|
+
const secondCapture = createDeterminismCapture(second.client);
|
|
4265
|
+
let secondExecution;
|
|
4266
|
+
try {
|
|
4267
|
+
secondExecution = dependencies.startRunner({
|
|
4268
|
+
client: secondCapture.client,
|
|
4269
|
+
suite: validated.value,
|
|
4270
|
+
onEvent: secondCapture.onEvent
|
|
4271
|
+
});
|
|
4272
|
+
} catch {
|
|
4273
|
+
const snapshot = snapshotOf(second);
|
|
4274
|
+
try {
|
|
4275
|
+
await second.forceClose();
|
|
4276
|
+
} catch {}
|
|
4277
|
+
return {
|
|
4278
|
+
kind: "incomplete",
|
|
4279
|
+
reason: "2회차 실행 시작 실패",
|
|
4280
|
+
...snapshot === void 0 ? {} : { diagnostics: snapshot }
|
|
4281
|
+
};
|
|
4282
|
+
}
|
|
4283
|
+
let secondReport;
|
|
4284
|
+
try {
|
|
4285
|
+
secondReport = await dependencies.finalize({
|
|
4286
|
+
execution: secondExecution,
|
|
4287
|
+
shutdown: {
|
|
4288
|
+
client: secondCapture.client,
|
|
4289
|
+
close: () => second.close(),
|
|
4290
|
+
forceClose: (_reason) => second.forceClose()
|
|
4291
|
+
}
|
|
4292
|
+
});
|
|
4293
|
+
} catch {
|
|
4294
|
+
const snapshot = snapshotOf(second);
|
|
4295
|
+
try {
|
|
4296
|
+
await second.forceClose();
|
|
4297
|
+
} catch {}
|
|
4298
|
+
return {
|
|
4299
|
+
kind: "incomplete",
|
|
4300
|
+
reason: "2회차 실행 또는 서버 종료 실패",
|
|
4301
|
+
...snapshot === void 0 ? {} : { diagnostics: snapshot }
|
|
4302
|
+
};
|
|
4303
|
+
}
|
|
4304
|
+
if (secondReport.status === "aborted") {
|
|
4305
|
+
const snapshot = snapshotOf(second);
|
|
4306
|
+
const stop = secondReport.stopReason;
|
|
4307
|
+
return {
|
|
4308
|
+
kind: "incomplete",
|
|
4309
|
+
reason: stop?.type === "timeout" ? `2회차 케이스 타임아웃 (${stop.caseId})` : stop?.type === "abortSignal" ? "2회차 실행 중단" : "2회차 실행 미완주",
|
|
4310
|
+
...snapshot === void 0 ? {} : { diagnostics: snapshot }
|
|
4311
|
+
};
|
|
4312
|
+
}
|
|
4313
|
+
try {
|
|
4314
|
+
return {
|
|
4315
|
+
kind: "compared",
|
|
4316
|
+
result: (dependencies.checkDeterminism ?? _mcpeak_runner.checkDeterminism)({
|
|
4317
|
+
first: firstCapture.observations(),
|
|
4318
|
+
second: secondCapture.observations(),
|
|
4319
|
+
stateRestored: input.resetCmd !== void 0
|
|
4320
|
+
})
|
|
4321
|
+
};
|
|
4322
|
+
} catch {
|
|
4323
|
+
return { kind: "internal" };
|
|
4324
|
+
}
|
|
4325
|
+
})();
|
|
4326
|
+
/**
|
|
4327
|
+
* 툴 목록이 비면 입력 계약 대조는 건너뛴다. 목록이 비었을 때 대조하면 모든 케이스가
|
|
4328
|
+
* `TOOL_NOT_DECLARED` 로 걸려 실패 원인과 무관한 줄만 늘어난다. 단언 실질성은 툴이 필요
|
|
4329
|
+
* 없으므로 항상 돈다. 표시는 실패한 케이스에 한한다. 설계 문서 §7.
|
|
4330
|
+
*
|
|
4331
|
+
* 검사가 던져도 판정과 exit code 는 바뀌지 않아야 하므로 삼킨다. `validated.value` 는 이미
|
|
4332
|
+
* `validateMcpSuite` 를 통과했으니 도달할 일이 없는 경로이고, 도달했다면 그것은 비차단
|
|
4333
|
+
* 진단의 결함이지 대상 서버의 결함이 아니다.
|
|
4334
|
+
*/
|
|
4335
|
+
const specFindings = (() => {
|
|
4336
|
+
/**
|
|
4337
|
+
* 실패한 케이스의 버킷을 보고서의 케이스 순서로 먼저 만든다. 두 검사 결과를 이어 붙인
|
|
4338
|
+
* 뒤에 `caseId` 로 묶으면 앞 케이스에 단언 finding 만 있고 뒤 케이스에 입력 계약 finding
|
|
4339
|
+
* 이 있을 때 뒤 케이스가 먼저 들어와 순서가 뒤집힌다. 케이스 사이 순서는 검사 종류가
|
|
4340
|
+
* 아니라 보고서가 정한다. 한 케이스 안의 블록 순서는 `FINDING_GROUP_ORDER` 가 맡는다.
|
|
4341
|
+
* 없는 키를 만들지 않으므로 버킷에 없는 caseId 는 그대로 걸러진다.
|
|
4342
|
+
*/
|
|
4343
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
4344
|
+
for (const item of finalReport.cases) if (item.status !== "passed") buckets.set(item.spec.id, []);
|
|
4345
|
+
try {
|
|
4346
|
+
const inputContract = dependencies.checkInputContract ?? _mcpeak_runner.checkInputContract;
|
|
4347
|
+
const assertionSubstance = dependencies.checkAssertionSubstance ?? _mcpeak_runner.checkAssertionSubstance;
|
|
4348
|
+
const found = [...tools.length === 0 ? [] : inputContract({
|
|
4349
|
+
suite: validated.value,
|
|
4350
|
+
tools
|
|
4351
|
+
}).findings, ...assertionSubstance(validated.value).findings];
|
|
4352
|
+
for (const finding of found) buckets.get(finding.caseId)?.push(finding);
|
|
4353
|
+
return [...buckets.values()].flat();
|
|
4354
|
+
} catch {
|
|
4355
|
+
return [];
|
|
4356
|
+
}
|
|
4357
|
+
})();
|
|
4358
|
+
/**
|
|
4359
|
+
* 참고 문장을 붙일 케이스. 승인 시점에 `serverDefect` 로 표시했는데 지금 또 실패한 것들이다.
|
|
4360
|
+
* 설계 문서 §9.
|
|
4361
|
+
*
|
|
4362
|
+
* **지문이 일치할 때만 본다.** 명세가 바뀌었으면 승인 시점의 판정이 지금 케이스에 해당하는지
|
|
4363
|
+
* 알 수 없다. 지문이 없으면 `approval.cases` 도 없으므로 이 집합은 비어 있다.
|
|
4364
|
+
* `serverDefect` 케이스가 통과하면 침묵한다. `test` 화면은 실패를 보는 자리다.
|
|
4365
|
+
*/
|
|
4366
|
+
const serverDefectCases = (() => {
|
|
4367
|
+
if (specApproval.state !== "matched") return /* @__PURE__ */ new Set();
|
|
4368
|
+
const statuses = caseApprovalStatuses(validated.value);
|
|
4369
|
+
if (statuses.size === 0) return /* @__PURE__ */ new Set();
|
|
4370
|
+
return new Set(finalReport.cases.filter((item) => item.status !== "passed" && statuses.get(item.spec.id) === "serverDefect").map((item) => item.spec.id));
|
|
4371
|
+
})();
|
|
4372
|
+
try {
|
|
4373
|
+
if (input.json) {
|
|
4374
|
+
/**
|
|
4375
|
+
* `spec` 은 억제 규칙과 무관하게 항상 넣는다. 기계가 읽는 출력에서 키가 조건부로
|
|
4376
|
+
* 사라지면 소비자가 분기를 하나 더 써야 한다. 설계 문서 §7.3.
|
|
4377
|
+
* `approvedFingerprint` 는 absent 일 때 키 자체가 없어야 하므로 조건부로 넣는다.
|
|
4378
|
+
*/
|
|
4379
|
+
const spec = {
|
|
4380
|
+
approval: specApproval.state,
|
|
4381
|
+
fingerprint: specApproval.fingerprint,
|
|
4382
|
+
findings: specFindings.map(({ code, severity, caseId, path }) => ({
|
|
4383
|
+
code,
|
|
4384
|
+
severity,
|
|
4385
|
+
caseId,
|
|
4386
|
+
path
|
|
4387
|
+
}))
|
|
4388
|
+
};
|
|
4389
|
+
if (specApproval.approvedFingerprint !== void 0) spec.approvedFingerprint = specApproval.approvedFingerprint;
|
|
4390
|
+
/**
|
|
4391
|
+
* 승인 시점 판정은 파일에 적힌 그대로 싣는다. 지문이 불일치여도 억제하지 않는다.
|
|
4392
|
+
* 텍스트 참고 문장을 지문 불일치에 억제하는 것은 사람이 읽는 화면의 규칙이고, 기계는
|
|
4393
|
+
* `spec.approval` 로 불일치를 이미 안다. 설계 문서 §9.
|
|
4394
|
+
* `approvedFingerprint` 와 같은 이유로 없을 때는 키 자체를 만들지 않는다.
|
|
4395
|
+
*/
|
|
4396
|
+
const approvedCases = validated.value.approval?.cases;
|
|
4397
|
+
if (approvedCases !== void 0) spec.cases = approvedCases;
|
|
4398
|
+
/**
|
|
4399
|
+
* 결정론성은 비교까지 갔을 때만 키를 만든다. `--determinism` 이 없으면 기존 JSON 이
|
|
4400
|
+
* 바이트 그대로여야 하고(설계 문서 §8), 비교를 못 한 경우에는 실어야 할
|
|
4401
|
+
* `DeterminismResult` 자체가 없다. 그 사실은 아래에서 stderr 로 알린다.
|
|
4402
|
+
*/
|
|
4403
|
+
const machine = determinismOutcome?.kind === "compared" ? {
|
|
4404
|
+
...finalReport,
|
|
4405
|
+
spec,
|
|
4406
|
+
determinism: determinismOutcome.result
|
|
4407
|
+
} : {
|
|
4408
|
+
...finalReport,
|
|
4409
|
+
spec
|
|
4410
|
+
};
|
|
4411
|
+
dependencies.writeStdout(`${JSON.stringify(machine, null, 2)}\n`);
|
|
4412
|
+
} else {
|
|
4413
|
+
dependencies.writeStdout(dependencies.renderReport(finalReport, { color: dependencies.colorEnabled }));
|
|
4414
|
+
/**
|
|
4415
|
+
* 참고 문장은 보고서 뒤, 명세 승인 블록 앞이다. 케이스마다 한 블록으로 묶는다.
|
|
4416
|
+
* 순서는 `runner` 가 정한 finding 순서이고 여기서 다시 정렬하지 않는다. 설계 문서 §7.2.
|
|
4417
|
+
*/
|
|
4418
|
+
if (specFindings.length > 0 || serverDefectCases.size > 0) {
|
|
4419
|
+
const byCase = /* @__PURE__ */ new Map();
|
|
4420
|
+
for (const finding of specFindings) {
|
|
4421
|
+
const list = byCase.get(finding.caseId) ?? [];
|
|
4422
|
+
list.push(finding);
|
|
4423
|
+
byCase.set(finding.caseId, list);
|
|
4424
|
+
}
|
|
4425
|
+
/**
|
|
4426
|
+
* 케이스 순서는 보고서가 정한다. `specFindings` 의 순서도 같은 출처에서 나오므로
|
|
4427
|
+
* 여기서 다시 정렬해도 기존 순서가 그대로다. 승인 판정만 있고 finding 이 없는 케이스는
|
|
4428
|
+
* `byCase` 에 없어서 이 목록으로 순회해야 빠지지 않는다.
|
|
4429
|
+
*/
|
|
4430
|
+
for (const item of finalReport.cases) {
|
|
4431
|
+
if (item.status === "passed") continue;
|
|
4432
|
+
const caseId = item.spec.id;
|
|
4433
|
+
const list = byCase.get(caseId) ?? [];
|
|
4434
|
+
for (const group of FINDING_GROUP_ORDER) {
|
|
4435
|
+
const grouped = list.filter((finding) => FINDING_GROUP[finding.code] === group);
|
|
4436
|
+
if (grouped.length === 0) continue;
|
|
4437
|
+
dependencies.writeStdout(`\n${FINDING_HEADING[group](escapeTerminalText(caseId))}\n${grouped.map((finding) => ` → ${(0, _mcpeak_runner.describeSpecFinding)(finding)}\n`).join("")}`);
|
|
4438
|
+
}
|
|
4439
|
+
if (serverDefectCases.has(caseId)) dependencies.writeStdout(`\n${SERVER_DEFECT_NOTE_LINE}`);
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
if (shouldShowSpecApproval(specApproval, allPassed)) dependencies.writeStdout(`\n${renderSpecApproval(specApproval)}`);
|
|
4443
|
+
/**
|
|
4444
|
+
* 결정론성 블록은 보고서 뒤, 다른 블록과 같은 레이아웃 규칙(앞에 빈 줄)을 따른다.
|
|
4445
|
+
* `--determinism` 없이는 한 줄도 찍지 않는다. 설계 문서 §8.
|
|
4446
|
+
*/
|
|
4447
|
+
if (determinismOutcome !== void 0) dependencies.writeStdout(`\n${renderDeterminism(determinismOutcome, {
|
|
4448
|
+
stateRestored: input.resetCmd !== void 0,
|
|
4449
|
+
stderrLines: input.stderrLines
|
|
4450
|
+
})}`);
|
|
4451
|
+
}
|
|
4452
|
+
/**
|
|
4453
|
+
* 기계가 읽는 출력에서는 비교 실패 사실이 사라진다(키를 만들지 않으므로). 그대로 두면
|
|
4454
|
+
* 사용자는 왜 키가 없는지 알 수 없다. stdout 은 JSON 전용이므로 stderr 에 적는다.
|
|
4455
|
+
*/
|
|
4456
|
+
if (input.json && determinismOutcome !== void 0 && determinismOutcome.kind !== "compared") dependencies.writeStderr(`\n${renderDeterminism(determinismOutcome, {
|
|
4457
|
+
stateRestored: input.resetCmd !== void 0,
|
|
4458
|
+
stderrLines: input.stderrLines
|
|
4459
|
+
})}`);
|
|
4460
|
+
} catch {
|
|
4461
|
+
return writeFailure(dependencies, {
|
|
4462
|
+
code: "CLI_INTERNAL_ERROR",
|
|
4463
|
+
...dictionary.CLI_INTERNAL_ERROR
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
4466
|
+
const settled = snapshotDiagnostics();
|
|
4467
|
+
if (!allPassed || settled.value !== void 0 && isAbnormalExit(settled.value)) writeDiagnostics(settled);
|
|
4468
|
+
/**
|
|
4469
|
+
* repair 번들. 스냅샷을 그대로 쓴다. 우리가 프로세스를 정리한 뒤의 상태를 다시 읽으면 그
|
|
4470
|
+
* 상태를 서버 탓으로 적게 된다. `--repair-bundle` 을 주지 않으면 이 블록을 통째로 건너뛰므로
|
|
4471
|
+
* 기존 경로의 출력과 종료 코드가 그대로다. 계획서 완료 조건 2.
|
|
4472
|
+
*/
|
|
4473
|
+
if (input.repairBundlePath !== void 0) {
|
|
4474
|
+
const bundle = buildRepairBundle({
|
|
4475
|
+
report: finalReport,
|
|
4476
|
+
suite: validated.value,
|
|
4477
|
+
specApproval,
|
|
4478
|
+
processDiagnostics: settled.value
|
|
4479
|
+
});
|
|
4480
|
+
if (bundle === void 0) dependencies.writeStdout(`\n${REPAIR_BUNDLE_EMPTY_LINE}`);
|
|
4481
|
+
else try {
|
|
4482
|
+
await dependencies.writeFile(input.repairBundlePath, serializeRepairBundle(bundle));
|
|
4483
|
+
} catch {
|
|
4484
|
+
return writeFailure(dependencies, {
|
|
4485
|
+
code: "REPAIR_BUNDLE_WRITE_FAILED",
|
|
4486
|
+
...dictionary.REPAIR_BUNDLE_WRITE_FAILED
|
|
4487
|
+
});
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
return allPassed ? 0 : 1;
|
|
4491
|
+
}
|
|
4492
|
+
//#endregion
|
|
4493
|
+
Object.defineProperty(exports, "GLOBAL_HELP", {
|
|
4494
|
+
enumerable: true,
|
|
4495
|
+
get: function() {
|
|
4496
|
+
return GLOBAL_HELP;
|
|
4497
|
+
}
|
|
4498
|
+
});
|
|
4499
|
+
Object.defineProperty(exports, "ReplayRuntimeUnavailableError", {
|
|
4500
|
+
enumerable: true,
|
|
4501
|
+
get: function() {
|
|
4502
|
+
return ReplayRuntimeUnavailableError;
|
|
4503
|
+
}
|
|
4504
|
+
});
|
|
4505
|
+
Object.defineProperty(exports, "VERIFY_USAGE_HINT", {
|
|
4506
|
+
enumerable: true,
|
|
4507
|
+
get: function() {
|
|
4508
|
+
return VERIFY_USAGE_HINT;
|
|
4509
|
+
}
|
|
4510
|
+
});
|
|
4511
|
+
Object.defineProperty(exports, "commandHelp", {
|
|
4512
|
+
enumerable: true,
|
|
4513
|
+
get: function() {
|
|
4514
|
+
return commandHelp;
|
|
4515
|
+
}
|
|
4516
|
+
});
|
|
4517
|
+
Object.defineProperty(exports, "nodeGenerateDependencies", {
|
|
4518
|
+
enumerable: true,
|
|
4519
|
+
get: function() {
|
|
4520
|
+
return nodeGenerateDependencies;
|
|
4521
|
+
}
|
|
4522
|
+
});
|
|
4523
|
+
Object.defineProperty(exports, "nodeReviewIO", {
|
|
4524
|
+
enumerable: true,
|
|
4525
|
+
get: function() {
|
|
4526
|
+
return nodeReviewIO;
|
|
4527
|
+
}
|
|
4528
|
+
});
|
|
4529
|
+
Object.defineProperty(exports, "parseRepairCommand", {
|
|
4530
|
+
enumerable: true,
|
|
4531
|
+
get: function() {
|
|
4532
|
+
return parseRepairCommand;
|
|
4533
|
+
}
|
|
4534
|
+
});
|
|
4535
|
+
Object.defineProperty(exports, "parseReplayCommand", {
|
|
4536
|
+
enumerable: true,
|
|
4537
|
+
get: function() {
|
|
4538
|
+
return parseReplayCommand;
|
|
4539
|
+
}
|
|
4540
|
+
});
|
|
4541
|
+
Object.defineProperty(exports, "parseTestCommand", {
|
|
4542
|
+
enumerable: true,
|
|
4543
|
+
get: function() {
|
|
4544
|
+
return parseTestCommand;
|
|
4545
|
+
}
|
|
4546
|
+
});
|
|
4547
|
+
Object.defineProperty(exports, "runCli", {
|
|
4548
|
+
enumerable: true,
|
|
4549
|
+
get: function() {
|
|
4550
|
+
return runCli;
|
|
4551
|
+
}
|
|
4552
|
+
});
|
|
4553
|
+
Object.defineProperty(exports, "runGenerateCommand", {
|
|
4554
|
+
enumerable: true,
|
|
4555
|
+
get: function() {
|
|
4556
|
+
return runGenerateCommand;
|
|
4557
|
+
}
|
|
4558
|
+
});
|
|
4559
|
+
Object.defineProperty(exports, "runRepairCommand", {
|
|
4560
|
+
enumerable: true,
|
|
4561
|
+
get: function() {
|
|
4562
|
+
return runRepairCommand;
|
|
4563
|
+
}
|
|
4564
|
+
});
|
|
4565
|
+
Object.defineProperty(exports, "runReplayCommand", {
|
|
4566
|
+
enumerable: true,
|
|
4567
|
+
get: function() {
|
|
4568
|
+
return runReplayCommand;
|
|
4569
|
+
}
|
|
4570
|
+
});
|
|
4571
|
+
Object.defineProperty(exports, "version", {
|
|
4572
|
+
enumerable: true,
|
|
4573
|
+
get: function() {
|
|
4574
|
+
return version;
|
|
4575
|
+
}
|
|
4576
|
+
});
|