@mcpeak/cli 0.9.0 → 0.11.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 +1 -1
- package/README.md +12 -2
- package/dist/cli.cjs +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/commands.cjs +1 -3
- package/dist/commands.d.cts +44 -6
- package/dist/commands.d.mts +44 -6
- package/dist/commands.mjs +2 -2
- package/dist/index.cjs +219 -5
- package/dist/index.d.cts +3 -14
- package/dist/index.d.mts +3 -14
- package/dist/index.mjs +217 -2
- package/dist/{replay-command-BAtSzf8R.d.mts → repair-command-D3zL3G5W.d.cts} +36 -44
- package/dist/{replay-command-V0ZSJPiD.d.cts → repair-command-DQh2h4tQ.d.mts} +36 -44
- package/dist/{test-command-BZ_94UmI.cjs → test-command-B_faNSfA.cjs} +1583 -873
- package/dist/{test-command-CsJfojut.mjs → test-command-DEp9hrfH.mjs} +1591 -869
- package/package.json +14 -10
- package/dist/src-D1NW6ckv.mjs +0 -459
- package/dist/src-Df8147pD.cjs +0 -482
|
@@ -1,63 +1,360 @@
|
|
|
1
1
|
import { access, link, open, readFile, unlink } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, extname, join } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
|
-
import { REDACTED, RunnerPayloadLimitError, canonicalJson, checkAssertionSubstance, checkDeterminism, checkInputContract, describeDeterminismDifference, describeSpecFinding, renderReport, runSuite, suiteFingerprint } from "@mcpeak/runner";
|
|
5
|
-
import { REDACTED as REDACTED$1, cassetteClient, loadCassette, matchKey, saveCassette } from "@mcpeak/record";
|
|
4
|
+
import { MAX_VALUE_STRING_CHARS, REDACTED, RunnerPayloadLimitError, canonicalJson, checkAssertionSubstance, checkDeterminism, checkInputContract, describeDeterminismDifference, describeSpecFinding, renderReport, runSuite, suiteFingerprint } from "@mcpeak/runner";
|
|
6
5
|
import { spawn } from "node:child_process";
|
|
7
6
|
import { CommandParseError, tokenizeCommand } from "@mcpeak/core";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
8
|
//#region package.json
|
|
9
|
-
var version = "0.
|
|
9
|
+
var version = "0.11.0";
|
|
10
10
|
//#endregion
|
|
11
|
-
//#region src/
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
11
|
+
//#region src/repair-render.ts
|
|
12
|
+
/**
|
|
13
|
+
* 터미널 제어 문자를 무해한 토큰으로 바꾼다.
|
|
14
|
+
*
|
|
15
|
+
* `packages/cli/src/test-command.ts` 의 `escapeTerminalText` 와 같은 계열이다. 그 함수를
|
|
16
|
+
* import 하지 않고 사본을 두는 근거는 ADR-0013 이고 `process-diagnostics.ts` 의 선례와 같다.
|
|
17
|
+
* 이 모듈이 찍는 문자열은 **외부 provider 가 준 산문**이라 원문 보존보다 화면 무해성이 앞선다.
|
|
18
|
+
*
|
|
19
|
+
* TAB(0x09)도 이스케이프한다. `process-diagnostics.ts` 사본만 TAB 을 남기는데 그것은 서버
|
|
20
|
+
* stderr 의 스택트레이스 들여쓰기를 보존하기 위해서다. AI 산문에는 보존할 들여쓰기가 없고,
|
|
21
|
+
* 우리가 만든 라벨 정렬을 TAB 이 흔들면 화면이 어긋난다.
|
|
22
|
+
*/
|
|
23
|
+
const escapeTerminalText$2 = (value) => Array.from(value, (character) => {
|
|
24
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
25
|
+
return codePoint <= 31 || codePoint >= 127 && codePoint <= 159 || codePoint === 8232 || codePoint === 8233 ? `\\u${codePoint.toString(16).padStart(4, "0")}` : character;
|
|
26
|
+
}).join("");
|
|
27
|
+
/** 코드 포인트 단위로 자른다. 서로게이트 쌍(이모지)의 중간이 끊기지 않는다. */
|
|
28
|
+
function clampDisplay(text, limit) {
|
|
29
|
+
const points = [...text];
|
|
30
|
+
return points.length <= limit ? text : `${points.slice(0, limit).join("")}…`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 경계 문장 둘. **모든 경로에서** 찍는다. `unsure` 여도, 진단이 잘 나와도 찍는다.
|
|
34
|
+
* 이것이 없으면 사용자가 멀쩡한 서버 코드를 판다. 억제 조건을 만들지 않는다. 설계서 §6.3.
|
|
35
|
+
*/
|
|
36
|
+
const REPAIR_BOUNDARY_LINES = "※ AI 제안입니다. 파일을 고치지 않았고 명세도 그대로입니다.\n※ 명세 쪽이 틀렸다고 판단되면 `mcpeak generate` 로 다시 승인받으세요.\n";
|
|
37
|
+
/** 비대화형에서 `--yes` 없이 부른 경우. 물어볼 수 없는 곳에서 조용히 보내지 않는다. */
|
|
38
|
+
const REPAIR_CONFIRM_REQUIRED_LINE = "오류 [REPAIR_CONFIRM_REQUIRED]: 확인을 받을 수 없는 환경입니다. 전송 내용을 보여줄 수 없어 보내지 않았습니다.\n해결: 내용을 확인했다면 `--yes` 를 붙여 다시 실행하세요.\n";
|
|
39
|
+
/** 사용자가 `n` 을 답한 경로. 실패가 아니라 의도한 종료다. */
|
|
40
|
+
const REPAIR_CANCELLED_LINE = "전송하지 않았습니다.\n";
|
|
41
|
+
const APPROVAL_LABEL = {
|
|
42
|
+
matched: "승인 지문 일치",
|
|
43
|
+
mismatched: "승인 지문 불일치",
|
|
44
|
+
absent: "승인 지문 없음"
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* 지문이 승인 상태가 아닐 때 결과 맨 위에 붙는 블록. 결과가 `unsure` 여도 붙는다. 설계서 §6.5.
|
|
48
|
+
* `matched` 면 빈 문자열이다.
|
|
49
|
+
*/
|
|
50
|
+
function renderApprovalNotice(approval) {
|
|
51
|
+
if (approval === "matched") return "";
|
|
52
|
+
if (approval === "mismatched") return "⚠ 이 명세는 승인 상태가 아닙니다 (지문 불일치).\n 실패 원인이 서버가 아니라 명세일 수 있습니다. 아래 제안은 그 전제로 받았습니다.\n\n";
|
|
53
|
+
return "⚠ 이 명세는 승인 지문이 없습니다.\n 실제 서버로 검증된 적이 없는 명세일 수 있습니다. 아래 제안은 그 전제로 받았습니다.\n\n";
|
|
54
|
+
}
|
|
55
|
+
/** KB 표기. 소수 한 자리다. 바이트 그대로 찍으면 큰 수를 눈으로 못 읽는다. */
|
|
56
|
+
function kilobytes(bytes) {
|
|
57
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 전송 확인 화면(설계서 §6.1). 질문 줄은 붙이지 않는다. 그것은 `ReviewIO.confirm` 이 찍는다.
|
|
61
|
+
*/
|
|
62
|
+
function renderRepairConfirm(view) {
|
|
63
|
+
const scope = view.omittedFailures === 0 ? `실패 ${view.totalFailures}건 중 ${view.sentFailures}건` : `실패 ${view.totalFailures}건 중 ${view.sentFailures}건 (--max-cases ${view.maxCases}, ${view.omittedFailures}건 제외)`;
|
|
64
|
+
let stderrLine;
|
|
65
|
+
if (!view.includeStderr) stderrLine = "(전송하지 않음)";
|
|
66
|
+
else if (view.stderr === void 0 || view.stderr === "") stderrLine = "없음";
|
|
67
|
+
else {
|
|
68
|
+
const lines = view.stderr.split("\n");
|
|
69
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
70
|
+
stderrLine = `${lines.length}줄 ${kilobytes(Buffer.byteLength(view.stderr, "utf8"))} (--no-stderr 로 제외할 수 있습니다)`;
|
|
71
|
+
}
|
|
72
|
+
return [
|
|
73
|
+
"repair 요청을 보냅니다.\n",
|
|
74
|
+
"\n",
|
|
75
|
+
` provider ${view.providerId} (${escapeTerminalText$2(view.model)})\n`,
|
|
76
|
+
` 대상 ${scope}\n`,
|
|
77
|
+
` 명세 상태 ${APPROVAL_LABEL[view.approval]}\n`,
|
|
78
|
+
` stderr ${stderrLine}\n`,
|
|
79
|
+
` 전송 크기 ${kilobytes(view.requestBytes)}\n`,
|
|
80
|
+
"\n",
|
|
81
|
+
"※ 위 내용이 외부 provider 로 전송됩니다.\n",
|
|
82
|
+
"※ stderr 는 서버가 자유롭게 쓰는 텍스트라 경로·토큰·데이터가 섞일 수 있습니다.\n"
|
|
83
|
+
].join("");
|
|
84
|
+
}
|
|
85
|
+
function discardedTotal(discarded) {
|
|
86
|
+
return discarded.unknownCase + discarded.specTarget + discarded.unsureCauses;
|
|
87
|
+
}
|
|
88
|
+
function renderDiscardReasons(discarded) {
|
|
89
|
+
const reasons = [];
|
|
90
|
+
if (discarded.unknownCase > 0) reasons.push(`요청에 없는 케이스를 가리킨 제안 ${discarded.unknownCase}건`);
|
|
91
|
+
if (discarded.specTarget > 0) reasons.push(`승인된 명세를 고치라는 제안 ${discarded.specTarget}건`);
|
|
92
|
+
if (discarded.unsureCauses > 0) reasons.push(`판단 불가 응답에 함께 온 원인 후보 ${discarded.unsureCauses}건`);
|
|
93
|
+
return reasons;
|
|
94
|
+
}
|
|
95
|
+
function renderDiscardActions(discarded) {
|
|
96
|
+
const actions = [];
|
|
97
|
+
if (discarded.specTarget > 0) actions.push(" → 명세가 실제로 틀렸다고 보시면 `mcpeak generate` 로 다시 승인받으세요.\n", " 승인된 명세를 고치라는 제안은 그 전에는 화면에 올리지 않습니다.\n");
|
|
98
|
+
if (discarded.unknownCase > 0 || discarded.unsureCauses > 0) actions.push(" → 같은 번들로 한 번 더 물어보세요. 같은 입력에도 답은 달라집니다.\n");
|
|
99
|
+
return actions;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 제안이 전부 폐기돼 남은 항목이 0 인 경우의 문안.
|
|
103
|
+
*
|
|
104
|
+
* 사용자가 알아야 할 것은 셋이다. AI 가 답을 냈다는 사실, 우리가 왜 버렸는지, 다음에 무엇을
|
|
105
|
+
* 할 수 있는지. generate 가 보존한 사유별 개수로 관련 있는 행동만 안내한다.
|
|
106
|
+
*/
|
|
107
|
+
function renderAllDiscarded(discarded) {
|
|
108
|
+
const parts = [
|
|
109
|
+
`AI 가 원인 후보 ${discardedTotal(discarded)}건을 냈지만 전부 검증에서 제외했습니다.\n`,
|
|
110
|
+
"판단 근거가 없어서가 아니라 답을 그대로 쓸 수 없어서입니다.\n",
|
|
111
|
+
"\n"
|
|
112
|
+
];
|
|
113
|
+
for (const reason of renderDiscardReasons(discarded)) parts.push(` 제외 사유 ${reason}\n`);
|
|
114
|
+
parts.push("\n", ...renderDiscardActions(discarded));
|
|
115
|
+
return parts.join("");
|
|
116
|
+
}
|
|
117
|
+
/** 케이스 머리줄. 툴이 없는 케이스(listTools)는 괄호를 안 찍는다. */
|
|
118
|
+
function caseHeading(caseId, tool) {
|
|
119
|
+
const id = escapeTerminalText$2(caseId);
|
|
120
|
+
return tool === void 0 ? `${id}\n` : `${id} (${escapeTerminalText$2(tool)})\n`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 결과 화면(설계서 §6.2·§6.4·§6.5).
|
|
124
|
+
*
|
|
125
|
+
* 문안은 CLI 가 소유한다. AI 는 필드만 채운다. 라벨·들여쓰기·순서·경계 문장은 전부 우리 것이다.
|
|
126
|
+
* 케이스 순서는 **번들 순서**다. AI 응답 순서로 정렬하지 않는다. 같은 실행을 두 번 볼 때 화면이
|
|
127
|
+
* 달라지면 안 된다.
|
|
128
|
+
*/
|
|
129
|
+
function renderRepairResult(options) {
|
|
130
|
+
const parts = [
|
|
131
|
+
`── 서버 수정 방향 (${options.providerId} / ${escapeTerminalText$2(options.model)}) ──\n`,
|
|
132
|
+
"\n",
|
|
133
|
+
renderApprovalNotice(options.bundle.spec.approval)
|
|
134
|
+
];
|
|
135
|
+
if (options.result.status === "unsure") {
|
|
136
|
+
/**
|
|
137
|
+
* `unsure` 는 두 가지 다른 일이다. 하나로 뭉치면 화면이 거짓말을 한다.
|
|
138
|
+
*
|
|
139
|
+
* `discarded` 합계가 0 이면 provider 가 실제로 판단을 못 한 것이고, 그때만 "근거가 부족" 이
|
|
140
|
+
* 사실이다. 합계가 0 보다 크면 **답은 왔는데 우리가 경계 밖이라 버린 것**이다.
|
|
141
|
+
* 그 경로에 "근거가 부족" 을 찍으면 사용자는 번들에 정보를 더 담아야 한다고 읽는다.
|
|
142
|
+
* 실제로 그 화면을 보고 "구현이 실패한 것 아니냐" 는 질문이 나왔다.
|
|
143
|
+
*/
|
|
144
|
+
if (discardedTotal(options.result.discarded) > 0) parts.push(renderAllDiscarded(options.result.discarded));
|
|
145
|
+
else parts.push("판단 근거가 부족해 원인 후보를 제시하지 못했습니다.\n");
|
|
146
|
+
if (options.result.shortfall !== "") {
|
|
147
|
+
const [first, ...rest] = escapeTerminalText$2(clampDisplay(options.result.shortfall, 500)).split("\n");
|
|
148
|
+
parts.push("\n", ` → ${first}\n`);
|
|
149
|
+
for (const line of rest) parts.push(` ${line}\n`);
|
|
42
150
|
}
|
|
151
|
+
} else {
|
|
152
|
+
const byCase = /* @__PURE__ */ new Map();
|
|
153
|
+
for (const cause of options.result.causes) {
|
|
154
|
+
const list = byCase.get(cause.caseId) ?? [];
|
|
155
|
+
list.push(cause);
|
|
156
|
+
byCase.set(cause.caseId, list);
|
|
157
|
+
}
|
|
158
|
+
let first = true;
|
|
159
|
+
for (const failure of options.bundle.failures) {
|
|
160
|
+
const causes = byCase.get(failure.caseId);
|
|
161
|
+
if (causes === void 0) continue;
|
|
162
|
+
for (const cause of causes) {
|
|
163
|
+
if (!first) parts.push("\n");
|
|
164
|
+
first = false;
|
|
165
|
+
parts.push(caseHeading(failure.caseId, failure.tool));
|
|
166
|
+
parts.push(` 원인 후보 ${escapeTerminalText$2(cause.summary)}\n`);
|
|
167
|
+
if (cause.location !== "") parts.push(` 확인할 곳 ${escapeTerminalText$2(cause.location)}\n`);
|
|
168
|
+
if (cause.evidence !== "") parts.push(` 근거 ${escapeTerminalText$2(cause.evidence)}\n`);
|
|
169
|
+
if (cause.target === "spec" && options.bundle.spec.approval !== "matched") parts.push(" 분류 명세 쪽 원인으로 봄\n");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (discardedTotal(options.result.discarded) > 0 && options.result.status !== "unsure") {
|
|
174
|
+
parts.push("\n");
|
|
175
|
+
for (const reason of renderDiscardReasons(options.result.discarded)) parts.push(`※ ${reason}이 검증에서 제외됐습니다.\n`);
|
|
176
|
+
parts.push("\n", ...renderDiscardActions(options.result.discarded));
|
|
177
|
+
}
|
|
178
|
+
parts.push("\n", REPAIR_BOUNDARY_LINES);
|
|
179
|
+
return parts.join("");
|
|
180
|
+
}
|
|
181
|
+
/** provider 실패 안내. 닫힌 enum 만 화면에 오고 raw stdout·stderr 는 오지 않는다. */
|
|
182
|
+
function renderRepairProviderFailure(failure) {
|
|
183
|
+
if (failure.reason === "unknownOption") {
|
|
184
|
+
const option = failure.option === void 0 ? "" : ` 옵션: ${failure.option}`;
|
|
185
|
+
return `오류 [REPAIR_PROVIDER_OPTION]: 설치된 ${failure.providerId} 가 우리가 넘긴 옵션을 모릅니다.${option}\n → 설치도 인증도 원인이 아닙니다. CLI 가 뜨기도 전에 옵션 해석에서 멈췄습니다.\n해결: \`${failure.providerId} --version\` 으로 버전을 확인하고 최신 버전으로 올리세요. 파일은 하나도 바뀌지 않았습니다.\n`;
|
|
186
|
+
}
|
|
187
|
+
const reason = failure.reason === void 0 ? "" : ` (${failure.reason})`;
|
|
188
|
+
return `오류 [REPAIR_PROVIDER_FAILED]: ${failure.providerId} 에게 진단을 받지 못했습니다. 코드: ${failure.code}${reason}\n해결: \`${failure.providerId} --version\` 으로 설치와 인증을 확인한 뒤 다시 실행하세요. 파일은 하나도 바뀌지 않았습니다.\n`;
|
|
189
|
+
}
|
|
190
|
+
/** 응답이 계약을 못 지킨 경우. 우리가 버렸다는 사실을 말한다. */
|
|
191
|
+
const REPAIR_INVALID_RESULT_LINE = "오류 [REPAIR_RESULT_INVALID]: provider 응답이 진단 형식을 지키지 않아 버렸습니다.\n해결: 같은 번들로 다시 실행하거나 다른 `--model` 로 시도하세요. 파일은 하나도 바뀌지 않았습니다.\n";
|
|
192
|
+
/**
|
|
193
|
+
* 응답이 `maxResultBytes` 를 넘어 거절된 경우. 형식이 틀린 것과 다르다. 답은 형식을 지켰지만
|
|
194
|
+
* 우리가 정한 상한 밖이라 받지 않은 것이고, 사용자가 할 일도 다르다. 같은 문장을 쓰면
|
|
195
|
+
* "다른 모델로 시도하세요" 라는 엉뚱한 안내를 하게 된다.
|
|
196
|
+
*/
|
|
197
|
+
const REPAIR_RESULT_LIMIT_LINE = "오류 [REPAIR_RESULT_LIMIT_EXCEEDED]: provider 응답이 허용 크기를 넘어 받지 않았습니다.\n해결: `--max-cases` 로 한 번에 묻는 실패 수를 줄여 다시 실행하세요. 파일은 하나도 바뀌지 않았습니다.\n";
|
|
198
|
+
/** 승인 검사에 걸린 경우. 사용자가 본 것과 나가는 것이 달라졌다는 뜻이다. */
|
|
199
|
+
const REPAIR_APPROVAL_INVALIDATED_LINE = "오류 [REPAIR_APPROVAL_INVALIDATED]: 확인한 내용과 보내려는 내용이 달라 전송을 멈췄습니다.\n해결: 같은 번들로 다시 실행하세요. 파일은 하나도 바뀌지 않았습니다.\n";
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/connect-target.ts
|
|
202
|
+
const ok = (value) => ({
|
|
203
|
+
ok: true,
|
|
204
|
+
value
|
|
205
|
+
});
|
|
206
|
+
const err = (message) => ({
|
|
207
|
+
ok: false,
|
|
208
|
+
message
|
|
209
|
+
});
|
|
210
|
+
/** RFC 9110 의 field-name token. `core/src/options.ts` 의 같은 이름 상수와 같은 집합이다. */
|
|
211
|
+
const HTTP_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
212
|
+
/**
|
|
213
|
+
* POSIX 환경변수 이름. 이 검사가 `--header-env` 를 값이 아니라 **이름** 자리로 못박는다.
|
|
214
|
+
*
|
|
215
|
+
* `--header-env Authorization="Bearer abc123"` 처럼 값을 바로 넣으려는 시도가 공백과 따옴표
|
|
216
|
+
* 때문에 여기서 걸린다. 걸리지 않으면 우리가 막으려던 그 노출(`ps` · 셸 히스토리)이 그대로
|
|
217
|
+
* 일어나므로, 이 정규식은 편의가 아니라 이 옵션의 안전 근거다.
|
|
218
|
+
*/
|
|
219
|
+
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
220
|
+
/** 되짚어 준 값 하나의 표시 상한. `process-diagnostics.ts` 의 `MAX_LINE_CHARACTERS` 와 같다. */
|
|
221
|
+
const MAX_ECHOED_CHARACTERS = 200;
|
|
222
|
+
/**
|
|
223
|
+
* 오류 문장에 되짚어 넣는 **사용자 입력**을 무해하게 만든다.
|
|
224
|
+
*
|
|
225
|
+
* 값 단위로만 건다. 문장 전체에 걸면 우리가 쓴 개행(`\n→ …`)까지 이스케이프되어 안내가 한 줄로
|
|
226
|
+
* 뭉개진다 — #289 가 정확히 그 결함이었다.
|
|
227
|
+
*
|
|
228
|
+
* 거는 이유는 `format()` 이 더 이상 `failure.message` 를 통째로 이스케이프하지 않기 때문이다.
|
|
229
|
+
* `mcpeak test s.json --url $'\e[2J…'` 처럼 ANSI 를 실은 인자가 그대로 터미널에 닿을 수 있다.
|
|
230
|
+
*/
|
|
231
|
+
function echoValue(value) {
|
|
232
|
+
const escaped = escapeTerminalText$2(value);
|
|
233
|
+
return escaped.length <= MAX_ECHOED_CHARACTERS ? escaped : `${escaped.slice(0, MAX_ECHOED_CHARACTERS)}…`;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* 비밀값을 셸 히스토리에 남기지 않고 환경변수에 넣는 방법. 안내 문장이 되풀이해 쓴다.
|
|
237
|
+
*
|
|
238
|
+
* **`MCP_TOKEN='Bearer …' mcpeak …` 를 예시로 쓰지 않는다.** 그 한 줄은 환경변수를 쓰긴 하지만
|
|
239
|
+
* 명령 자체가 히스토리 파일에 토큰째로 남아, 이 옵션이 막으려던 노출의 절반을 그대로 만든다.
|
|
240
|
+
* 우리가 권하는 예시가 우리 근거를 깎으면 안 된다.
|
|
241
|
+
*/
|
|
242
|
+
const SECRET_INPUT_HINT = "값은 셸 히스토리에 남지 않게 넣으세요. 예: `read -rs MCP_TOKEN; export MCP_TOKEN`";
|
|
243
|
+
/**
|
|
244
|
+
* `--url` 값을 검증한다.
|
|
245
|
+
*
|
|
246
|
+
* 스킴을 http·https 로 제한한다. `file:` 이나 `ws:` 를 넘기면 `core` 의 SDK 안쪽에서
|
|
247
|
+
* 엉뚱한 문장으로 실패하고, 그 문장은 우리가 고칠 수 없는 자리에서 나온다.
|
|
248
|
+
*/
|
|
249
|
+
function parseUrlOption(raw) {
|
|
250
|
+
let url;
|
|
251
|
+
try {
|
|
252
|
+
url = new URL(raw);
|
|
253
|
+
} catch {
|
|
254
|
+
return err(`\`--url\` 값이 올바른 URL 이 아닙니다: '${echoValue(raw)}'\n→ 스킴을 포함한 절대 URL 이어야 합니다. 예: https://mcp.example.com/v1`);
|
|
255
|
+
}
|
|
256
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return err(`\`--url\` 은 http 또는 https 만 받습니다. 받은 스킴: '${echoValue(url.protocol.replace(":", ""))}'\n→ Streamable HTTP MCP 서버의 엔드포인트를 넘기세요.`);
|
|
257
|
+
if (url.username !== "" || url.password !== "") return err("`--url` 에 자격증명을 넣지 마세요.\n→ URL 에 넣은 자격증명은 셸 히스토리와 `ps` 목록에 그대로 남습니다.\n→ 인증이 필요하면 `--header-env <헤더이름>=<환경변수이름>` 을 쓰세요.");
|
|
258
|
+
return ok(url.toString());
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* `--header-env` 한 건을 파싱한다. 형식은 `<헤더이름>=<환경변수이름>` 이다.
|
|
262
|
+
*
|
|
263
|
+
* 값을 읽지 않는다. 이 함수는 argv 만 본다.
|
|
264
|
+
*/
|
|
265
|
+
function parseHeaderEnvOption(raw) {
|
|
266
|
+
const separator = raw.indexOf("=");
|
|
267
|
+
if (separator <= 0) return err(`\`--header-env\` 값의 형식이 올바르지 않습니다: '${echoValue(raw)}'\n→ \`<헤더이름>=<환경변수이름>\` 형식이어야 합니다. 예: --header-env Authorization=MCP_TOKEN`);
|
|
268
|
+
const header = raw.slice(0, separator);
|
|
269
|
+
const envName = raw.slice(separator + 1);
|
|
270
|
+
if (!HTTP_TOKEN_PATTERN.test(header)) return err(`\`--header-env\` 의 헤더 이름에 쓸 수 없는 문자가 있습니다: '${echoValue(header)}'\n→ 헤더 이름은 공백 없는 토큰이어야 합니다. 예: Authorization, X-Api-Key`);
|
|
271
|
+
if (!ENV_NAME_PATTERN.test(envName)) return err(`\`--header-env\` 의 '${echoValue(header)}' 자리에 환경변수 **이름**이 아닌 값이 온 것 같습니다: '${echoValue(envName)}'\n→ 이 옵션은 헤더 값을 직접 받지 않습니다. 값을 argv 에 실으면 \`ps\` 목록과 셸 히스토리에 남습니다.
|
|
272
|
+
→ 값은 환경변수에 넣고 그 이름만 넘기세요. ${SECRET_INPUT_HINT}`);
|
|
273
|
+
return ok({
|
|
274
|
+
header,
|
|
275
|
+
envName
|
|
43
276
|
});
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
277
|
+
}
|
|
278
|
+
function createHeaderEnvCollector() {
|
|
279
|
+
/** 소문자 이름 → 처음 쓴 표기. */
|
|
280
|
+
const seen = /* @__PURE__ */ new Map();
|
|
281
|
+
/**
|
|
282
|
+
* null 프로토타입으로 만든다. 평범한 `{}` 에 `values["__proto__"] = …` 를 하면 값이 조용히
|
|
283
|
+
* 사라진다(프로토타입 설정으로 해석되고 문자열이라 무시된다). `__proto__` · `constructor` ·
|
|
284
|
+
* `toString` 은 모두 RFC 9110 토큰을 통과하는 유효한 헤더 이름이다.
|
|
285
|
+
*/
|
|
286
|
+
const values = Object.create(null);
|
|
287
|
+
return {
|
|
288
|
+
add(header, envName) {
|
|
289
|
+
const first = seen.get(header.toLowerCase());
|
|
290
|
+
if (first === header) return `\`--header-env\` 의 '${echoValue(header)}' 헤더가 두 번 지정됐습니다.`;
|
|
291
|
+
if (first !== void 0) return `\`--header-env\` 의 '${echoValue(header)}' 는 앞서 지정한 '${echoValue(first)}' 와 같은 헤더입니다.\n→ HTTP 헤더 이름은 대소문자를 구분하지 않습니다. 둘 중 하나만 남기세요.`;
|
|
292
|
+
seen.set(header.toLowerCase(), header);
|
|
293
|
+
values[header] = envName;
|
|
294
|
+
},
|
|
295
|
+
isEmpty: () => seen.size === 0,
|
|
296
|
+
snapshot: () => Object.freeze({ ...values })
|
|
53
297
|
};
|
|
298
|
+
}
|
|
299
|
+
/** 대상을 사람이 읽는 한 조각으로 만든다. 오류 문장이 무엇에 붙으려 했는지 말할 때 쓴다. */
|
|
300
|
+
function describeTarget(target) {
|
|
301
|
+
return target.transport === "stdio" ? [target.command, ...target.args].join(" ") : target.url;
|
|
302
|
+
}
|
|
303
|
+
/** `openConnection` 이 연결 전에 멈춘 경우. 서브커맨드가 자기 오류 코드로 옮긴다. */
|
|
304
|
+
var ConnectTargetError = class extends Error {};
|
|
305
|
+
/**
|
|
306
|
+
* 대상을 열어 `CliConnection` 을 돌려준다. `core` 가 던지는 `McpClientError` 는 그대로
|
|
307
|
+
* 통과시킨다 — 그 오류의 진단과 문장이 기존 실패 경로가 기대하는 값이다.
|
|
308
|
+
*
|
|
309
|
+
* `env` 는 stdio 전용이다. External 배선이 만든 자식 환경 변수라 띄울 프로세스가 있어야
|
|
310
|
+
* 뜻이 있고, 파서가 `--url` 과 External 세션 옵션의 동시 사용을 이미 막는다.
|
|
311
|
+
*/
|
|
312
|
+
async function openConnection(target, dependencies, options) {
|
|
313
|
+
if (target.transport === "stdio") return await dependencies.connectStdio({
|
|
314
|
+
command: target.command,
|
|
315
|
+
args: target.args,
|
|
316
|
+
...options?.env === void 0 ? {} : { env: options.env }
|
|
317
|
+
});
|
|
318
|
+
const connectHttp = dependencies.connectHttp;
|
|
319
|
+
if (connectHttp === void 0) throw new ConnectTargetError("이 진입점은 원격(Streamable HTTP) 서버를 지원하지 않습니다.\n→ `--url` 대신 `--command` 로 서버를 직접 띄우세요.");
|
|
320
|
+
const headers = resolveHeaders(target.headerEnv, dependencies.readEnv);
|
|
321
|
+
if (!headers.ok) throw new ConnectTargetError(headers.message);
|
|
322
|
+
const connection = await connectHttp({
|
|
323
|
+
url: target.url,
|
|
324
|
+
...Object.keys(headers.value).length === 0 ? {} : { headers: headers.value }
|
|
325
|
+
});
|
|
54
326
|
return {
|
|
55
|
-
client,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
327
|
+
client: connection.client,
|
|
328
|
+
getDiagnostics: () => connection.getDiagnostics(),
|
|
329
|
+
close: () => connection.close(),
|
|
330
|
+
forceClose: () => connection.close()
|
|
59
331
|
};
|
|
60
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* 환경변수에서 헤더 값을 읽는다.
|
|
335
|
+
*
|
|
336
|
+
* **실패 문장에 값을 싣지 않는다.** 환경변수 이름만 말한다. 값이 비었다는 사실을 알리려다
|
|
337
|
+
* 값을 화면에 찍으면 이 옵션이 존재하는 이유가 없어진다.
|
|
338
|
+
*/
|
|
339
|
+
function resolveHeaders(headerEnv, readEnv) {
|
|
340
|
+
const entries = Object.entries(headerEnv);
|
|
341
|
+
if (entries.length === 0) return ok({});
|
|
342
|
+
if (readEnv === void 0) return err("이 진입점은 `--header-env` 를 지원하지 않습니다 (환경변수를 읽을 수 없습니다).\n→ 인증이 필요 없는 엔드포인트인지 확인하거나 터미널에서 `mcpeak` 을 직접 실행하세요.");
|
|
343
|
+
/**
|
|
344
|
+
* **null 프로토타입으로 만든다.** 평범한 `{}` 에 `headers["__proto__"] = …` 를 하면 값이
|
|
345
|
+
* 조용히 사라진다(프로토타입 설정으로 해석되고 문자열이라 무시된다). `__proto__` 는 RFC 9110
|
|
346
|
+
* 토큰을 통과하는 유효한 헤더 이름이므로, 그대로 두면 사용자가 지정한 헤더가 아무 말 없이
|
|
347
|
+
* 빠진 채 요청이 나간다.
|
|
348
|
+
*/
|
|
349
|
+
const headers = Object.create(null);
|
|
350
|
+
for (const [header, envName] of entries) {
|
|
351
|
+
const value = readEnv(envName);
|
|
352
|
+
if (value === void 0 || value === "") return err(`환경변수 '${echoValue(envName)}' 이 비어 있어 '${echoValue(header)}' 헤더를 만들 수 없습니다.\n→ ${SECRET_INPUT_HINT}`);
|
|
353
|
+
if (/[\r\n\0]/.test(value)) return err(`환경변수 '${echoValue(envName)}' 의 값에 개행이나 NUL 이 들어 있어 '${echoValue(header)}' 헤더로 쓸 수 없습니다.\n→ 값 끝의 줄바꿈이 흔한 원인입니다. 값을 확인하고 다시 실행하세요.`);
|
|
354
|
+
headers[header] = value;
|
|
355
|
+
}
|
|
356
|
+
return ok(headers);
|
|
357
|
+
}
|
|
61
358
|
//#endregion
|
|
62
359
|
//#region src/dry-run.ts
|
|
63
360
|
/** 보고서가 상한을 넘긴 경우의 안내. 케이스 수 말고는 사용자가 손댈 수 있는 것이 없다. */
|
|
@@ -93,32 +390,53 @@ const caseBlocks = (report) => {
|
|
|
93
390
|
return report.cases.map((_, index) => (blocks[index] ?? []).join("\n"));
|
|
94
391
|
};
|
|
95
392
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* 그 결과다. 그래서 첫 지점에서 자른다.
|
|
393
|
+
* 연결 상실 사유별 문장. `runner` 의 `reporter.ts`·`junit.ts` 가 쓰는 어휘를 그대로 따른다.
|
|
394
|
+
* 같은 죽음을 `test` 에서 볼 때와 `generate` 에서 볼 때 다른 낱말로 부르면 두 번 배우게 된다.
|
|
99
395
|
*/
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
index,
|
|
105
|
-
message: diagnostic.message
|
|
106
|
-
};
|
|
107
|
-
}
|
|
396
|
+
const CONNECTION_LOST_TEXT = {
|
|
397
|
+
processExited: "서버 프로세스가 종료됐습니다",
|
|
398
|
+
transportFailed: "서버와의 연결이 끊겼습니다",
|
|
399
|
+
httpSessionLost: "서버가 세션을 잃었습니다"
|
|
108
400
|
};
|
|
109
401
|
/**
|
|
110
|
-
*
|
|
402
|
+
* 종료 코드·시그널 괄호. 둘 다 관측하지 못했으면 괄호를 만들지 않는다. `(없음)` 은 관측하지
|
|
403
|
+
* 못한 것을 관측했다고 말하는 것이다. reporter.ts 의 같은 이름 함수와 같은 규칙이다.
|
|
404
|
+
*/
|
|
405
|
+
const exitParens = (stop) => {
|
|
406
|
+
const parts = [];
|
|
407
|
+
if (stop.exitCode !== void 0) parts.push(`종료 코드 ${stop.exitCode}`);
|
|
408
|
+
if (stop.signal !== void 0) parts.push(`시그널 ${escapeTerminalText$2(stop.signal)}`);
|
|
409
|
+
return parts.length === 0 ? "" : ` (${parts.join(", ")})`;
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* 러너가 케이스를 다 돌기 전에 멈춘 경우의 중단 사유. `stopReason` 이 그 사실을 말한다.
|
|
111
413
|
*
|
|
112
414
|
* 이것을 읽지 않으면 실행되지도 않은 `notRun` 케이스가 실패 목록에 섞여 분류 화면으로 간다.
|
|
113
415
|
* 사용자가 그것을 `서버 결함` 으로 고르면 서버에 보낸 적도 없는 호출이 회귀 테스트가 된다.
|
|
416
|
+
*
|
|
417
|
+
* **연결이 끝났는지를 여기서 짐작하지 않는다.** 러너가 core 의 오류 코드로 판정해 넘겨준
|
|
418
|
+
* `connectionLost` 만 연결 상실이다(#279 · ADR-0073). 예전에는 이 파일이 진단 코드가
|
|
419
|
+
* `OPERATION_FAILED` 인 첫 케이스를 연결 끊김으로 읽었는데, 그 코드는 서버가 살아서 낸 평범한
|
|
420
|
+
* 툴 오류에도 붙는다. 그래서 툴 하나가 오류를 내면 멀쩡히 돌아간 뒤 케이스의 판정까지 버리고
|
|
421
|
+
* "연결이 끊겼습니다" 라고 말했다.
|
|
114
422
|
*/
|
|
115
|
-
const
|
|
423
|
+
const abortedFromStop = (report) => {
|
|
116
424
|
const stop = report.stopReason;
|
|
117
425
|
if (stop === void 0) return void 0;
|
|
118
|
-
const
|
|
426
|
+
const nameOf = (caseId) => escapeTerminalText$2(report.cases.find((result) => result.spec.id === caseId)?.spec.name ?? caseId);
|
|
119
427
|
const suffix = "남은 케이스는 실행되지 않았습니다.";
|
|
120
|
-
if (stop.type === "
|
|
121
|
-
|
|
428
|
+
if (stop.type === "connectionLost") return {
|
|
429
|
+
reason: "connectionLost",
|
|
430
|
+
detail: `케이스 '${nameOf(stop.caseId)}' 에서 ${CONNECTION_LOST_TEXT[stop.cause]}${exitParens(stop)}. ${suffix}`
|
|
431
|
+
};
|
|
432
|
+
if (stop.type === "timeout") return {
|
|
433
|
+
reason: "stopped",
|
|
434
|
+
detail: `케이스 '${nameOf(stop.caseId)}' 가 제한 시간 안에 끝나지 않았습니다. ${suffix}`
|
|
435
|
+
};
|
|
436
|
+
return {
|
|
437
|
+
reason: "stopped",
|
|
438
|
+
detail: stop.caseId === void 0 ? `실행이 중단됐습니다. ${suffix}` : `케이스 '${nameOf(stop.caseId)}' 에서 실행이 중단됐습니다. ${suffix}`
|
|
439
|
+
};
|
|
122
440
|
};
|
|
123
441
|
const toResult = (report) => {
|
|
124
442
|
const blocks = caseBlocks(report);
|
|
@@ -130,21 +448,10 @@ const toResult = (report) => {
|
|
|
130
448
|
rejectionBasis: result.rejectionBasis,
|
|
131
449
|
...result.rejectionBody === void 0 ? {} : { rejectionBody: result.rejectionBody }
|
|
132
450
|
}));
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
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 {
|
|
451
|
+
const aborted = abortedFromStop(report);
|
|
452
|
+
if (aborted !== void 0) return {
|
|
143
453
|
outcomes: outcomes.filter((outcome) => outcome.status !== "notRun"),
|
|
144
|
-
aborted
|
|
145
|
-
reason: "stopped",
|
|
146
|
-
detail: stopped
|
|
147
|
-
}
|
|
454
|
+
aborted
|
|
148
455
|
};
|
|
149
456
|
return { outcomes };
|
|
150
457
|
};
|
|
@@ -320,35 +627,96 @@ const FINDING_GROUP = {
|
|
|
320
627
|
};
|
|
321
628
|
//#endregion
|
|
322
629
|
//#region src/help.ts
|
|
323
|
-
|
|
630
|
+
/**
|
|
631
|
+
* 원격(Streamable HTTP) 대상 옵션. `test` 와 `generate` 가 같은 문장을 쓴다 — 같은 사람이
|
|
632
|
+
* 두 커맨드를 잇달아 쓰는데 설명이 다르면 그 차이를 기능으로 읽는다(#137).
|
|
633
|
+
*
|
|
634
|
+
* `--header-env` 가 값을 직접 받지 않는 이유를 여기서 말한다. 이유를 모르면 이 옵션은 그냥
|
|
635
|
+
* 번거로운 `--header` 로 보이고, 사용자는 우회로를 찾다가 우리가 막으려던 그 노출을 만든다.
|
|
636
|
+
*/
|
|
637
|
+
const REMOTE_TARGET_OPTIONS = ` --url <URL> 이미 떠 있는 Streamable HTTP MCP 서버에 붙습니다. \`--command\` 및
|
|
638
|
+
\`--arg\` 와 함께 쓸 수 없고, 띄울 프로세스가 없으므로 stdio
|
|
639
|
+
전용 옵션도 받지 않습니다. 어느 옵션이 그런지는 함께 썼을 때
|
|
640
|
+
오류 문장이 말합니다
|
|
641
|
+
--header-env <헤더이름>=<환경변수이름>
|
|
642
|
+
요청 헤더를 환경변수에서 읽어 붙입니다. 값을 직접 받지 않는
|
|
643
|
+
이유는 명령줄에 쓴 토큰이 \`ps\` 목록과 셸 히스토리에 그대로
|
|
644
|
+
남기 때문입니다. 값을 넣을 때도 히스토리에 남기지 마세요:
|
|
645
|
+
read -rs MCP_TOKEN; export MCP_TOKEN
|
|
646
|
+
mcpeak … --header-env Authorization=MCP_TOKEN`;
|
|
647
|
+
const TEST_USAGE = "사용법: mcpeak test <suite.json> (-- <executable> [args...] | --command <executable> [--arg <value> ...] | --url <URL> [--header-env <헤더이름>=<환경변수이름> ...]) [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>] [--session <path> | --record-session <path>]";
|
|
324
648
|
/**
|
|
325
649
|
* 시험 실행 옵션 설명. `--determinism` 은 툴을 2회 호출하므로 부작용이 있는 서버에서 모르고
|
|
326
650
|
* 쓰면 사고가 난다. 그 경고가 사용법 한 줄에는 들어가지 않는다.
|
|
327
651
|
*/
|
|
328
652
|
const TEST_OPTIONS = `옵션:
|
|
653
|
+
-- <executable> [args...]
|
|
654
|
+
\`--\` 뒤는 전부 서버를 띄울 명령입니다. 첫 토큰이 실행 파일,
|
|
655
|
+
나머지가 그 인자입니다. \`--command\`/\`--arg\` 와 같은 일을 하며
|
|
656
|
+
함께 쓸 수 없습니다
|
|
329
657
|
--determinism 스위트를 2회 실행해 결과가 같은지 확인합니다. 툴을 2회
|
|
330
658
|
호출하므로 부작용이 있는 서버에서는 샌드박스에서 쓰세요.
|
|
331
659
|
--reset-cmd 와 함께 쓰면 결정론성 확인이 되고, 없으면
|
|
332
|
-
"2회 결과가 같았다" 까지만
|
|
660
|
+
"2회 결과가 같았다" 까지만 확인합니다.
|
|
661
|
+
비차단 진단입니다 — 차이를 찾아도 종료 코드는 시험 판정
|
|
662
|
+
그대로이며 이 진단만으로 1 이 되지 않습니다. CI 를 막으려면
|
|
663
|
+
--json 의 determinism 키를 보세요.
|
|
664
|
+
차이 지점의 양쪽 값을 화면에 싣습니다. \`token\`·\`apiKey\` 같은
|
|
665
|
+
이름의 자리는 가려지지만, 서버가 결과를 JSON 문자열로 만들어
|
|
666
|
+
text 블록 하나에 실으면 그 안의 비밀값은 이름이 없어
|
|
667
|
+
가려지지 않습니다. 실행마다 비밀값을 새로 발급하는 서버라면
|
|
668
|
+
출력을 CI 로그에 남기기 전에 확인하세요
|
|
333
669
|
--reset-cmd <command> 각 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지
|
|
334
|
-
않으므로 파이프나 && 는 쓸 수
|
|
335
|
-
|
|
670
|
+
않으므로 파이프나 && 는 쓸 수 없습니다
|
|
671
|
+
${REMOTE_TARGET_OPTIONS}
|
|
672
|
+
--record-session <path>
|
|
673
|
+
서버가 \`globalThis.fetch\` 로 밖에 부른 HTTP 호출만 녹화합니다.
|
|
674
|
+
서버는 실제로 실행됩니다. \`node:http\`·\`node:https\` 같은 범위 밖 호출은
|
|
675
|
+
잡히지 않아 실제 네트워크로 나갈 수 있습니다
|
|
676
|
+
--session <path> 녹화한 외부 호출을 재생합니다. 서버는 실제로 실행되지만 외부
|
|
677
|
+
API 호출은 \`globalThis.fetch\` 범위 안에서만 가로챕니다.
|
|
678
|
+
범위 밖 호출은 실제 네트워크로 나갈 수 있으며, 범위 밖 호출이
|
|
679
|
+
의심되는 경우 종료 때 알립니다
|
|
680
|
+
|
|
681
|
+
두 세션 옵션은 서로, 그리고 \`--determinism\` 과 함께 쓸 수 없습니다. \`--determinism\` 은 서버에
|
|
682
|
+
2회 연결하는데 세션은 연결 하나에 묶여 있어, 2회차가 같은 세션을 쓰면 반복 호출 순번이
|
|
683
|
+
어긋나고 새 세션을 쓰면 비교 기준이 갈라집니다.
|
|
684
|
+
|
|
685
|
+
세션 파일에는 외부 API 응답이 저장되므로 .gitignore 를 확인하세요. \`token\`·\`apiKey\` 같은
|
|
686
|
+
이름의 값은 저장 전에 가려집니다. 다만 **본문에 실린 URL 은 가려지지 않습니다** — 이름으로
|
|
687
|
+
판정할 수 없는 자리라서입니다. 녹화가 끝나면 그런 URL 이 몇 건 남았는지 알려 주며, 경로 자체가
|
|
688
|
+
자격증명인 endpoint(Slack·Discord webhook 등)를 녹화했다면 커밋 전에 내용을 확인하세요.
|
|
689
|
+
|
|
690
|
+
런타임에 따라 세션 옵션을 쓴 실행에서 \`ExperimentalWarning: SQLite is an experimental
|
|
691
|
+
feature\` 가 stderr 에 한 줄 찍힙니다(실측: Node 22.18.0 에서 나오고 24.16.0 에서는 안 납니다).
|
|
692
|
+
\`node:sqlite\` 를 처음 로드할 때 한 번 나오므로 실행당 한 줄이며, 세션 옵션이 없는 실행에는
|
|
693
|
+
붙지 않습니다. Node 가 그 모듈을 아직 실험적으로 표시한다는 뜻이고, **저장된 녹화는 표준
|
|
694
|
+
SQLite 파일이라 영향받지 않습니다.**`;
|
|
695
|
+
const GENERATE_USAGE = "사용법: mcpeak generate --out <suite.json> (-- <executable> [args...] | --command <executable> [--arg <value> ...] | --url <URL> [--header-env <헤더이름>=<환경변수이름> ...]) [--suite-id <id>] [--name <name>] [--baseline-only] [--provider <codex|claude>] [--model <model>] [--no-dry-run] [--reset-cmd <command>] [--no-repair] [--force]";
|
|
336
696
|
/**
|
|
337
|
-
* 시험 실행 옵션 설명. 사용법 한 줄로는 `--
|
|
338
|
-
*
|
|
697
|
+
* 시험 실행 옵션 설명. 사용법 한 줄로는 `--reset-cmd` 가 셸을 거치지 않는다는 제약을 알 수
|
|
698
|
+
* 없다. 모르고 쓰면 사고가 나는 값이다.
|
|
339
699
|
*/
|
|
340
700
|
const GENERATE_DRY_RUN_OPTIONS = `옵션:
|
|
701
|
+
-- <executable> [args...]
|
|
702
|
+
\`--\` 뒤는 전부 서버를 띄울 명령입니다. 첫 토큰이 실행 파일,
|
|
703
|
+
나머지가 그 인자입니다. \`--command\`/\`--arg\` 와 같은 일을 하며
|
|
704
|
+
함께 쓸 수 없습니다
|
|
705
|
+
--suite-id <id> 생략하면 \`--out\` 파일명에서 뽑습니다 (contract.suite.json → contract)
|
|
706
|
+
--name <name> 생략하면 \`--suite-id\` 와 같은 방식으로 뽑습니다
|
|
707
|
+
--out <suite.json> 저장 경로입니다. **파일명이 승인 지문에 들어갑니다** — id·name 을
|
|
708
|
+
생략해 파일명에서 뽑았다면, 파일명을 바꿔 다시 생성할 때 지문이
|
|
709
|
+
달라져 재승인이 뜹니다. 고정하려면 --suite-id 와 --name 을
|
|
710
|
+
직접 지정하세요
|
|
341
711
|
--no-dry-run 승인 전 시험 실행을 건너뜁니다. 케이스가 실제 서버에서 확인되지
|
|
342
712
|
않은 채 저장됩니다
|
|
343
|
-
--cassette <path> 서버 응답을 녹화·재생합니다. 반복 실행에서 서버를 다시 부르지
|
|
344
|
-
않습니다. 응답 전문이 저장되므로 .gitignore 를 확인하세요
|
|
345
|
-
--record 카세트를 처음부터 다시 녹화합니다 (--cassette 필요)
|
|
346
713
|
--reset-cmd <command> 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지 않으므로
|
|
347
714
|
파이프나 && 는 쓸 수 없습니다
|
|
348
715
|
--no-repair 시험 실행이 실패해도 입력값을 고쳐 다시 시도하지 않습니다.
|
|
349
716
|
실패가 곧바로 분류 화면으로 갑니다
|
|
350
717
|
--force \`--out\` 경로에 파일이 있으면 지우고 새로 씁니다. 기본은 저장을
|
|
351
|
-
멈추는
|
|
718
|
+
멈추는 것입니다
|
|
719
|
+
${REMOTE_TARGET_OPTIONS}`;
|
|
352
720
|
const REPAIR_USAGE = "사용법: mcpeak repair <bundle.json> --provider <codex|claude> --model <model> [--max-cases <N>] [--no-stderr] [--yes]";
|
|
353
721
|
/**
|
|
354
722
|
* repair 옵션 설명. 사용법 한 줄로는 `--no-stderr` 가 무엇을 빼는지, `--yes` 가 무엇을 건너뛰는지
|
|
@@ -361,49 +729,7 @@ const REPAIR_OPTIONS = `옵션:
|
|
|
361
729
|
--no-stderr 서버 stderr 를 전송에서 뺍니다. stderr 는 서버가 자유롭게 쓰는
|
|
362
730
|
텍스트라 경로·토큰·데이터가 섞일 수 있습니다
|
|
363
731
|
--yes 전송 확인 화면을 건너뜁니다. 비대화형 환경에서 필요합니다`;
|
|
364
|
-
const
|
|
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 서버 테스트 프레임워크
|
|
732
|
+
const GLOBAL_HELP = `MCPeak — MCP 서버 테스트 프레임워크
|
|
407
733
|
|
|
408
734
|
사용법: mcpeak <명령> [옵션]
|
|
409
735
|
mcpeak help [명령]
|
|
@@ -412,8 +738,6 @@ const GLOBAL_HELP = `OhMyMCP — MCP 서버 테스트 프레임워크
|
|
|
412
738
|
test JSON 테스트 명세로 MCP 서버를 실행하고 검증합니다.
|
|
413
739
|
generate MCP 서버의 툴 스키마에서 테스트 명세를 생성합니다.
|
|
414
740
|
repair 실패한 test 실행의 번들로 서버 코드의 원인 후보를 제안받습니다.
|
|
415
|
-
replay 녹화된 카세트로 서버 없이 테스트 명세를 재생합니다.
|
|
416
|
-
verify 카세트가 아직 실서버 응답과 맞는지 확인합니다 (카세트를 고치지 않음).
|
|
417
741
|
|
|
418
742
|
옵션:
|
|
419
743
|
-h, --help 도움말을 표시합니다.
|
|
@@ -423,19 +747,20 @@ const GLOBAL_HELP = `OhMyMCP — MCP 서버 테스트 프레임워크
|
|
|
423
747
|
mcpeak help <명령>
|
|
424
748
|
mcpeak <명령> --help
|
|
425
749
|
`;
|
|
426
|
-
|
|
750
|
+
/**
|
|
751
|
+
* 여러 실패 안내가 이 문구를 그대로 되풀이한다. `export` 하는 이유는 `test-command.ts` 의
|
|
752
|
+
* `COMMAND_NOT_IMPLEMENTED` 안내가 한때 이 목록을 손으로 베껴 적었다가 `replay` 제거(ADR-0059)
|
|
753
|
+
* 뒤에도 낡은 채 남았기 때문이다(#289). 정본을 하나만 두면 같은 일이 되풀이되지 않는다.
|
|
754
|
+
*
|
|
755
|
+
* **여기에 어느 서브커맨드의 사용법도 붙이지 않는다.** 오타는 "어느 옵션을 쓰나" 가 아니라
|
|
756
|
+
* "어떤 명령이 있나" 를 모르는 상태다. 특정 서브커맨드의 플래그 목록을 붙이면 사용자가
|
|
757
|
+
* 묻지도 않은 명령의 사용법 200 자를 읽고 나서야 목록에 닿는다.
|
|
758
|
+
*/
|
|
759
|
+
const commandDiscovery = "사용 가능한 명령: test, generate, repair. 전체 도움말: mcpeak --help";
|
|
427
760
|
const TEST_USAGE_HINT = `${TEST_USAGE} ${commandDiscovery}`;
|
|
428
761
|
const GENERATE_USAGE_HINT = `${GENERATE_USAGE} ${commandDiscovery}`;
|
|
429
762
|
const REPAIR_USAGE_HINT = `${REPAIR_USAGE} ${commandDiscovery}`;
|
|
430
|
-
const REPLAY_USAGE_HINT = `${REPLAY_USAGE} ${commandDiscovery}`;
|
|
431
|
-
const VERIFY_USAGE_HINT = `${VERIFY_USAGE} ${commandDiscovery}`;
|
|
432
763
|
function commandHelp(command) {
|
|
433
|
-
if (command === "verify") return `verify — 카세트가 아직 실서버 응답과 맞는지 확인합니다.
|
|
434
|
-
|
|
435
|
-
${VERIFY_USAGE}
|
|
436
|
-
|
|
437
|
-
${VERIFY_OPTIONS}
|
|
438
|
-
`;
|
|
439
764
|
if (command === "test") return `test — JSON 테스트 명세로 MCP 서버를 실행하고 검증합니다.
|
|
440
765
|
|
|
441
766
|
${TEST_USAGE}
|
|
@@ -447,12 +772,6 @@ ${TEST_OPTIONS}
|
|
|
447
772
|
${REPAIR_USAGE}
|
|
448
773
|
|
|
449
774
|
${REPAIR_OPTIONS}
|
|
450
|
-
`;
|
|
451
|
-
if (command === "replay") return `replay — 녹화된 카세트로 서버 없이 테스트 명세를 재생합니다.
|
|
452
|
-
|
|
453
|
-
${REPLAY_USAGE}
|
|
454
|
-
|
|
455
|
-
${REPLAY_OPTIONS}
|
|
456
775
|
`;
|
|
457
776
|
return `generate — MCP 서버의 툴 스키마에서 테스트 명세를 생성합니다.
|
|
458
777
|
|
|
@@ -462,6 +781,76 @@ ${GENERATE_DRY_RUN_OPTIONS}
|
|
|
462
781
|
`;
|
|
463
782
|
}
|
|
464
783
|
//#endregion
|
|
784
|
+
//#region src/http-diagnostics.ts
|
|
785
|
+
/**
|
|
786
|
+
* core 의 HTTP 진단인지 구조와 값의 범위로 확인한다.
|
|
787
|
+
*
|
|
788
|
+
* stdio 진단은 `url` 이 없으므로 여기서 `undefined` 로 떨어진다. 반대로 HTTP 진단은
|
|
789
|
+
* `stderr` 가 없어 `processDiagnostics` 에서 떨어진다. 두 가드가 서로를 배제하는 것이
|
|
790
|
+
* 이 쌍의 요점이다 — 한쪽 블록이 다른 쪽 transport 의 값을 그리면 화면이 거짓말을 한다.
|
|
791
|
+
*/
|
|
792
|
+
function httpDiagnostics(value) {
|
|
793
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
794
|
+
if (!("url" in value) || typeof value.url !== "string" || value.url === "") return void 0;
|
|
795
|
+
if (!("status" in value) || !(value.status === null || typeof value.status === "number" && Number.isInteger(value.status) && value.status >= 0)) return void 0;
|
|
796
|
+
if (!("statusText" in value) || !(value.statusText === null || typeof value.statusText === "string")) return void 0;
|
|
797
|
+
if (!("sessionId" in value) || !(value.sessionId === null || typeof value.sessionId === "string")) return void 0;
|
|
798
|
+
return value;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* 터미널 제어 문자를 무해한 문자열로 바꾼다.
|
|
802
|
+
*
|
|
803
|
+
* `process-diagnostics.ts` 의 `escapeTokens` 와 같은 계열이되 **TAB 도 이스케이프한다.**
|
|
804
|
+
* 그쪽이 TAB 을 살려 두는 이유는 서버 stderr 의 스택 트레이스 들여쓰기였는데, 여기서
|
|
805
|
+
* 그리는 값은 URL 과 상태 문구와 세션 ID 다. 셋 다 TAB 이 들어갈 자리가 아니다.
|
|
806
|
+
*
|
|
807
|
+
* 사본을 두는 근거는 `process-diagnostics.ts` 와 같다(ADR-0013).
|
|
808
|
+
*/
|
|
809
|
+
function escapeTerminalText$1(value) {
|
|
810
|
+
return Array.from(value, (character) => {
|
|
811
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
812
|
+
return codePoint <= 31 || codePoint >= 127 && codePoint <= 159 || codePoint === 8232 || codePoint === 8233 ? `\\u${codePoint.toString(16).padStart(4, "0")}` : character;
|
|
813
|
+
}).join("");
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* 값 하나가 지나치게 길 때 자른다. URL 은 사용자가 준 값이고 세션 ID 는 서버가 준 값이라
|
|
817
|
+
* 둘 다 길이에 상한이 없다. `process-diagnostics.ts` 의 `MAX_LINE_CHARACTERS` 와 같은 값이다.
|
|
818
|
+
*/
|
|
819
|
+
const MAX_VALUE_CHARACTERS = 1e3;
|
|
820
|
+
function clamp(value) {
|
|
821
|
+
const escaped = escapeTerminalText$1(value);
|
|
822
|
+
if (escaped.length <= MAX_VALUE_CHARACTERS) return escaped;
|
|
823
|
+
return `${escaped.slice(0, MAX_VALUE_CHARACTERS)} …(${escaped.length - MAX_VALUE_CHARACTERS}자 생략)`;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* 블록에 담을 내용이 있는지 판정한다. `process-diagnostics.ts` 의 `hasDiagnosticContent` 와
|
|
827
|
+
* 같은 자리에 쓴다.
|
|
828
|
+
*
|
|
829
|
+
* **`status !== null` 이 곧 "실패를 관측했다" 이다.** core 의 `HttpConnectionState` 는 상태
|
|
830
|
+
* 코드를 실패 경로(`toConnectError` · `operationFailureKind`)에서만 채운다. 성공한 실행은
|
|
831
|
+
* 끝까지 null 이므로, 이 판정 없이 그리면 초록불 뒤에 매번 빈 진단 블록이 붙는다.
|
|
832
|
+
*
|
|
833
|
+
* 연결 자체가 실패한 경로는 이 판정을 거치지 않는다. 그때는 상태 코드가 없어도(DNS 실패,
|
|
834
|
+
* 연결 거부) **어느 엔드포인트에 붙으려다 실패했는지**가 정보이고, HTTP 진단에는 stderr 처럼
|
|
835
|
+
* 비어 있을 수 있는 본문이 없어 "정보량 0인 블록" 이 나올 수가 없다.
|
|
836
|
+
*/
|
|
837
|
+
function hasHttpDiagnosticContent(diagnostics) {
|
|
838
|
+
return diagnostics.status !== null;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* 진단 블록을 만든다. 빈 문자열이 아니면 항상 개행으로 끝난다.
|
|
842
|
+
*
|
|
843
|
+
* 상태 코드가 없을 때 `(없음)` 이라고 쓰지 않는다. stdio 의 `종료 코드: 없음` 은 "아직 안
|
|
844
|
+
* 죽었다" 라는 사실이지만, HTTP 에서 상태 코드가 없는 것은 **응답에 닿지 못했다**는 뜻이라
|
|
845
|
+
* 뜻이 정반대다. 같은 문구를 쓰면 읽는 사람이 서버가 200 을 줬다고 읽는다.
|
|
846
|
+
*/
|
|
847
|
+
function renderHttpDiagnostics(diagnostics) {
|
|
848
|
+
const status = diagnostics.status === null ? "응답에 닿지 못했습니다" : diagnostics.statusText === null ? String(diagnostics.status) : `${diagnostics.status} ${clamp(diagnostics.statusText)}`;
|
|
849
|
+
const sessionId = diagnostics.sessionId === null ? "발급하지 않음 (stateless)" : clamp(diagnostics.sessionId);
|
|
850
|
+
return `원격 서버 진단
|
|
851
|
+
엔드포인트: ${clamp(diagnostics.url)}\n HTTP 상태: ${status}\n 세션 ID: ${sessionId}\n`;
|
|
852
|
+
}
|
|
853
|
+
//#endregion
|
|
465
854
|
//#region src/input-repair.ts
|
|
466
855
|
/** 케이스 머리글 들여쓰기. 분류 화면(§8.3)과 같은 값이라 번호가 이어 읽힌다. */
|
|
467
856
|
const HEAD = " ";
|
|
@@ -473,8 +862,20 @@ const BODY = " ";
|
|
|
473
862
|
* `RepairTarget` 이 `detail` 을 나르지 않아 여기서 다시 적는다.
|
|
474
863
|
*/
|
|
475
864
|
const FAILURE_LINE = `${BODY}isError 정상 응답을 기대했지만 오류 응답을 받았습니다.`;
|
|
476
|
-
|
|
865
|
+
/**
|
|
866
|
+
* 제안값의 출처를 말한다. `propose` 는 provider 가 있을 때만 배선되므로 여기 오는 값은
|
|
867
|
+
* **항상 provider 가 만든 것**이다 — 그런데 전에는 서버에 귀속했다(#286).
|
|
868
|
+
* 표기를 못 받은 경우에도 서버라고 말하지 않는다.
|
|
869
|
+
*/
|
|
870
|
+
const proposedLead = (by) => `${BODY}입력값이 거절된 것으로 보입니다. ${by === void 0 ? "AI 가" : `${by} 가`} 서버 응답을 보고 제안한 값입니다.`;
|
|
477
871
|
const MANUAL_LEAD = `${BODY}입력값이 거절된 것으로 보입니다. 서버 응답에 쓸 만한 값이 없어 직접 받습니다.`;
|
|
872
|
+
/**
|
|
873
|
+
* 전송을 거절한 갈래. **`MANUAL_LEAD` 를 쓰면 안 된다** — 서버 응답은 있었고 보내지 않기로
|
|
874
|
+
* 한 것뿐인데 "쓸 만한 값이 없다" 는 거짓 사유가 된다(#286).
|
|
875
|
+
*/
|
|
876
|
+
/** `propose` 가 아예 없는 경우. 요청조차 하지 않았으니 근거 부재와 같다. */
|
|
877
|
+
const UNAVAILABLE_OUTCOME = { kind: "unavailable" };
|
|
878
|
+
const DECLINED_LEAD = `${BODY}입력값이 거절된 것으로 보입니다. AI 전송을 거절했으므로 값을 직접 받습니다.`;
|
|
478
879
|
const RERUN_LINE = `${BODY}▸ 다시 실행 중... 1건`;
|
|
479
880
|
const PASSED_LINE = `${BODY}✓ 통과`;
|
|
480
881
|
const EXHAUSTED_LINE = `${BODY}✗ 여전히 실패합니다. 입력값 문제가 아닐 수 있습니다.`;
|
|
@@ -590,8 +991,9 @@ async function repairInputs(options) {
|
|
|
590
991
|
const shared = options.targets.slice(index).filter((other) => other.tool === target.tool && field in other.input).length;
|
|
591
992
|
options.io.write(`${BODY}같은 값을 ${target.tool}.${field} 를 쓰는 케이스 ${shared}건에 함께 적용합니다.\n`);
|
|
592
993
|
}
|
|
593
|
-
const
|
|
594
|
-
|
|
994
|
+
const outcome = askable.length === 0 ? void 0 : await options.propose?.(target) ?? UNAVAILABLE_OUTCOME;
|
|
995
|
+
const proposed = outcome?.kind === "proposed" ? outcome.input : void 0;
|
|
996
|
+
if (askable.length > 0) options.io.write(`${outcome?.kind === "proposed" ? proposedLead(options.proposedBy) : outcome?.kind === "declined" ? DECLINED_LEAD : MANUAL_LEAD}\n`);
|
|
595
997
|
const first = await askRound(options.io, target, {
|
|
596
998
|
current: target.input,
|
|
597
999
|
proposed,
|
|
@@ -833,6 +1235,18 @@ function dropSkippedTools(suite, skips) {
|
|
|
833
1235
|
}
|
|
834
1236
|
//#endregion
|
|
835
1237
|
//#region src/process-diagnostics.ts
|
|
1238
|
+
/**
|
|
1239
|
+
* core 의 stdio 프로세스 진단인지 구조와 값의 범위로 확인한다.
|
|
1240
|
+
* 일부 필드만 있거나 프로세스 상태로 표현할 수 없는 값은 렌더하지 않는다.
|
|
1241
|
+
*/
|
|
1242
|
+
function processDiagnostics(value) {
|
|
1243
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
1244
|
+
if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
|
|
1245
|
+
if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
|
|
1246
|
+
if (!("exitCode" in value) || !(value.exitCode === null || typeof value.exitCode === "number" && Number.isInteger(value.exitCode))) return void 0;
|
|
1247
|
+
if (!("signal" in value) || !(value.signal === null || typeof value.signal === "string" && value.signal.length > 0)) return void 0;
|
|
1248
|
+
return value;
|
|
1249
|
+
}
|
|
836
1250
|
/** TAB. 이 모듈만 이스케이프 대상에서 빼는 문자다. 아래 escapeTokens 주석에 근거가 있다. */
|
|
837
1251
|
const TAB = 9;
|
|
838
1252
|
/**
|
|
@@ -1017,229 +1431,45 @@ function acceptProposal(options) {
|
|
|
1017
1431
|
if (same(beforeInput, afterInput)) return void 0;
|
|
1018
1432
|
return afterInput;
|
|
1019
1433
|
}
|
|
1020
|
-
|
|
1021
|
-
* 교정안을 provider 에게 요청한다. 실패는 전부 `undefined` 로 접는다. 교정은 선택이지
|
|
1022
|
-
* 전제가 아니라서, 여기서 던지면 교정 실패가 시험 실행을 통째로 죽인다.
|
|
1023
|
-
*/
|
|
1434
|
+
const UNAVAILABLE = { kind: "unavailable" };
|
|
1024
1435
|
async function proposeRepair(options) {
|
|
1025
|
-
if (options.target.serverMessage === "") return
|
|
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
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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");
|
|
1436
|
+
if (options.target.serverMessage === "") return UNAVAILABLE;
|
|
1437
|
+
const before = options.session.approvedDraft.suite;
|
|
1438
|
+
try {
|
|
1439
|
+
const preview = options.prepare({
|
|
1440
|
+
mode: "revise",
|
|
1441
|
+
instruction: buildInstruction(options.target),
|
|
1442
|
+
baseline: options.session.baseline.suite,
|
|
1443
|
+
candidate: before,
|
|
1444
|
+
tools: options.tools,
|
|
1445
|
+
providerId: options.provider.id,
|
|
1446
|
+
model: options.provider.model ?? "",
|
|
1447
|
+
redaction: options.redaction
|
|
1448
|
+
});
|
|
1449
|
+
if (!hasServerMessage(preview.request.instruction)) return UNAVAILABLE;
|
|
1450
|
+
if (!await options.confirm(preview)) return { kind: "declined" };
|
|
1451
|
+
const result = await options.dispatch({
|
|
1452
|
+
provider: options.provider,
|
|
1453
|
+
preview,
|
|
1454
|
+
approval: {
|
|
1455
|
+
approved: true,
|
|
1456
|
+
fingerprint: preview.fingerprint
|
|
1217
1457
|
}
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1458
|
+
});
|
|
1459
|
+
if (result.status !== "preview") return UNAVAILABLE;
|
|
1460
|
+
const input = acceptProposal({
|
|
1461
|
+
target: options.target,
|
|
1462
|
+
before,
|
|
1463
|
+
after: result.preview.result.suite
|
|
1464
|
+
});
|
|
1465
|
+
return input === void 0 ? UNAVAILABLE : {
|
|
1466
|
+
kind: "proposed",
|
|
1467
|
+
input
|
|
1468
|
+
};
|
|
1469
|
+
} catch {
|
|
1470
|
+
return UNAVAILABLE;
|
|
1224
1471
|
}
|
|
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
1472
|
}
|
|
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
1473
|
//#endregion
|
|
1244
1474
|
//#region src/repair-target.ts
|
|
1245
1475
|
/** `renderReport` 가 케이스 본문 줄에 쓰는 들여쓰기. dry-run.ts 의 INDENT 와 같은 값이다. */
|
|
@@ -1527,12 +1757,12 @@ const optionNames$1 = /* @__PURE__ */ new Set([
|
|
|
1527
1757
|
"--out",
|
|
1528
1758
|
"--command",
|
|
1529
1759
|
"--arg",
|
|
1760
|
+
"--url",
|
|
1761
|
+
"--header-env",
|
|
1530
1762
|
"--baseline-only",
|
|
1531
1763
|
"--provider",
|
|
1532
1764
|
"--model",
|
|
1533
1765
|
"--no-dry-run",
|
|
1534
|
-
"--cassette",
|
|
1535
|
-
"--record",
|
|
1536
1766
|
"--reset-cmd",
|
|
1537
1767
|
"--no-repair",
|
|
1538
1768
|
"--force"
|
|
@@ -1541,10 +1771,11 @@ const optionNames$1 = /* @__PURE__ */ new Set([
|
|
|
1541
1771
|
const flagNames$1 = /* @__PURE__ */ new Set([
|
|
1542
1772
|
"--baseline-only",
|
|
1543
1773
|
"--no-dry-run",
|
|
1544
|
-
"--record",
|
|
1545
1774
|
"--no-repair",
|
|
1546
1775
|
"--force"
|
|
1547
1776
|
]);
|
|
1777
|
+
const removedGenerateOptions = /* @__PURE__ */ new Set(["--cassette", "--record"]);
|
|
1778
|
+
const removedGenerateOptionMessage = (option) => `\`${option}\` generate 옵션은 Tool 카세트와 함께 제거되었습니다(ADR-0059). 시험 실행은 서버를 직접 호출합니다. 서버의 외부 HTTP 호출을 녹화하려면 \`mcpeak test <suite.json> --command <executable> --record-session <path>\`를, 재생하려면 \`mcpeak test <suite.json> --command <executable> --session <path>\`를 사용하세요. 서버를 대신하려면 \`mcpeak test <suite.json> --command mcpeak-mock --arg <mock.json>\`을 사용하세요.`;
|
|
1548
1779
|
function optionValue$1(argv, index, option) {
|
|
1549
1780
|
const item = argv[index];
|
|
1550
1781
|
if (item === void 0) throw new UsageError$1(`\`${option}\` 옵션 값이 필요합니다.`);
|
|
@@ -1554,15 +1785,45 @@ function optionValue$1(argv, index, option) {
|
|
|
1554
1785
|
if (value === void 0 || option !== "--arg" && value.startsWith("--")) throw new UsageError$1(`\`${option}\` 옵션 값이 필요합니다.`);
|
|
1555
1786
|
return [value, index + 1];
|
|
1556
1787
|
}
|
|
1788
|
+
/**
|
|
1789
|
+
* `--out` 에서 스위트 id·name 을 유도한다. `contract.suite.json` → `contract`(#242).
|
|
1790
|
+
* 명시한 값이 있으면 그쪽이 이긴다. 뽑을 이름이 없으면 `undefined` 를 주고 호출 측이 요구한다.
|
|
1791
|
+
*
|
|
1792
|
+
* **파일명이 승인 지문에 들어간다.** `suiteFingerprint` 가 `approval` 만 빼고 전부 해시하므로
|
|
1793
|
+
* (`packages/runner/src/fingerprint.ts`) 출력 파일명을 바꿔 다시 생성하면 지문이 달라져
|
|
1794
|
+
* 재승인이 뜬다. 그 사실을 `--out` 도움말이 말한다. ADR-0073.
|
|
1795
|
+
*/
|
|
1796
|
+
const deriveSuiteName = (out) => {
|
|
1797
|
+
const strip = (value, suffix) => value.toLowerCase().endsWith(suffix) ? value.slice(0, -suffix.length) : value;
|
|
1798
|
+
const derived = strip(strip(basename(out), ".json"), ".suite");
|
|
1799
|
+
return derived === "" ? void 0 : derived;
|
|
1800
|
+
};
|
|
1557
1801
|
function parseGenerateCommand(argv) {
|
|
1558
1802
|
const values = /* @__PURE__ */ new Map();
|
|
1559
1803
|
const args = [];
|
|
1560
1804
|
const flags = /* @__PURE__ */ new Set();
|
|
1805
|
+
const headerEnv = createHeaderEnvCollector();
|
|
1561
1806
|
for (let index = 0; index < argv.length; index++) {
|
|
1562
1807
|
const item = argv[index];
|
|
1563
1808
|
if (item === void 0) continue;
|
|
1809
|
+
/**
|
|
1810
|
+
* `--` 뒤는 전부 서버를 띄울 명령이다. npm · cargo · docker 가 쓰는 관례다(#242).
|
|
1811
|
+
* 첫 토큰이 실행 파일, 나머지가 그 인자다. **뒤에 오는 것을 해석하지 않으므로**
|
|
1812
|
+
* `--arg` 가 값 검사에 두던 특수분기가 이 경로에는 필요 없다.
|
|
1813
|
+
*/
|
|
1814
|
+
if (item === "--") {
|
|
1815
|
+
if (values.has("--command")) throw new UsageError$1("`--command` 와 `--` 를 함께 쓸 수 없습니다.\n→ 둘 다 서버를 띄울 명령을 정하므로 대상이 둘이 됩니다.\n→ `--` 뒤에는 실행 파일과 인자를 그대로 적습니다.");
|
|
1816
|
+
if (args.length > 0) throw new UsageError$1("`--arg` 와 `--` 를 함께 쓸 수 없습니다.\n→ `--arg` 로 준 값이 `--` 뒤의 인자 앞에 끼어듭니다.\n→ `--` 를 쓰면 인자도 그 뒤에 모두 적습니다.");
|
|
1817
|
+
const rest = argv.slice(index + 1);
|
|
1818
|
+
const executable = rest[0];
|
|
1819
|
+
if (executable === void 0 || executable === "") throw new UsageError$1("`--` 뒤에 실행할 명령이 없습니다.\n→ `-- <executable> [args...]` 처럼 첫 토큰에 실행 파일을 적으세요.");
|
|
1820
|
+
values.set("--command", executable);
|
|
1821
|
+
args.push(...rest.slice(1));
|
|
1822
|
+
break;
|
|
1823
|
+
}
|
|
1564
1824
|
const option = item.includes("=") ? item.slice(0, item.indexOf("=")) : item;
|
|
1565
1825
|
if (!option.startsWith("--")) throw new UsageError$1(`추가 위치 인자 '${item}'는 허용되지 않습니다.`);
|
|
1826
|
+
if (removedGenerateOptions.has(option)) throw new UsageError$1(removedGenerateOptionMessage(option));
|
|
1566
1827
|
if (!optionNames$1.has(option)) throw new UsageError$1(`지원하지 않는 generate 옵션 '${option}'입니다.`);
|
|
1567
1828
|
if (flagNames$1.has(option)) {
|
|
1568
1829
|
if (item !== option || flags.has(option)) throw new UsageError$1(`\`${option}\`는 한 번만 사용할 수 있습니다.`);
|
|
@@ -1575,15 +1836,36 @@ function parseGenerateCommand(argv) {
|
|
|
1575
1836
|
args.push(value);
|
|
1576
1837
|
continue;
|
|
1577
1838
|
}
|
|
1839
|
+
if (option === "--header-env") {
|
|
1840
|
+
const parsed = parseHeaderEnvOption(value);
|
|
1841
|
+
if (!parsed.ok) throw new UsageError$1(parsed.message);
|
|
1842
|
+
const rejected = headerEnv.add(parsed.value.header, parsed.value.envName);
|
|
1843
|
+
if (rejected !== void 0) throw new UsageError$1(rejected);
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1578
1846
|
if (values.has(option)) throw new UsageError$1(`\`${option}\`는 한 번만 사용할 수 있습니다.`);
|
|
1579
1847
|
values.set(option, value);
|
|
1580
1848
|
}
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1849
|
+
const out = values.get("--out");
|
|
1850
|
+
if (out === void 0) throw new UsageError$1("`--out` 옵션이 필요합니다.");
|
|
1851
|
+
const derived = deriveSuiteName(out);
|
|
1852
|
+
for (const option of ["--suite-id", "--name"]) {
|
|
1853
|
+
if (values.get(option) !== void 0) continue;
|
|
1854
|
+
if (derived === void 0) throw new UsageError$1(`\`${option}\` 옵션이 필요합니다.\n→ \`--out\` 파일명에서 이름을 뽑을 수 없어 직접 지정해야 합니다.`);
|
|
1855
|
+
values.set(option, derived);
|
|
1856
|
+
}
|
|
1857
|
+
const command = values.get("--command");
|
|
1858
|
+
const rawUrl = values.get("--url");
|
|
1859
|
+
if (command !== void 0 && rawUrl !== void 0) throw new UsageError$1("`--command` 와 `--url` 은 함께 쓸 수 없습니다.\n→ `--command` 는 서버를 프로세스로 띄우고, `--url` 은 이미 떠 있는 원격 서버에 붙습니다.\n→ 둘 중 무엇에서 명세를 뽑을지 하나만 고르세요.");
|
|
1860
|
+
if (command === void 0 && rawUrl === void 0) throw new UsageError$1("`--command` 또는 `--url` 옵션이 필요합니다.");
|
|
1861
|
+
if (rawUrl !== void 0 && args.length > 0) throw new UsageError$1("`--arg` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--arg` 는 우리가 띄우는 프로세스에 넘길 인자입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
|
|
1862
|
+
if (command !== void 0 && !headerEnv.isEmpty()) throw new UsageError$1("`--header-env` 는 `--url` 과 함께만 쓸 수 있습니다.\n→ 헤더는 HTTP 요청에 실립니다. `--command` 로 띄운 서버와는 stdio 로 이야기합니다.");
|
|
1863
|
+
const url = (() => {
|
|
1864
|
+
if (rawUrl === void 0) return void 0;
|
|
1865
|
+
const parsed = parseUrlOption(rawUrl);
|
|
1866
|
+
if (!parsed.ok) throw new UsageError$1(parsed.message);
|
|
1867
|
+
return parsed.value;
|
|
1868
|
+
})();
|
|
1587
1869
|
const outPath = values.get("--out");
|
|
1588
1870
|
if (!outPath.toLowerCase().endsWith(".json")) throw new UsageError$1("`--out`은 .json 파일이어야 합니다.");
|
|
1589
1871
|
const rawProvider = values.get("--provider");
|
|
@@ -1592,24 +1874,26 @@ function parseGenerateCommand(argv) {
|
|
|
1592
1874
|
const dryRun = !flags.has("--no-dry-run");
|
|
1593
1875
|
const repair = !flags.has("--no-repair");
|
|
1594
1876
|
if (!dryRun && !repair) throw new UsageError$1("`--no-dry-run`과 `--no-repair`는 함께 사용할 수 없습니다.");
|
|
1595
|
-
const cassettePath = values.get("--cassette");
|
|
1596
1877
|
const resetCmd = values.get("--reset-cmd");
|
|
1597
|
-
if (!dryRun && cassettePath !== void 0) throw new UsageError$1("`--no-dry-run`과 `--cassette`는 함께 사용할 수 없습니다.");
|
|
1598
1878
|
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
1879
|
if (resetCmd !== void 0 && resetCmd.trim() === "") throw new UsageError$1("`--reset-cmd` 옵션 값이 필요합니다.");
|
|
1601
1880
|
return Object.freeze({
|
|
1602
1881
|
suiteId: values.get("--suite-id"),
|
|
1603
1882
|
name: values.get("--name"),
|
|
1604
1883
|
outPath,
|
|
1605
|
-
|
|
1606
|
-
|
|
1884
|
+
target: url === void 0 ? Object.freeze({
|
|
1885
|
+
transport: "stdio",
|
|
1886
|
+
command,
|
|
1887
|
+
args: Object.freeze(args)
|
|
1888
|
+
}) : Object.freeze({
|
|
1889
|
+
transport: "http",
|
|
1890
|
+
url,
|
|
1891
|
+
headerEnv: headerEnv.snapshot()
|
|
1892
|
+
}),
|
|
1607
1893
|
baselineOnly: flags.has("--baseline-only"),
|
|
1608
1894
|
provider: rawProvider,
|
|
1609
1895
|
model: values.get("--model"),
|
|
1610
1896
|
dryRun,
|
|
1611
|
-
cassettePath,
|
|
1612
|
-
forceRecord: flags.has("--record"),
|
|
1613
1897
|
force: flags.has("--force"),
|
|
1614
1898
|
resetCmd,
|
|
1615
1899
|
repair
|
|
@@ -1679,8 +1963,12 @@ async function saveSuite(input, suite, fingerprint, deps, approvals = []) {
|
|
|
1679
1963
|
}
|
|
1680
1964
|
}
|
|
1681
1965
|
const defaultModel = (provider) => provider === "codex" ? "gpt-5.6-luna" : "haiku";
|
|
1682
|
-
|
|
1683
|
-
|
|
1966
|
+
/**
|
|
1967
|
+
* 전송 승인 화면. **통로마다 보내는 것이 다르므로 목록을 받는다**(#286) — 입력값 교정은
|
|
1968
|
+
* 실패한 케이스의 서버 응답 원문까지 싣는데, 기본 문구로 덮으면 화면이 사실보다 적게 말한다.
|
|
1969
|
+
*/
|
|
1970
|
+
function showRequest(io, preview, data = "사용자 요청, baseline suite, current candidate, 툴 이름·설명·inputSchema") {
|
|
1971
|
+
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전송 데이터: ${data}\n`);
|
|
1684
1972
|
}
|
|
1685
1973
|
const MAX_DIFF_BODY_LINES = 40;
|
|
1686
1974
|
/**
|
|
@@ -1727,6 +2015,35 @@ function diffBody(change) {
|
|
|
1727
2015
|
default: return changedLeaves(change.before, change.after);
|
|
1728
2016
|
}
|
|
1729
2017
|
}
|
|
2018
|
+
/** `show` 의 입력 JSON 한 줄 상한. 넘으면 자른다. 전체는 저장 후 파일에서 본다. */
|
|
2019
|
+
const MAX_SHOW_INPUT_CHARS = 80;
|
|
2020
|
+
/**
|
|
2021
|
+
* 검토 메뉴 `show`. 지금 승인된 명세를 케이스당 한 줄로 요약한다. 검토 메뉴는 diff(바뀐 값)만
|
|
2022
|
+
* 보여 주므로 AI 가 만든 케이스가 무엇인지 저장 전에는 볼 곳이 없었다. 전문 JSON 은 케이스
|
|
2023
|
+
* 8건이면 100줄이 넘어 로그를 덮는다. 한 줄 요약이 화면에 맞는다.
|
|
2024
|
+
*/
|
|
2025
|
+
function renderSuiteSummary(options) {
|
|
2026
|
+
const { suite } = options;
|
|
2027
|
+
const width = String(suite.cases.length).length;
|
|
2028
|
+
const lines = suite.cases.map((testCase, index) => {
|
|
2029
|
+
const number = String(index + 1).padStart(width, " ");
|
|
2030
|
+
const operation = testCase.operation.type === "listTools" ? "listTools" : `callTool ${testCase.operation.tool} ${truncate(JSON.stringify(testCase.operation.input), MAX_SHOW_INPUT_CHARS)}`;
|
|
2031
|
+
const assertions = testCase.assertions.map((assertion) => assertion.type === "toolExists" ? `toolExists ${assertion.tool}` : assertion.type === "isError" ? `isError=${assertion.expected}` : assertion.type).join(", ");
|
|
2032
|
+
return ` ${number}. ${testCase.id} ${operation} → ${assertions}`;
|
|
2033
|
+
});
|
|
2034
|
+
const header = `현재 명세: ${suite.name} (id ${suite.id}) · 케이스 ${suite.cases.length}건 · revision ${options.revision}`;
|
|
2035
|
+
const footer = `저장하면 이 내용이 ${options.outPath} 에 쓰입니다.`;
|
|
2036
|
+
const pending = options.pendingCandidate ? " → 미반영 AI 후보가 있습니다. 위 목록에는 안 들어 있습니다. apply-all 또는 select 로 반영하세요." : "";
|
|
2037
|
+
return `${[
|
|
2038
|
+
header,
|
|
2039
|
+
...lines,
|
|
2040
|
+
footer,
|
|
2041
|
+
...pending === "" ? [] : [pending]
|
|
2042
|
+
].map(escapeTerminalText$2).join("\n")}\n`;
|
|
2043
|
+
}
|
|
2044
|
+
function truncate(text, max) {
|
|
2045
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
2046
|
+
}
|
|
1730
2047
|
function showDiff(io, preview) {
|
|
1731
2048
|
if (preview.changes.length === 0) {
|
|
1732
2049
|
io.write("AI가 제안한 변경이 없습니다.\n → 원하는 케이스를 `AI 요청:`에 구체적으로 적어 다시 물어보세요.\n → 지금 상태로 저장하려면 save를 고르세요.\n");
|
|
@@ -1822,6 +2139,7 @@ function exitMessage(failure, model) {
|
|
|
1822
2139
|
case "rateLimited": return `오류 [GENERATE_PROVIDER_RATE_LIMIT]: ${id}가 요청 한도를 초과했습니다.\n해결: 잠시 뒤 다시 요청하세요. 반복되면 도구 수를 줄여 payload를 줄이세요.\n`;
|
|
1823
2140
|
case "badRequest": return `오류 [GENERATE_PROVIDER_REQUEST]: ${id}가 요청을 거절했습니다. 모델: ${model}\n해결: 두 가지를 확인하세요.\n 1. 모델 이름이 이 계정에서 쓸 수 있는지. ${id} 기본값은 ${fallback}입니다.\n 2. provider가 전송 schema를 받아들이는지. 반복되면 다른 provider로 시도하세요.\n`;
|
|
1824
2141
|
case "serverError": return `오류 [GENERATE_PROVIDER_SERVER]: ${id} 쪽 서버 오류입니다.\n해결: 잠시 뒤 다시 요청하세요. 계속되면 provider 상태 페이지를 확인하세요.\n`;
|
|
2142
|
+
case "unknownOption": return `오류 [GENERATE_PROVIDER_OPTION]: 설치된 ${id}가 우리가 넘긴 옵션을 모릅니다.${failure.option === void 0 ? "" : ` 옵션: ${failure.option}`}\n → 로그인도 모델도 원인이 아닙니다. CLI가 뜨기도 전에 옵션 해석에서 멈췄습니다.\n해결: \`${id} --version\` 으로 버전을 확인하고 최신 버전으로 올리세요.\n`;
|
|
1825
2143
|
default: return `오류 [GENERATE_PROVIDER_EXIT]: ${id}가 종료했습니다. ${failure.exitCode === void 0 ? "" : `종료 코드: ${failure.exitCode}, `}모델: ${model}\n해결: \`${authCommand(id)}\` 명령으로 로그인 상태를 확인하고, 모델 이름이 맞는지 확인하세요.\n`;
|
|
1826
2144
|
}
|
|
1827
2145
|
}
|
|
@@ -1864,13 +2182,16 @@ const failureHeading = (index, caseName) => ` [${index + 1}] ${caseName}\n`;
|
|
|
1864
2182
|
* 문장은 건드리지 않는다. dry-run-review.ts 의 같은 처리와 규칙이 같다.
|
|
1865
2183
|
*/
|
|
1866
2184
|
const detailBlock = (detail) => detail === "" ? "" : `${detail.split("\n").map((line) => ` ${line}`).join("\n")}\n`;
|
|
1867
|
-
/** 시험 실행 고지(§8.1).
|
|
2185
|
+
/** 시험 실행 고지(§8.1). 초기화는 값이 있을 때만 줄이 나간다. */
|
|
1868
2186
|
function writeDryRunNotice(io, notice) {
|
|
1869
2187
|
io.write(`시험 실행: 케이스 ${notice.caseCount}개를 실제 서버에 보냅니다.\n`);
|
|
1870
2188
|
io.write(` 대상: ${notice.target}\n`);
|
|
1871
|
-
if (notice.cassette !== void 0) io.write(` 카세트: ${notice.cassette.path} (${notice.cassette.fresh ? "신규 녹화" : "재생"})\n`);
|
|
1872
2189
|
if (notice.resetCmd !== void 0) io.write(` 초기화: ${notice.resetCmd}\n`);
|
|
1873
|
-
if (notice.repair)
|
|
2190
|
+
if (notice.repair) {
|
|
2191
|
+
io.write(" 실패한 케이스는 값을 고쳐 최대 2회까지 다시 호출합니다.\n");
|
|
2192
|
+
if (notice.repairProvider !== void 0) io.write(` 값 제안에 ${notice.repairProvider} 를 씁니다 — 실패한 케이스의 명세와 서버 응답을 보냅니다.\n 전송 전에 매번 승인 화면을 보여주며, 거절하면 직접 입력합니다.
|
|
2193
|
+
`);
|
|
2194
|
+
}
|
|
1874
2195
|
io.write("\n이 실행은 서버 상태를 바꿀 수 있습니다. 입력 검증이 없는 서버라면 외부 API 호출도\n그대로 나갑니다.\n");
|
|
1875
2196
|
}
|
|
1876
2197
|
/** 시험 실행 결과(§8.2). 0건인 종류는 찍지 않는다. */
|
|
@@ -1901,13 +2222,13 @@ function writeDryRunResult(io, result) {
|
|
|
1901
2222
|
function writeRejectionUnverified(io, result) {
|
|
1902
2223
|
const unverified = result.outcomes.filter((outcome) => outcome.rejectionBasis === "unverified");
|
|
1903
2224
|
if (unverified.length === 0) return;
|
|
1904
|
-
const ids = unverified.map((outcome) => escapeTerminalText$
|
|
2225
|
+
const ids = unverified.map((outcome) => escapeTerminalText$2(outcome.caseId));
|
|
1905
2226
|
const column = Math.max(...ids.map((id) => Array.from(id).length));
|
|
1906
2227
|
io.write(`\n거절 근거 미확인 ${unverified.length}건\n`);
|
|
1907
2228
|
for (const [index, outcome] of unverified.entries()) {
|
|
1908
2229
|
const id = ids[index] ?? "";
|
|
1909
2230
|
const pad = " ".repeat(Math.max(0, column - Array.from(id).length));
|
|
1910
|
-
const body = outcome.rejectionBody === void 0 ? "(본문 없음)" : escapeTerminalText$
|
|
2231
|
+
const body = outcome.rejectionBody === void 0 ? "(본문 없음)" : escapeTerminalText$2(outcome.rejectionBody);
|
|
1911
2232
|
io.write(` → ${id}${pad} 응답: ${body}\n`);
|
|
1912
2233
|
}
|
|
1913
2234
|
io.write(" 이 응답이 서버의 정상 거절인지 내부 오류인지 확인하지 못했습니다.\n\n");
|
|
@@ -1928,13 +2249,13 @@ const VERDICT_LABEL = {
|
|
|
1928
2249
|
*/
|
|
1929
2250
|
function writeRejectionDiagnosis(io, requested, results) {
|
|
1930
2251
|
io.write(`\n거절 근거 미확인 ${requested}건에 대해 AI 진단을 요청했습니다.\n\n`);
|
|
1931
|
-
const ids = results.map((item) => escapeTerminalText$
|
|
2252
|
+
const ids = results.map((item) => escapeTerminalText$2(item.caseId));
|
|
1932
2253
|
const column = Math.max(0, ...ids.map((id) => Array.from(id).length));
|
|
1933
2254
|
for (const [index, item] of results.entries()) {
|
|
1934
2255
|
const id = ids[index] ?? "";
|
|
1935
2256
|
const pad = " ".repeat(Math.max(0, column - Array.from(id).length));
|
|
1936
2257
|
io.write(` ${id}${pad} ${VERDICT_LABEL[item.verdict]}\n`);
|
|
1937
|
-
io.write(` → ${escapeTerminalText$
|
|
2258
|
+
io.write(` → ${escapeTerminalText$2(item.reason)}\n`);
|
|
1938
2259
|
}
|
|
1939
2260
|
io.write("\n이 진단은 참고입니다. 케이스 판정과 저장 여부를 바꾸지 않습니다.\n\n");
|
|
1940
2261
|
}
|
|
@@ -2066,31 +2387,28 @@ function writeDryRunAborted(io, result, totalCases, diagnostics) {
|
|
|
2066
2387
|
const progress = `${result.outcomes.length}/${totalCases}`;
|
|
2067
2388
|
io.write(aborted.reason === "connectionLost" ? `✗ 시험 실행을 마치지 못했습니다. ${progress} 케이스에서 연결이 끊겼습니다.\n` : aborted.reason === "stopped" ? `✗ 시험 실행을 마치지 못했습니다. ${progress} 케이스에서 멈췄습니다.\n` : "✗ 시험 실행을 마치지 못했습니다.\n");
|
|
2068
2389
|
io.write(` → ${aborted.detail}\n`);
|
|
2069
|
-
|
|
2070
|
-
|
|
2390
|
+
const local = processDiagnostics(diagnostics);
|
|
2391
|
+
if (local !== void 0 && hasDiagnosticContent(local)) {
|
|
2392
|
+
const block = renderProcessDiagnostics(local, { maxLines: DRY_RUN_STDERR_LINES });
|
|
2071
2393
|
if (block !== "") io.write(`\n${block}`);
|
|
2072
2394
|
}
|
|
2395
|
+
/**
|
|
2396
|
+
* 원격 대상. 내용 판정을 걸지 않는다 — 이 함수는 위에서 `aborted === undefined` 면 이미
|
|
2397
|
+
* 돌아갔으므로 **중단된 실행에서만** 여기 닿는다. 그 자리에서는 상태 코드가 없어도 어느
|
|
2398
|
+
* 엔드포인트에서 끊겼는지가 정보다. `test` 의 연결 실패 경로와 같은 규칙이다.
|
|
2399
|
+
*/
|
|
2400
|
+
const remote = httpDiagnostics(diagnostics);
|
|
2401
|
+
if (remote !== void 0) io.write(`\n${renderHttpDiagnostics(remote)}`);
|
|
2073
2402
|
io.write("\n저장하지 않았습니다. 서버를 고친 뒤 다시 save 를 고르세요.\n");
|
|
2074
2403
|
}
|
|
2075
|
-
/**
|
|
2076
|
-
|
|
2077
|
-
*
|
|
2078
|
-
* 재생은 `auto` 에서만 일어난다. 신규 녹화(`record`)는 회차마다 전량을 다시 서버로 보내므로
|
|
2079
|
-
* "나머지는 카세트에서 재생됩니다" 가 거짓이 된다. 그래서 경로 유무가 아니라 `wireCassette`
|
|
2080
|
-
* 가 정한 모드를 받는다.
|
|
2081
|
-
*/
|
|
2082
|
-
function writeReviewBlocked(io, specErrors, caseCount, cassetteMode) {
|
|
2404
|
+
/** 분류가 저장을 막았을 때의 안내(§8.3). */
|
|
2405
|
+
function writeReviewBlocked(io, specErrors, caseCount) {
|
|
2083
2406
|
io.write("\n");
|
|
2084
2407
|
if (specErrors > 0) {
|
|
2085
2408
|
io.write(` 명세 오류 ${specErrors}건이 있어 저장할 수 없습니다.\n`);
|
|
2086
2409
|
io.write(" → 검토 메뉴의 revise 또는 edit 으로 고친 뒤 다시 save 를 고르세요.\n");
|
|
2087
2410
|
} else io.write(" 분류하지 않은 케이스가 있어 저장할 수 없습니다.\n");
|
|
2088
|
-
if (cassetteMode === "auto") {
|
|
2089
|
-
io.write(" → 고친 케이스만 서버에 다시 나갑니다. 나머지는 카세트에서 재생됩니다.\n");
|
|
2090
|
-
return;
|
|
2091
|
-
}
|
|
2092
2411
|
io.write(` → 다시 save 를 고르면 케이스 ${caseCount}개가 모두 서버에 다시 나갑니다.\n`);
|
|
2093
|
-
io.write(cassetteMode === void 0 ? " 반복 비용이 부담되면 --cassette 를 쓰세요.\n" : " 신규 녹화 중이라 이번 세션에서는 재생하지 않습니다.\n");
|
|
2094
2412
|
}
|
|
2095
2413
|
/** 초기화 명령 실패 안내. stderr 은 마지막 3줄만 보여준다. 설계 문서 §6. */
|
|
2096
2414
|
function writeResetFailure(io, error) {
|
|
@@ -2114,14 +2432,11 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2114
2432
|
let preferred = input.provider;
|
|
2115
2433
|
let model = input.model;
|
|
2116
2434
|
/**
|
|
2117
|
-
*
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2435
|
+
* 진단 읽기가 판정을 바꾸면 안 된다. getDiagnostics 가 던지면 삼킨다.
|
|
2436
|
+
*
|
|
2437
|
+
* 좁히지 않고 원값을 넘긴다. 여기서 `processDiagnostics` 로 좁히면 원격 대상의 진단이
|
|
2438
|
+
* 버려져, 시험 실행이 중단됐을 때 엔드포인트도 상태 코드도 화면에 닿지 못한다(#137).
|
|
2120
2439
|
*/
|
|
2121
|
-
let cassette;
|
|
2122
|
-
/** 이미 화면에 낸 카세트 경고 수. 배선이 세션당 하나라 경고도 누적된다. 회차마다 새것만 낸다. */
|
|
2123
|
-
let printedWarnings = 0;
|
|
2124
|
-
/** 진단 읽기가 판정을 바꾸면 안 된다. getDiagnostics 가 던지면 삼킨다. */
|
|
2125
2440
|
const diagnostics = () => {
|
|
2126
2441
|
try {
|
|
2127
2442
|
return connection.getDiagnostics();
|
|
@@ -2132,6 +2447,7 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2132
2447
|
try {
|
|
2133
2448
|
while (true) {
|
|
2134
2449
|
const action = await io.choose("검토 메뉴", [
|
|
2450
|
+
"show",
|
|
2135
2451
|
"codex",
|
|
2136
2452
|
"claude",
|
|
2137
2453
|
"apply-all",
|
|
@@ -2142,6 +2458,18 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2142
2458
|
"cancel"
|
|
2143
2459
|
]);
|
|
2144
2460
|
if (action === "cancel") return 0;
|
|
2461
|
+
if (action === "show") {
|
|
2462
|
+
io.write(renderSuiteSummary({
|
|
2463
|
+
suite: session.approvedDraft.suite,
|
|
2464
|
+
revision: session.approvedDraft.revision,
|
|
2465
|
+
outPath: input.outPath,
|
|
2466
|
+
pendingCandidate: candidate !== void 0 && makeDiff({
|
|
2467
|
+
session,
|
|
2468
|
+
candidate
|
|
2469
|
+
}).changes.length > 0
|
|
2470
|
+
}));
|
|
2471
|
+
continue;
|
|
2472
|
+
}
|
|
2145
2473
|
if (action === "save") {
|
|
2146
2474
|
const dryRunSuite = session.approvedDraft.suite;
|
|
2147
2475
|
const caseCount = dryRunSuite.cases.length;
|
|
@@ -2154,16 +2482,13 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2154
2482
|
`);
|
|
2155
2483
|
if (!await io.confirm(" 계속할까요?")) continue;
|
|
2156
2484
|
} else {
|
|
2157
|
-
const
|
|
2485
|
+
const repairProvider = preferred === void 0 ? void 0 : deps.providers?.[preferred]?.(model ?? defaultModel(preferred));
|
|
2158
2486
|
writeDryRunNotice(io, {
|
|
2159
2487
|
caseCount,
|
|
2160
|
-
target:
|
|
2161
|
-
...path === void 0 ? {} : { cassette: {
|
|
2162
|
-
path,
|
|
2163
|
-
fresh: cassette === void 0 ? input.forceRecord || !await deps.exists(path) : cassette.mode === "record"
|
|
2164
|
-
} },
|
|
2488
|
+
target: describeTarget(input.target),
|
|
2165
2489
|
resetCmd: input.resetCmd,
|
|
2166
|
-
repair: input.repair
|
|
2490
|
+
repair: input.repair,
|
|
2491
|
+
repairProvider: repairProvider === void 0 ? void 0 : `${repairProvider.id}(${repairProvider.model ?? ""})`
|
|
2167
2492
|
});
|
|
2168
2493
|
if (!await io.confirm("계속할까요?")) continue;
|
|
2169
2494
|
if (input.resetCmd !== void 0) {
|
|
@@ -2176,19 +2501,11 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2176
2501
|
}
|
|
2177
2502
|
io.write(`▸ 초기화: ${input.resetCmd}\n`);
|
|
2178
2503
|
}
|
|
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
2504
|
io.write(`▸ 시험 실행 중... ${caseCount}/${caseCount}\n`);
|
|
2186
2505
|
const result = await runDryRun({
|
|
2187
|
-
client:
|
|
2506
|
+
client: connection.client,
|
|
2188
2507
|
suite: dryRunSuite
|
|
2189
2508
|
});
|
|
2190
|
-
for (const warning of cassette.warnings.slice(printedWarnings)) io.write(`${warning}\n`);
|
|
2191
|
-
printedWarnings = cassette.warnings.length;
|
|
2192
2509
|
if (result.aborted !== void 0) {
|
|
2193
2510
|
writeDryRunAborted(io, result, caseCount, diagnostics());
|
|
2194
2511
|
continue;
|
|
@@ -2204,8 +2521,6 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2204
2521
|
provider: preferred === void 0 ? void 0 : deps.rejectionProviders?.[preferred]?.(model ?? defaultModel(preferred)),
|
|
2205
2522
|
model: model ?? (preferred === void 0 ? "" : defaultModel(preferred))
|
|
2206
2523
|
});
|
|
2207
|
-
const repairProvider = preferred === void 0 ? void 0 : deps.providers?.[preferred]?.(model ?? defaultModel(preferred));
|
|
2208
|
-
const wired = cassette;
|
|
2209
2524
|
const targets = input.repair ? selectRepairTargets({
|
|
2210
2525
|
suite: dryRunSuite,
|
|
2211
2526
|
outcomes: result.outcomes,
|
|
@@ -2219,6 +2534,7 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2219
2534
|
suite: dryRunSuite,
|
|
2220
2535
|
targets,
|
|
2221
2536
|
tools,
|
|
2537
|
+
proposedBy: repairProvider === void 0 ? void 0 : `${repairProvider.id}(${repairProvider.model ?? ""})`,
|
|
2222
2538
|
rerun: async (caseId, value) => {
|
|
2223
2539
|
const spec = dryRunSuite.cases.find((item) => item.id === caseId);
|
|
2224
2540
|
if (spec === void 0 || !isCallTool(spec)) return {
|
|
@@ -2226,7 +2542,7 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2226
2542
|
detail: ""
|
|
2227
2543
|
};
|
|
2228
2544
|
const one = await runDryRun({
|
|
2229
|
-
client:
|
|
2545
|
+
client: connection.client,
|
|
2230
2546
|
suite: {
|
|
2231
2547
|
...dryRunSuite,
|
|
2232
2548
|
cases: [{
|
|
@@ -2254,7 +2570,11 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2254
2570
|
tools,
|
|
2255
2571
|
provider: repairProvider,
|
|
2256
2572
|
prepare,
|
|
2257
|
-
dispatch
|
|
2573
|
+
dispatch,
|
|
2574
|
+
confirm: async (preview) => {
|
|
2575
|
+
showRequest(io, preview, "실패한 케이스의 명세와 서버 응답 원문, baseline suite, current candidate, 툴 이름·설명·inputSchema");
|
|
2576
|
+
return await io.confirm("이 요청을 전송할까요?");
|
|
2577
|
+
}
|
|
2258
2578
|
})
|
|
2259
2579
|
});
|
|
2260
2580
|
const repaired = new Map(repairs.filter((item) => item.repaired && item.input !== void 0).map((item) => [item.caseId, item.input]));
|
|
@@ -2297,11 +2617,9 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2297
2617
|
} : outcome)
|
|
2298
2618
|
};
|
|
2299
2619
|
}
|
|
2300
|
-
for (const warning of cassette.warnings.slice(printedWarnings)) io.write(`${warning}\n`);
|
|
2301
|
-
printedWarnings = cassette.warnings.length;
|
|
2302
2620
|
const review = await reviewDryRun(io, effective, attempts);
|
|
2303
2621
|
if (!review.cleared) {
|
|
2304
|
-
writeReviewBlocked(io, review.specErrors.length, caseCount
|
|
2622
|
+
writeReviewBlocked(io, review.specErrors.length, caseCount);
|
|
2305
2623
|
continue;
|
|
2306
2624
|
}
|
|
2307
2625
|
approvals = review.approvals;
|
|
@@ -2324,17 +2642,6 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2324
2642
|
try {
|
|
2325
2643
|
const finalSuite = deps.getAuthoringExecutionSuite(final.snapshot);
|
|
2326
2644
|
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
2645
|
const skippedIndexes = new Set(skippedTools.map((tool) => tool.index));
|
|
2339
2646
|
reportCoverageSafely(deps, () => deps.computeCoverage?.({
|
|
2340
2647
|
suite: finalSuite,
|
|
@@ -2595,10 +2902,16 @@ function renderPreFillSummary(options) {
|
|
|
2595
2902
|
/**
|
|
2596
2903
|
* 케이스 수 고지. 임계 아래면 빈 문자열이다.
|
|
2597
2904
|
* 생성을 막지 않는다. 이미 존재하는 상한을 사용자에게 보이게 하는 것이 목적이다.
|
|
2905
|
+
*
|
|
2906
|
+
* 임계는 케이스당 600바이트 **추정** 위에 있다(#92). 생성 시점에는 응답 크기를 모르니 개수로
|
|
2907
|
+
* 볼 수밖에 없고, 그래서 문안이 추정임을 밝히고 실제 판정은 `test` 실행(runner 의 `payload`
|
|
2908
|
+
* 고지)이 한다고 적는다. 추정을 확정처럼 적으면 응답이 작은 서버의 사용자가 경고를 무시하는
|
|
2909
|
+
* 법을 배운다.
|
|
2598
2910
|
*/
|
|
2599
2911
|
function renderCaseCountNotice(caseCount) {
|
|
2600
2912
|
if (caseCount < CASE_COUNT_WARNING_THRESHOLD) return "";
|
|
2601
|
-
return `→ 케이스 ${caseCount}개를 만들었습니다. runner 보고서 상한(1MB)에 가까워 test 실행이\n RunnerPayloadLimitError 로 실패할 수 있습니다.
|
|
2913
|
+
return `→ 케이스 ${caseCount}개를 만들었습니다. runner 보고서 상한(1MB)에 가까워 test 실행이\n RunnerPayloadLimitError 로 실패할 수 있습니다. 케이스당 600바이트로 잡은 추정이라
|
|
2914
|
+
실제 벽은 서버 응답 크기에 따라 다릅니다. test 실행이 실제 크기로 다시 알립니다.
|
|
2602
2915
|
→ 툴을 나눠 여러 명세 파일로 생성하면 피할 수 있습니다.
|
|
2603
2916
|
`;
|
|
2604
2917
|
}
|
|
@@ -2733,9 +3046,10 @@ async function runGenerateCommand(argv, deps) {
|
|
|
2733
3046
|
*/
|
|
2734
3047
|
let review;
|
|
2735
3048
|
try {
|
|
2736
|
-
connection = await
|
|
2737
|
-
|
|
2738
|
-
|
|
3049
|
+
connection = await openConnection(input.target, {
|
|
3050
|
+
connectStdio: deps.connect,
|
|
3051
|
+
...deps.connectHttp === void 0 ? {} : { connectHttp: deps.connectHttp },
|
|
3052
|
+
...deps.readEnv === void 0 ? {} : { readEnv: deps.readEnv }
|
|
2739
3053
|
});
|
|
2740
3054
|
const active = connection;
|
|
2741
3055
|
const tools = await active.client.listTools();
|
|
@@ -2789,12 +3103,29 @@ async function runGenerateCommand(argv, deps) {
|
|
|
2789
3103
|
}
|
|
2790
3104
|
} catch (error) {
|
|
2791
3105
|
if (connection !== void 0) await connection.forceClose().catch(() => void 0);
|
|
3106
|
+
if (isReviewInputClosed(error)) {
|
|
3107
|
+
deps.reviewIO?.close?.();
|
|
3108
|
+
deps.writeStdout("입력이 종료되어 검토를 취소했습니다. 저장하지 않았습니다.\n");
|
|
3109
|
+
return 0;
|
|
3110
|
+
}
|
|
3111
|
+
if (error instanceof ConnectTargetError) {
|
|
3112
|
+
deps.writeStderr(`오류 [CLI_USAGE]: ${error.message}\n해결: ${GENERATE_USAGE_HINT}\n`);
|
|
3113
|
+
return 1;
|
|
3114
|
+
}
|
|
2792
3115
|
if (error instanceof OutputExistsError) outputExistsFailure(deps, error.path);
|
|
2793
3116
|
else if (error instanceof OutputReplaceError) outputReplaceFailure(deps, error.path, error.code);
|
|
2794
3117
|
else if (error instanceof LinkUnsupportedError) linkUnsupportedFailure(deps, error.path, error.code);
|
|
2795
3118
|
else if (deps.GenerateTestsError !== void 0 && error instanceof deps.GenerateTestsError) generateTestsFailure(deps, error);
|
|
2796
|
-
else if (isCoreClientError(error))
|
|
2797
|
-
|
|
3119
|
+
else if (isCoreClientError(error)) {
|
|
3120
|
+
deps.writeStderr(`오류 [GENERATE_CONNECT_FAILED/${error.code}]: ${error.message}\n해결: ${error.hint}\n`);
|
|
3121
|
+
const diagnostics = processDiagnostics(error.diagnostics);
|
|
3122
|
+
if (diagnostics !== void 0 && hasDiagnosticContent(diagnostics)) {
|
|
3123
|
+
const block = renderProcessDiagnostics(diagnostics, { maxLines: DRY_RUN_STDERR_LINES });
|
|
3124
|
+
if (block !== "") deps.writeStderr(`\n${block}`);
|
|
3125
|
+
}
|
|
3126
|
+
const remote = httpDiagnostics(error.diagnostics);
|
|
3127
|
+
if (remote !== void 0) deps.writeStderr(`\n${renderHttpDiagnostics(remote)}`);
|
|
3128
|
+
} else deps.writeStderr("오류 [GENERATE_FAILED]: baseline suite를 생성하거나 저장하지 못했습니다.\n해결: MCP 서버와 출력 경로를 확인한 뒤 다시 실행하세요.\n");
|
|
2798
3129
|
return 1;
|
|
2799
3130
|
}
|
|
2800
3131
|
try {
|
|
@@ -2914,12 +3245,12 @@ function shouldShowSpecApproval(result, allPassed) {
|
|
|
2914
3245
|
return allPassed ? result.state === "mismatched" : true;
|
|
2915
3246
|
}
|
|
2916
3247
|
/** 반환은 개행으로 끝난다. 호출자가 앞에 빈 줄을 붙인다. 설계 문서 §7.2. */
|
|
2917
|
-
function renderSpecApproval(result) {
|
|
3248
|
+
function renderSpecApproval(result, allPassed) {
|
|
2918
3249
|
if (result.state === "matched") return `명세: 승인 시점과 동일 (${short(result.fingerprint)})\n`;
|
|
2919
3250
|
if (result.state === "absent") return "명세: 승인 지문이 없습니다 (미고정)\n → mcpeak generate 로 승인한 명세가 아니거나 승인 이전 버전으로 만든 파일입니다.\n";
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
3251
|
+
const outcome = allPassed ? " → 승인받지 않은 현재 명세로 모든 테스트가 통과했습니다.\n" : " → 실패 원인에서 명세 변경을 배제할 수 없습니다.\n";
|
|
3252
|
+
return `명세: 현재 명세와 저장된 approval.fingerprint가 불일치함
|
|
3253
|
+
→ 승인 ${short(result.approvedFingerprint ?? "")} 현재 ${short(result.fingerprint)}\n` + outcome + " → 지문만으로는 변경 내용을 알 수 없습니다. 버전 관리에서 명세를 비교하세요.\n → 의도한 변경이면 같은 설정으로 mcpeak generate 를 다시 실행하고 --force 로 재승인하세요.\n";
|
|
2923
3254
|
}
|
|
2924
3255
|
/** 번들에 적는 CLI 식별자. `mcpeak --version` 이 찍는 것과 같은 출처를 쓴다. */
|
|
2925
3256
|
const REPAIR_BUNDLE_GENERATED_BY = `mcpeak ${version}`;
|
|
@@ -3188,7 +3519,7 @@ async function runRepairCommand(argv, deps) {
|
|
|
3188
3519
|
try {
|
|
3189
3520
|
text = await deps.readFile(input.bundlePath);
|
|
3190
3521
|
} catch {
|
|
3191
|
-
deps.writeStderr(`오류 [REPAIR_BUNDLE_READ_FAILED]: repair 번들 파일을 읽지 못했습니다. 경로: ${escapeTerminalText$
|
|
3522
|
+
deps.writeStderr(`오류 [REPAIR_BUNDLE_READ_FAILED]: repair 번들 파일을 읽지 못했습니다. 경로: ${escapeTerminalText$2(input.bundlePath)}\n해결: 경로와 읽기 권한을 확인하세요. 번들은 \`mcpeak test --repair-bundle <path>\` 가 만듭니다.\n`);
|
|
3192
3523
|
return 1;
|
|
3193
3524
|
}
|
|
3194
3525
|
const read = readRepairBundle(text);
|
|
@@ -3280,330 +3611,6 @@ async function runRepairCommand(argv, deps) {
|
|
|
3280
3611
|
return 0;
|
|
3281
3612
|
}
|
|
3282
3613
|
//#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(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 === REDACTED$1) add(path);
|
|
3448
|
-
else if (value.includes(REDACTED$1)) 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 = 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
3614
|
//#region src/determinism-capture.ts
|
|
3608
3615
|
/**
|
|
3609
3616
|
* 관찰용 평문 사본. `checkDeterminism` 은 `canonicalJson` 으로 비교하는데 그것은 undefined
|
|
@@ -3689,6 +3696,68 @@ function createDeterminismCapture(inner) {
|
|
|
3689
3696
|
};
|
|
3690
3697
|
}
|
|
3691
3698
|
//#endregion
|
|
3699
|
+
//#region src/external-wiring.ts
|
|
3700
|
+
/**
|
|
3701
|
+
* `@mcpeak/record/external` 은 **세션 옵션을 실제로 쓸 때만** 불러온다.
|
|
3702
|
+
*
|
|
3703
|
+
* 정적 import 로 두면 `node:sqlite` 가 CLI 를 띄우는 것만으로 로드되고, Node 22.x 는 그때
|
|
3704
|
+
* `ExperimentalWarning: SQLite is an experimental feature` 를 stderr 에 찍는다. 세션을 쓰지도
|
|
3705
|
+
* 않은 `mcpeak test` 실행마다 그 줄이 붙는다 — 터미널 출력이 곧 UI 인 도구에서 그냥 손해다.
|
|
3706
|
+
* (실제로 `dist-cli-e2e` 의 "stderr 가 비어 있다" 단언이 이걸 잡았다.)
|
|
3707
|
+
*
|
|
3708
|
+
* 지연 로딩이면 경고를 보는 사람은 External 을 직접 켠 사용자뿐이다. 그 경고를 아예 지울지는
|
|
3709
|
+
* ADR-0054 가 남겨 둔 별도 결정이며, 라이브러리가 아니라 CLI 가 정할 몫이다.
|
|
3710
|
+
*/
|
|
3711
|
+
const loadExternal = () => import("@mcpeak/record/external");
|
|
3712
|
+
/** 세션 파일 하나가 세션 하나이므로 식별자를 사용자에게 묻지 않는다. */
|
|
3713
|
+
const SESSION_ID = "default";
|
|
3714
|
+
/**
|
|
3715
|
+
* 재생할 세션 파일이 없을 때. **Store 를 열기 전에 던진다** — `node:sqlite` 의 `DatabaseSync`
|
|
3716
|
+
* 는 없는 경로를 만들어 버리므로, 열고 나서 판정하면 오타 한 번에 빈 DB 가 디스크에 남는다.
|
|
3717
|
+
* 그러면 두 번째 실행부터는 "파일이 없다" 가 거짓이 되어 진단이 또 어긋난다(#260).
|
|
3718
|
+
*/
|
|
3719
|
+
var SessionFileMissingError = class extends Error {
|
|
3720
|
+
path;
|
|
3721
|
+
name = "SessionFileMissingError";
|
|
3722
|
+
constructor(path) {
|
|
3723
|
+
super(`세션 파일이 없습니다: ${path}`);
|
|
3724
|
+
this.path = path;
|
|
3725
|
+
}
|
|
3726
|
+
};
|
|
3727
|
+
async function startExternalWiring(options) {
|
|
3728
|
+
if (options.mode === "replay" && !existsSync(options.sessionPath)) throw new SessionFileMissingError(options.sessionPath);
|
|
3729
|
+
const { createSqliteSessionStore, startExternalCoordinator } = await loadExternal();
|
|
3730
|
+
const store = createSqliteSessionStore({
|
|
3731
|
+
path: options.sessionPath,
|
|
3732
|
+
readOnly: options.mode === "replay"
|
|
3733
|
+
});
|
|
3734
|
+
let handle;
|
|
3735
|
+
try {
|
|
3736
|
+
handle = options.mode === "record" ? await startExternalCoordinator({
|
|
3737
|
+
mode: "record",
|
|
3738
|
+
sessionId: SESSION_ID,
|
|
3739
|
+
store,
|
|
3740
|
+
...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
|
|
3741
|
+
}) : await startExternalCoordinator({
|
|
3742
|
+
mode: "replay",
|
|
3743
|
+
sourceSessionId: SESSION_ID,
|
|
3744
|
+
store,
|
|
3745
|
+
...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
|
|
3746
|
+
});
|
|
3747
|
+
} catch (error) {
|
|
3748
|
+
store.close();
|
|
3749
|
+
throw error;
|
|
3750
|
+
}
|
|
3751
|
+
let settled;
|
|
3752
|
+
return {
|
|
3753
|
+
env: handle.childEnvironment,
|
|
3754
|
+
finish(status) {
|
|
3755
|
+
settled ??= handle.finish(status).finally(() => store.close());
|
|
3756
|
+
return settled;
|
|
3757
|
+
}
|
|
3758
|
+
};
|
|
3759
|
+
}
|
|
3760
|
+
//#endregion
|
|
3692
3761
|
//#region src/test-command.ts
|
|
3693
3762
|
/** --stderr-lines 기본값. 설계 문서 §6. */
|
|
3694
3763
|
const DEFAULT_STDERR_LINES = 20;
|
|
@@ -3725,6 +3794,11 @@ const dictionary = {
|
|
|
3725
3794
|
message: "Runner 실행 또는 MCP 서버 종료에 실패했습니다.",
|
|
3726
3795
|
hint: "서버 응답과 종료 상태를 확인하세요."
|
|
3727
3796
|
},
|
|
3797
|
+
/** 문장은 `payloadLimitFailure` 가 실제 크기로 만든다. 여기는 타입을 채우는 자리다. */
|
|
3798
|
+
RUNNER_PAYLOAD_LIMIT_EXCEEDED: {
|
|
3799
|
+
message: "보고서 또는 케이스가 runner 상한을 넘었습니다.",
|
|
3800
|
+
hint: "케이스나 응답의 크기를 줄이세요."
|
|
3801
|
+
},
|
|
3728
3802
|
JUNIT_WRITE_FAILED: {
|
|
3729
3803
|
message: "JUnit XML 파일을 쓰지 못했습니다.",
|
|
3730
3804
|
hint: "`--junit` 경로의 디렉터리가 존재하는지와 쓰기 권한을 확인하세요."
|
|
@@ -3737,6 +3811,10 @@ const dictionary = {
|
|
|
3737
3811
|
message: "초기화 명령이 실패했습니다.",
|
|
3738
3812
|
hint: "`--reset-cmd` 명령이 단독으로 성공하는지 확인한 뒤 다시 실행하세요."
|
|
3739
3813
|
},
|
|
3814
|
+
EXTERNAL_SESSION_FAILED: {
|
|
3815
|
+
message: "External 세션 처리에 실패했습니다.",
|
|
3816
|
+
hint: "세션 파일 경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요."
|
|
3817
|
+
},
|
|
3740
3818
|
CLI_INTERNAL_ERROR: {
|
|
3741
3819
|
message: "예상하지 못한 CLI 내부 오류가 발생했습니다.",
|
|
3742
3820
|
hint: "다시 실행한 뒤 재현 정보와 함께 이슈를 보고하세요."
|
|
@@ -3756,19 +3834,66 @@ const fail = (message) => {
|
|
|
3756
3834
|
hint: TEST_USAGE_HINT
|
|
3757
3835
|
});
|
|
3758
3836
|
};
|
|
3837
|
+
/**
|
|
3838
|
+
* `--name value` 와 `--name=value` 두 형태에서 값을 꺼낸다.
|
|
3839
|
+
*
|
|
3840
|
+
* 값이 `--` 로 시작하면 거절한다. `--session --json` 처럼 값을 빠뜨리면 다음 옵션이 경로로
|
|
3841
|
+
* 먹히고, 그러면 "그런 파일이 없습니다" 라는 엉뚱한 곳에서 실패한다.
|
|
3842
|
+
*/
|
|
3843
|
+
const readOptionValue = (argv, token, name, index) => {
|
|
3844
|
+
let value;
|
|
3845
|
+
let next = index;
|
|
3846
|
+
if (token === name) {
|
|
3847
|
+
const candidate = argv[++next];
|
|
3848
|
+
if (candidate === void 0) throw new CliCommandError({
|
|
3849
|
+
code: "CLI_USAGE",
|
|
3850
|
+
message: `\`${name}\` 옵션 값이 필요합니다.`,
|
|
3851
|
+
hint: TEST_USAGE_HINT
|
|
3852
|
+
});
|
|
3853
|
+
value = candidate;
|
|
3854
|
+
} else value = token.slice(`${name}=`.length);
|
|
3855
|
+
if (value.trim() === "" || value.startsWith("--")) fail(`\`${name}\` 옵션 값이 필요합니다.`);
|
|
3856
|
+
return {
|
|
3857
|
+
value,
|
|
3858
|
+
index: next
|
|
3859
|
+
};
|
|
3860
|
+
};
|
|
3759
3861
|
function parseTestCommand(argv) {
|
|
3760
3862
|
const suitePath = argv[0] ?? "";
|
|
3761
3863
|
if (suitePath === "") fail("테스트 명세 JSON 경로가 필요합니다.");
|
|
3762
3864
|
let command;
|
|
3865
|
+
let url;
|
|
3763
3866
|
let json = false;
|
|
3764
3867
|
let junitPath;
|
|
3765
3868
|
let repairBundlePath;
|
|
3766
3869
|
let determinism = false;
|
|
3767
3870
|
let resetCmd;
|
|
3768
3871
|
let stderrLines;
|
|
3872
|
+
let sessionPath;
|
|
3873
|
+
let recordSessionPath;
|
|
3769
3874
|
const args = [];
|
|
3875
|
+
const headerEnv = createHeaderEnvCollector();
|
|
3770
3876
|
for (let index = 1; index < argv.length; index += 1) {
|
|
3771
3877
|
const token = argv[index] ?? "";
|
|
3878
|
+
/**
|
|
3879
|
+
* `--` 뒤는 전부 서버를 띄울 명령이다. npm · cargo · docker 가 쓰는 관례라 설명이
|
|
3880
|
+
* 필요 없고, 무엇보다 **뒤에 오는 것을 우리가 해석하지 않는다** — `--arg --port` 처럼
|
|
3881
|
+
* 옵션처럼 생긴 값에 특수분기를 두지 않아도 된다(#242).
|
|
3882
|
+
*
|
|
3883
|
+
* 첫 토큰이 실행 파일, 나머지가 그 인자다. `--command` 와 함께 쓸 수 없다 — 대상이
|
|
3884
|
+
* 둘이 되면 어느 쪽을 띄울지 화면이 말할 수 없다.
|
|
3885
|
+
*/
|
|
3886
|
+
if (token === "--") {
|
|
3887
|
+
if (command !== void 0) fail("`--command` 와 `--` 를 함께 쓸 수 없습니다.\n→ 둘 다 서버를 띄울 명령을 정하므로 대상이 둘이 됩니다.\n→ `--` 뒤에는 실행 파일과 인자를 그대로 적습니다.");
|
|
3888
|
+
if (args.length > 0) fail("`--arg` 와 `--` 를 함께 쓸 수 없습니다.\n→ `--arg` 로 준 값이 `--` 뒤의 인자 앞에 끼어듭니다.\n→ `--` 를 쓰면 인자도 그 뒤에 모두 적습니다.");
|
|
3889
|
+
const rest = argv.slice(index + 1);
|
|
3890
|
+
const executable = rest[0];
|
|
3891
|
+
if (executable === void 0 || executable === "") fail("`--` 뒤에 실행할 명령이 없습니다.\n→ `-- <executable> [args...]` 처럼 첫 토큰에 실행 파일을 적으세요.");
|
|
3892
|
+
command = executable;
|
|
3893
|
+
args.push(...rest.slice(1));
|
|
3894
|
+
index = argv.length;
|
|
3895
|
+
continue;
|
|
3896
|
+
}
|
|
3772
3897
|
if (token === "--command" || token.startsWith("--command=")) {
|
|
3773
3898
|
if (command !== void 0) fail("`--command`는 한 번만 사용할 수 있습니다.");
|
|
3774
3899
|
let value;
|
|
@@ -3784,6 +3909,22 @@ function parseTestCommand(argv) {
|
|
|
3784
3909
|
if (value === "") fail("`--command` 옵션 값이 필요합니다.");
|
|
3785
3910
|
if (value.startsWith("--")) fail("`--command` 옵션 값이 필요합니다.");
|
|
3786
3911
|
command = value;
|
|
3912
|
+
} else if (token === "--url" || token.startsWith("--url=")) {
|
|
3913
|
+
if (url !== void 0) fail("`--url`은 한 번만 사용할 수 있습니다.");
|
|
3914
|
+
const read = readOptionValue(argv, token, "--url", index);
|
|
3915
|
+
index = read.index;
|
|
3916
|
+
const parsed = parseUrlOption(read.value);
|
|
3917
|
+
if (!parsed.ok) fail(parsed.message);
|
|
3918
|
+
else url = parsed.value;
|
|
3919
|
+
} else if (token === "--header-env" || token.startsWith("--header-env=")) {
|
|
3920
|
+
const read = readOptionValue(argv, token, "--header-env", index);
|
|
3921
|
+
index = read.index;
|
|
3922
|
+
const parsed = parseHeaderEnvOption(read.value);
|
|
3923
|
+
if (!parsed.ok) fail(parsed.message);
|
|
3924
|
+
else {
|
|
3925
|
+
const rejected = headerEnv.add(parsed.value.header, parsed.value.envName);
|
|
3926
|
+
if (rejected !== void 0) fail(rejected);
|
|
3927
|
+
}
|
|
3787
3928
|
} else if (token === "--arg" || token.startsWith("--arg=")) {
|
|
3788
3929
|
let value;
|
|
3789
3930
|
if (token === "--arg") {
|
|
@@ -3859,27 +4000,53 @@ function parseTestCommand(argv) {
|
|
|
3859
4000
|
const parsedLines = Number.parseInt(value, 10);
|
|
3860
4001
|
if (!Number.isSafeInteger(parsedLines)) fail("`--stderr-lines` 값은 0 이상의 정수여야 합니다.");
|
|
3861
4002
|
stderrLines = parsedLines;
|
|
4003
|
+
} else if (token === "--session" || token.startsWith("--session=")) {
|
|
4004
|
+
if (sessionPath !== void 0) fail("`--session`은 한 번만 사용할 수 있습니다.");
|
|
4005
|
+
const read = readOptionValue(argv, token, "--session", index);
|
|
4006
|
+
sessionPath = read.value;
|
|
4007
|
+
index = read.index;
|
|
4008
|
+
} else if (token === "--record-session" || token.startsWith("--record-session=")) {
|
|
4009
|
+
if (recordSessionPath !== void 0) fail("`--record-session`은 한 번만 사용할 수 있습니다.");
|
|
4010
|
+
const read = readOptionValue(argv, token, "--record-session", index);
|
|
4011
|
+
recordSessionPath = read.value;
|
|
4012
|
+
index = read.index;
|
|
3862
4013
|
} else if (token === "--json") {
|
|
3863
4014
|
if (json) fail("`--json`은 한 번만 사용할 수 있습니다.");
|
|
3864
4015
|
json = true;
|
|
3865
4016
|
} else if (token.startsWith("--json=")) fail("`--json`은 값을 받지 않습니다.");
|
|
3866
|
-
else if (token.startsWith("-")) fail(`지원하지 않는 test 옵션 '${token}'입니다.`);
|
|
3867
|
-
else fail(`추가 위치 인자 '${token}'는 허용되지 않습니다.`);
|
|
4017
|
+
else if (token.startsWith("-")) fail(`지원하지 않는 test 옵션 '${escapeTerminalText(token)}'입니다.`);
|
|
4018
|
+
else fail(`추가 위치 인자 '${escapeTerminalText(token)}'는 허용되지 않습니다.`);
|
|
3868
4019
|
}
|
|
3869
|
-
if (command
|
|
4020
|
+
if (command !== void 0 && url !== void 0) fail("`--command` 와 `--url` 은 함께 쓸 수 없습니다.\n→ `--command` 는 서버를 프로세스로 띄우고, `--url` 은 이미 떠 있는 원격 서버에 붙습니다.\n→ 둘 중 무엇을 검사할지 하나만 고르세요.");
|
|
4021
|
+
if (command === void 0 && url === void 0) throw new CliCommandError({
|
|
3870
4022
|
code: "CLI_USAGE",
|
|
3871
|
-
message: "`--command` 옵션이 필요합니다.",
|
|
4023
|
+
message: "`--command` 또는 `--url` 옵션이 필요합니다.",
|
|
3872
4024
|
hint: TEST_USAGE_HINT
|
|
3873
4025
|
});
|
|
4026
|
+
if (url !== void 0 && args.length > 0) fail("`--arg` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--arg` 는 우리가 띄우는 프로세스에 넘길 인자입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
|
|
4027
|
+
if (command !== void 0 && !headerEnv.isEmpty()) fail("`--header-env` 는 `--url` 과 함께만 쓸 수 있습니다.\n→ 헤더는 HTTP 요청에 실립니다. `--command` 로 띄운 서버와는 stdio 로 이야기합니다.");
|
|
4028
|
+
if (url !== void 0 && stderrLines !== void 0) fail("`--stderr-lines` 는 `--url` 과 함께 쓸 수 없습니다.\n→ 이 옵션은 우리가 띄운 프로세스의 stderr 를 보여줍니다. 원격 서버에는 그 프로세스가 없습니다.\n→ 원격 서버 실패는 엔드포인트와 HTTP 상태로 진단합니다. 그 블록은 항상 나옵니다.");
|
|
4029
|
+
if (url !== void 0 && (sessionPath !== void 0 || recordSessionPath !== void 0)) fail("External 세션 옵션은 `--url` 과 함께 쓸 수 없습니다.\n→ External 배선은 우리가 띄운 자식 프로세스의 환경변수로 들어갑니다.\n→ 원격 서버는 우리가 띄우지 않으므로 그 주입 지점이 없습니다.");
|
|
4030
|
+
if (sessionPath !== void 0 && recordSessionPath !== void 0) fail("`--session`과 `--record-session`은 함께 쓸 수 없습니다. 재생과 녹화 중 하나만 고르세요.");
|
|
4031
|
+
if (determinism && (sessionPath !== void 0 || recordSessionPath !== void 0)) fail("`--determinism`은 External 세션 옵션과 함께 쓸 수 없습니다.\n→ `--determinism`은 서버에 2회 연결하지만 External 세션은 연결 하나에 묶여 있습니다.\n→ 2회차가 같은 세션을 쓰면 반복 호출 순번이 어긋나고, 새 세션을 쓰면 비교 기준이 갈라집니다.");
|
|
3874
4032
|
return Object.freeze({
|
|
3875
4033
|
suitePath,
|
|
3876
|
-
|
|
3877
|
-
|
|
4034
|
+
target: url === void 0 ? Object.freeze({
|
|
4035
|
+
transport: "stdio",
|
|
4036
|
+
command,
|
|
4037
|
+
args: Object.freeze(args)
|
|
4038
|
+
}) : Object.freeze({
|
|
4039
|
+
transport: "http",
|
|
4040
|
+
url,
|
|
4041
|
+
headerEnv: headerEnv.snapshot()
|
|
4042
|
+
}),
|
|
3878
4043
|
json,
|
|
3879
4044
|
junitPath,
|
|
3880
4045
|
repairBundlePath,
|
|
3881
4046
|
determinism,
|
|
3882
4047
|
resetCmd,
|
|
4048
|
+
sessionPath,
|
|
4049
|
+
recordSessionPath,
|
|
3883
4050
|
stderrLines: stderrLines ?? DEFAULT_STDERR_LINES
|
|
3884
4051
|
});
|
|
3885
4052
|
}
|
|
@@ -3909,8 +4076,77 @@ const FINDING_GROUP_ORDER = [
|
|
|
3909
4076
|
"rejectionIntent",
|
|
3910
4077
|
"skipped"
|
|
3911
4078
|
];
|
|
4079
|
+
const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
|
|
4080
|
+
const SAME_UNICODE_CASE_FOLDED_CHARACTER = /^(.)\1$/iu;
|
|
4081
|
+
/** 두 유니코드 코드 포인트가 정규식의 `/iu` 대소문자 규칙에서 같은지 판정한다. */
|
|
4082
|
+
const sameUnicodeCaseFoldedCharacter = (left, right) => SAME_UNICODE_CASE_FOLDED_CHARACTER.test(left + right);
|
|
4083
|
+
/** limit 를 넘기기 전에 순회를 멈춰 신뢰하지 않는 진단 문자열의 메모리 사용을 제한한다. */
|
|
4084
|
+
const codePointsWithinLimit = (value, limit) => {
|
|
4085
|
+
const points = [];
|
|
4086
|
+
for (const point of value) {
|
|
4087
|
+
if (points.length === limit) return void 0;
|
|
4088
|
+
points.push(point);
|
|
4089
|
+
}
|
|
4090
|
+
return points;
|
|
4091
|
+
};
|
|
4092
|
+
/**
|
|
4093
|
+
* 진단 문장에서 독립된 marker 를 찾는다. runner 가 보장하는 진단 문자열 길이를 넘으면
|
|
4094
|
+
* 임의로 자른 경계에서 오탐하지 않도록 중복이 아니라고 보수적으로 판정한다.
|
|
4095
|
+
*/
|
|
4096
|
+
const containsWholeDiagnosticMarker = (note, marker) => {
|
|
4097
|
+
if (marker === "") return false;
|
|
4098
|
+
const notePoints = codePointsWithinLimit(note, MAX_VALUE_STRING_CHARS);
|
|
4099
|
+
if (notePoints === void 0) return false;
|
|
4100
|
+
const markerPoints = codePointsWithinLimit(marker, notePoints.length);
|
|
4101
|
+
if (markerPoints === void 0) return false;
|
|
4102
|
+
for (let start = 0; start <= notePoints.length - markerPoints.length; start += 1) {
|
|
4103
|
+
if (!markerPoints.every((point, offset) => sameUnicodeCaseFoldedCharacter(notePoints[start + offset] ?? "", point))) continue;
|
|
4104
|
+
const before = notePoints[start - 1];
|
|
4105
|
+
const after = notePoints[start + markerPoints.length];
|
|
4106
|
+
if ((before === void 0 || !WORD_CHARACTER.test(before)) && (after === void 0 || !WORD_CHARACTER.test(after))) return true;
|
|
4107
|
+
}
|
|
4108
|
+
return false;
|
|
4109
|
+
};
|
|
4110
|
+
/**
|
|
4111
|
+
* 서버 응답 본문이 이미 타입 위반을 설명했는지 보수적으로 판정한다(#350).
|
|
4112
|
+
*
|
|
4113
|
+
* 응답 문장을 자연어로 해석하려 들면 "city 문자열을 숫자로 바꾸세요" 같은 다른 사실까지
|
|
4114
|
+
* 같은 말로 오인할 수 있다. 그래서 TYPE_MISMATCH 에서 구조적으로 아는 세 표식, 즉 필드명과
|
|
4115
|
+
* 기대 타입, 실제 타입(또는 실제 입력값)이 한 줄에 모두 있을 때만 참고 finding 을 접는다.
|
|
4116
|
+
* 하나라도 없으면 서버가 충분히 설명했다고 단정하지 않고 기존 참고를 남긴다.
|
|
4117
|
+
*
|
|
4118
|
+
* 다른 finding 은 같은 사실인지 판별할 표식이 부족하다. 예를 들어 REQUIRED_MISSING 은 필드명
|
|
4119
|
+
* 하나만 같아도 되므로 여기서 함께 억제하면 조용한 서버에서 유일한 단서를 잃을 수 있다.
|
|
4120
|
+
* 이 보수적 억제 범위와 검토한 대안은 ADR-0077에 기록한다.
|
|
4121
|
+
*/
|
|
4122
|
+
const responseRepeatsTypeMismatch = (finding, item) => {
|
|
4123
|
+
if (finding.code !== "TYPE_MISMATCH") return false;
|
|
4124
|
+
const expected = finding.expected;
|
|
4125
|
+
const actualType = finding.actual;
|
|
4126
|
+
if (typeof expected !== "string" || typeof actualType !== "string") return false;
|
|
4127
|
+
if (item.spec.operation.type !== "callTool" || !finding.path.startsWith("input.")) return false;
|
|
4128
|
+
const field = finding.path.slice(6);
|
|
4129
|
+
if (field === "") return false;
|
|
4130
|
+
const notes = [...item.operation.diagnostic?.notes ?? [], ...item.assertions.flatMap((assertion) => assertion.diagnostic?.notes ?? [])];
|
|
4131
|
+
const actualValue = item.spec.operation.input[field];
|
|
4132
|
+
const actualValueText = actualValue === void 0 ? void 0 : typeof actualValue === "string" ? actualValue : JSON.stringify(actualValue);
|
|
4133
|
+
return notes.some((note) => containsWholeDiagnosticMarker(note, field) && containsWholeDiagnosticMarker(note, expected) && (containsWholeDiagnosticMarker(note, actualType) || actualValueText !== void 0 && containsWholeDiagnosticMarker(note, actualValueText)));
|
|
4134
|
+
};
|
|
3912
4135
|
/** 결정론성 결과 블록의 머리글. 설계 문서 §8. */
|
|
3913
4136
|
const DETERMINISM_HEADING = "결정론성 확인";
|
|
4137
|
+
/**
|
|
4138
|
+
* 종료 코드 고지. `--determinism` 은 **비차단 진단**이다 — 차이를 찾아도 종료 코드는 1회차
|
|
4139
|
+
* 판정 그대로 0 이다(설계 문서 §서론 "비차단 진단. status·종료 코드·`RunnerReport` 불변",
|
|
4140
|
+
* ADR-0018). 오탐이 CI 를 막으면 안 된다는 것이 그 설계의 이유다.
|
|
4141
|
+
*
|
|
4142
|
+
* **동작은 바꾸지 않는다.** 그 사실이 화면 어디에도 없어서, CI 는 초록인데 서버가 비결정이라는
|
|
4143
|
+
* 것을 아무도 모르는 자리가 있었다(#292). 고치는 것은 고지뿐이다.
|
|
4144
|
+
*
|
|
4145
|
+
* 뒤에 붙는 "어디를 보라" 는 갈래마다 다르다. `--json` 의 `determinism` 키는 **비교까지 갔을
|
|
4146
|
+
* 때만** 만들어지므로(위 §JSON 조립), 비교를 못 한 갈래에서 그 키를 가리키면 없는 것을
|
|
4147
|
+
* 가리키는 안내가 된다.
|
|
4148
|
+
*/
|
|
4149
|
+
const EXIT_CODE_NOTICE = "→ 이 진단은 종료 코드에 반영되지 않습니다.";
|
|
3914
4150
|
/** `(12/12)` 와 `(12/12, 제외 2: 실행되지 않은 케이스)`. 설계 문서 §8. */
|
|
3915
4151
|
const determinismCounts = (result) => result.skipped === 0 ? `(${result.compared}/${result.compared})` : `(${result.compared}/${result.compared}, 제외 ${result.skipped}: 실행되지 않은 케이스)`;
|
|
3916
4152
|
/**
|
|
@@ -3923,6 +4159,7 @@ function renderDeterminism(outcome, options) {
|
|
|
3923
4159
|
if (outcome.kind === "incomplete") {
|
|
3924
4160
|
const head = `${DETERMINISM_HEADING}\n→ 2회차 실행이 완주하지 못해 비교할 수 없습니다. (사유: ${escapeTerminalText(outcome.reason)})\n→ 1회차는 완주했으므로, 서버가 반복 실행 자체에 취약할 수 있습니다
|
|
3925
4161
|
(이전 실행이 남긴 상태·잠금·포트 점유 등).
|
|
4162
|
+
${EXIT_CODE_NOTICE} --json 에서는 determinism 키가 만들어지지 않아\n 이 안내가 stderr 로만 나갑니다.
|
|
3926
4163
|
`;
|
|
3927
4164
|
if (options.stderrLines === 0 || outcome.diagnostics === void 0 || !hasDiagnosticContent(outcome.diagnostics)) return head;
|
|
3928
4165
|
const block = renderProcessDiagnostics(outcome.diagnostics, { maxLines: options.stderrLines });
|
|
@@ -3935,37 +4172,143 @@ function renderDeterminism(outcome, options) {
|
|
|
3935
4172
|
`;
|
|
3936
4173
|
const suffix = result.skipped === 0 ? "" : ` (제외 ${result.skipped}: 실행되지 않은 케이스)`;
|
|
3937
4174
|
const blocks = result.differences.map((difference) => describeDeterminismDifference(difference, { stateRestored: options.stateRestored })).join("\n\n");
|
|
3938
|
-
return `${DETERMINISM_HEADING}\n→ ${result.differences.length}/${result.compared} 케이스에서 2회 실행 결과가 다릅니다.${suffix}\n\n${blocks}\n`;
|
|
4175
|
+
return `${DETERMINISM_HEADING}\n→ ${result.differences.length}/${result.compared} 케이스에서 2회 실행 결과가 다릅니다.${suffix}\n\n${blocks}\n${EXIT_CODE_NOTICE} CI 를 막으려면 --json 의 determinism 키를 보세요.\n`;
|
|
3939
4176
|
}
|
|
3940
|
-
/**
|
|
4177
|
+
/**
|
|
4178
|
+
* 초기화 명령 실패 안내.
|
|
4179
|
+
*
|
|
4180
|
+
* 진단(종료 코드·stderr 꼬리)과 다음 행동(hint)을 한 줄에 눌러 담지 않는다(#289). 예전에는
|
|
4181
|
+
* 그 둘을 이어 붙이며 경계를 두지 않아 `... ENOENT 명령이 단독으로 성공하는지 ...` 처럼
|
|
4182
|
+
* `ENOENT` 가 `명령` 을 수식하는 말로 읽혔다. 진단은 `message` 에 줄을 나눠 담고, `hint` 는
|
|
4183
|
+
* 다음에 할 일 한 문장만 남긴다 — `format()` 이 이제 개행을 이스케이프하지 않으므로
|
|
4184
|
+
* (`--reset-cmd` 명령이 단독으로) 그대로 구조가 유지된다.
|
|
4185
|
+
*
|
|
4186
|
+
* `error.command`·stderr 꼬리는 사용자가 지정한 명령과 그 명령이 만든 출력이라, 우리 문장이
|
|
4187
|
+
* 아닌 값이 섞이는 자리다. 화면을 깨뜨릴 제어 문자가 실려도 그대로 나가지 않도록 이스케이프한다.
|
|
4188
|
+
*/
|
|
3941
4189
|
function resetFailure(error) {
|
|
3942
4190
|
const exit = error.exitCode === null ? "없음" : String(error.exitCode);
|
|
3943
4191
|
const tail = error.stderr.split("\n").filter(Boolean).slice(-3).join(" | ");
|
|
3944
|
-
const
|
|
4192
|
+
const stderrLine = tail === "" ? "" : `\n stderr 마지막 3줄: ${escapeTerminalText(tail)}`;
|
|
3945
4193
|
return {
|
|
3946
4194
|
code: "RESET_COMMAND_FAILED",
|
|
3947
|
-
message: `초기화 명령이 실패했습니다: ${error.command}`,
|
|
3948
|
-
hint:
|
|
4195
|
+
message: `초기화 명령이 실패했습니다: ${escapeTerminalText(error.command)}\n 종료 코드: ${exit}${stderrLine}`,
|
|
4196
|
+
hint: "`--reset-cmd` 명령이 단독으로 성공하는지 확인한 뒤 다시 실행하세요."
|
|
4197
|
+
};
|
|
4198
|
+
}
|
|
4199
|
+
/** JUnit 파일 쓰기 실패에 사용자가 지정한 경로와 운영체제 오류 코드를 남긴다(#294). */
|
|
4200
|
+
/**
|
|
4201
|
+
* 노드 오류의 `code`. 문자열(`ENOENT`)이거나 숫자일 수 있고, 둘 다 아니면 우리가 아는 것이
|
|
4202
|
+
* 없다. `junitWriteFailure` 가 쓰던 것을 꺼내 다른 실패 생성기와 함께 쓴다(#276).
|
|
4203
|
+
*/
|
|
4204
|
+
function errnoOf(error) {
|
|
4205
|
+
const code = error?.code;
|
|
4206
|
+
return typeof code === "string" || typeof code === "number" ? String(code) : "알 수 없음";
|
|
4207
|
+
}
|
|
4208
|
+
function junitWriteFailure(path, error) {
|
|
4209
|
+
return {
|
|
4210
|
+
code: "JUNIT_WRITE_FAILED",
|
|
4211
|
+
message: `JUnit XML 파일을 쓰지 못했습니다: ${escapeTerminalText(path)}\n errno: ${escapeTerminalText(errnoOf(error))}`,
|
|
4212
|
+
hint: dictionary.JUNIT_WRITE_FAILED.hint
|
|
4213
|
+
};
|
|
4214
|
+
}
|
|
4215
|
+
/**
|
|
4216
|
+
* `--repair-bundle` 쓰기 실패. 짝인 `junitWriteFailure` 와 같은 모양이어야 한다 — 같은 실행에서
|
|
4217
|
+
* 둘 다 실패할 수 있고, 그때 한쪽만 경로를 싣고 있으면 사용자가 어느 파일이 없는지 알 수 없다.
|
|
4218
|
+
*/
|
|
4219
|
+
function repairBundleWriteFailure(path, error) {
|
|
4220
|
+
return {
|
|
4221
|
+
code: "REPAIR_BUNDLE_WRITE_FAILED",
|
|
4222
|
+
message: `repair 번들 파일을 쓰지 못했습니다: ${escapeTerminalText(path)}\n errno: ${escapeTerminalText(errnoOf(error))}`,
|
|
4223
|
+
hint: dictionary.REPAIR_BUNDLE_WRITE_FAILED.hint
|
|
4224
|
+
};
|
|
4225
|
+
}
|
|
4226
|
+
/**
|
|
4227
|
+
* 명세 읽기 실패. **errno 별로 다음 행동이 다르다** — 파일이 없으면 경로를 고치고, 권한이
|
|
4228
|
+
* 없으면 권한을 고친다. 둘을 한 문장으로 묶으면 사용자에게 둘 다 확인하게 만든다(#276).
|
|
4229
|
+
* 어느 쪽인지 아는데 안 말할 이유가 없다.
|
|
4230
|
+
*/
|
|
4231
|
+
function suiteReadFailure(path, error) {
|
|
4232
|
+
const shown = escapeTerminalText(path);
|
|
4233
|
+
const errno = errnoOf(error);
|
|
4234
|
+
if (errno === "ENOENT") return {
|
|
4235
|
+
code: "SUITE_READ_FAILED",
|
|
4236
|
+
message: `테스트 명세 파일이 그 경로에 없습니다: ${shown}`,
|
|
4237
|
+
hint: "경로와 파일 이름을 확인하세요. 상대 경로는 명령을 실행한 디렉터리 기준입니다."
|
|
4238
|
+
};
|
|
4239
|
+
if (errno === "EACCES" || errno === "EPERM") return {
|
|
4240
|
+
code: "SUITE_READ_FAILED",
|
|
4241
|
+
message: `테스트 명세 파일을 읽을 권한이 없습니다: ${shown}`,
|
|
4242
|
+
hint: "그 파일의 읽기 권한을 확인하세요. 경로 자체는 찾았습니다."
|
|
4243
|
+
};
|
|
4244
|
+
if (errno === "EISDIR") return {
|
|
4245
|
+
code: "SUITE_READ_FAILED",
|
|
4246
|
+
message: `그 경로는 파일이 아니라 디렉터리입니다: ${shown}`,
|
|
4247
|
+
hint: "스위트 JSON 파일의 경로를 주세요."
|
|
4248
|
+
};
|
|
4249
|
+
return {
|
|
4250
|
+
code: "SUITE_READ_FAILED",
|
|
4251
|
+
message: `테스트 명세 파일을 읽지 못했습니다: ${shown}\n errno: ${escapeTerminalText(errno)}`,
|
|
4252
|
+
hint: dictionary.SUITE_READ_FAILED.hint
|
|
4253
|
+
};
|
|
4254
|
+
}
|
|
4255
|
+
/**
|
|
4256
|
+
* 확장자 검사 실패. **인코딩 얘기를 하지 않는다.**
|
|
4257
|
+
*
|
|
4258
|
+
* 이 갈래는 `extname(path) !== ".json"` 하나로만 온다. 그런데 안내가 "UTF-8로 저장한 .json
|
|
4259
|
+
* 명세 파일을 사용하세요" 라고 해서, 이미 UTF-8 JSON 인 사용자는 따라 해도 안 풀렸다.
|
|
4260
|
+
* 인코딩은 **바로 다음 단계가 따로 검사한다**(`SUITE_ENCODING_INVALID`). 그래서 이 안내는
|
|
4261
|
+
* 인코딩을 아예 언급하지 않는다 — 한 번 꺼내면 그것부터 확인하러 가고, 여기서 할 일은
|
|
4262
|
+
* 경로를 고치는 것 하나다.
|
|
4263
|
+
*/
|
|
4264
|
+
function suiteFormatFailure(path) {
|
|
4265
|
+
const ext = extname(path);
|
|
4266
|
+
return {
|
|
4267
|
+
code: "SUITE_FORMAT_UNSUPPORTED",
|
|
4268
|
+
message: ext === "" ? `테스트 명세는 .json 파일이어야 합니다. 준 경로에 확장자가 없습니다: ${escapeTerminalText(path)}` : `테스트 명세는 .json 파일이어야 합니다. 준 확장자: ${escapeTerminalText(ext)}\n 경로: ${escapeTerminalText(path)}`,
|
|
4269
|
+
hint: "경로가 .json 으로 끝나는지 확인하세요. 이 단계는 파일을 열지 않고 확장자만 봅니다."
|
|
4270
|
+
};
|
|
4271
|
+
}
|
|
4272
|
+
function suiteEncodingFailure(path) {
|
|
4273
|
+
return {
|
|
4274
|
+
code: "SUITE_ENCODING_INVALID",
|
|
4275
|
+
message: `테스트 명세 파일이 유효한 UTF-8이 아닙니다: ${escapeTerminalText(path)}`,
|
|
4276
|
+
hint: dictionary.SUITE_ENCODING_INVALID.hint
|
|
4277
|
+
};
|
|
4278
|
+
}
|
|
4279
|
+
/**
|
|
4280
|
+
* JSON 문법 실패. **파서가 준 문장을 그대로 통과시킨다.** 위치 표기는 런타임마다 다르다 —
|
|
4281
|
+
* `at position 7 (line 1 column 8)` 을 주는 경우도 있고 깨진 자리의 스니펫만 주는 경우도 있다
|
|
4282
|
+
* (Node 25 실측). 우리가 형식을 고정하면 어느 한쪽에서 거짓이 되므로, 그 문장을 옮기고
|
|
4283
|
+
* 경로만 우리가 더한다. 파서 문장에는 사용자 파일 내용 조각이 섞이므로 이스케이프한다.
|
|
4284
|
+
*/
|
|
4285
|
+
function suiteJsonFailure(path, error) {
|
|
4286
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
4287
|
+
return {
|
|
4288
|
+
code: "SUITE_JSON_INVALID",
|
|
4289
|
+
message: `테스트 명세의 JSON 문법이 유효하지 않습니다: ${escapeTerminalText(path)}\n ${escapeTerminalText(reason)}`,
|
|
4290
|
+
hint: dictionary.SUITE_JSON_INVALID.hint
|
|
3949
4291
|
};
|
|
3950
4292
|
}
|
|
4293
|
+
/**
|
|
4294
|
+
* `message`·`hint` 는 **여기서 이스케이프하지 않는다**(#289). 그 필드는 우리가 여러 줄로
|
|
4295
|
+
* 공들여 쓴 안내와, 자식 프로세스·경로처럼 신뢰 못 할 값이 섞여 있다 — 어느 부분이 우리 글이고
|
|
4296
|
+
* 어느 부분이 남의 값인지는 필드를 다 만든 뒤인 여기서는 구분할 수 없다. 그래서 이스케이프는
|
|
4297
|
+
* **만드는 자리에서, 신뢰 못 할 값에만** 건다(`resetFailure`·`externalOpenFailure`·옵션 파싱의
|
|
4298
|
+
* `escapeTerminalText(token)` 호출들). 우리가 쓴 리터럴 개행은 그 자리를 거치지 않으므로
|
|
4299
|
+
* 구조를 유지한 채 여기까지 그대로 온다.
|
|
4300
|
+
*
|
|
4301
|
+
* `code`·`issue.*` 는 계속 통째로 건다. `code` 는 항상 우리 열거형이라 무해하고, `issue.*` 는
|
|
4302
|
+
* `validateSuite` 가 주는 데이터 레코드라 우리가 개행을 심을 자리가 아니다 — 한 줄 필드에
|
|
4303
|
+
* 통째로 걸어도 잃을 구조가 없다.
|
|
4304
|
+
*/
|
|
3951
4305
|
function format(failure) {
|
|
3952
4306
|
const code = failure.coreCode === void 0 ? failure.code : `${failure.code}/${failure.coreCode}`;
|
|
3953
|
-
let result = `오류 [${escapeTerminalText(code)}]: ${
|
|
4307
|
+
let result = `오류 [${escapeTerminalText(code)}]: ${failure.message}\n해결: ${failure.hint}`;
|
|
3954
4308
|
for (const issue of failure.issues ?? []) result += `\n- [${escapeTerminalText(issue.code)}] ${escapeTerminalText(issue.path)}: ${escapeTerminalText(issue.message)}\n 해결: ${escapeTerminalText(issue.hint)}`;
|
|
3955
4309
|
return `${result}\n`;
|
|
3956
4310
|
}
|
|
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
|
-
}
|
|
4311
|
+
/** AggregateError 내부까지 내려가 core 오류와 검증된 진단을 꺼낸다. */
|
|
3969
4312
|
function coreError(error) {
|
|
3970
4313
|
const seen = /* @__PURE__ */ new Set();
|
|
3971
4314
|
const visit = (value) => {
|
|
@@ -3974,7 +4317,8 @@ function coreError(error) {
|
|
|
3974
4317
|
code: value.code,
|
|
3975
4318
|
message: value.message,
|
|
3976
4319
|
hint: value.hint,
|
|
3977
|
-
diagnostics: processDiagnostics("diagnostics" in value ? value.diagnostics : void 0)
|
|
4320
|
+
diagnostics: processDiagnostics("diagnostics" in value ? value.diagnostics : void 0),
|
|
4321
|
+
httpDiagnostics: httpDiagnostics("diagnostics" in value ? value.diagnostics : void 0)
|
|
3978
4322
|
});
|
|
3979
4323
|
if (typeof value !== "object" || value === null || seen.has(value)) return void 0;
|
|
3980
4324
|
seen.add(value);
|
|
@@ -3985,30 +4329,60 @@ function coreError(error) {
|
|
|
3985
4329
|
};
|
|
3986
4330
|
return visit(error);
|
|
3987
4331
|
}
|
|
4332
|
+
/**
|
|
4333
|
+
* 보고서·케이스 상한 초과(#92). 서버 문제가 아니라 우리 상한이라 일반 종료 실패와 갈라야 한다.
|
|
4334
|
+
* 같은 문장으로 접으면 사용자는 서버 응답과 종료 상태를 확인하러 가서 아무것도 못 찾는다.
|
|
4335
|
+
*
|
|
4336
|
+
* 케이스 초과에 "케이스 수를 줄이라" 고 하면 안 된다. 넘긴 것은 그 케이스 하나이고 나머지를
|
|
4337
|
+
* 지워도 같은 오류가 그대로 난다. `dry-run.ts` 의 안내와 같은 구분이다.
|
|
4338
|
+
*/
|
|
4339
|
+
function payloadLimitFailure(error) {
|
|
4340
|
+
const kb = (bytes) => `${Math.round(bytes / 1024)}KB`;
|
|
4341
|
+
if (error.scope === "report") return {
|
|
4342
|
+
code: "RUNNER_PAYLOAD_LIMIT_EXCEEDED",
|
|
4343
|
+
message: `보고서가 ${kb(error.actualBytes)} 로 상한 ${kb(error.limitBytes)} 를 넘었습니다. 상한은 올릴 수 없습니다.`,
|
|
4344
|
+
hint: "툴을 나눠 여러 명세 파일로 만드세요. 응답이 큰 툴은 케이스 수를 줄이세요."
|
|
4345
|
+
};
|
|
4346
|
+
return {
|
|
4347
|
+
code: "RUNNER_PAYLOAD_LIMIT_EXCEEDED",
|
|
4348
|
+
message: `${error.caseId === void 0 ? "케이스" : `케이스 '${escapeTerminalText(error.caseId)}'`} 이 ${kb(error.actualBytes)} 로 상한 ${kb(error.limitBytes)} 를 넘었습니다. 상한은 올릴 수 없습니다.`,
|
|
4349
|
+
hint: "그 케이스의 이름·입력·단언을 줄이세요. 다른 케이스를 지워도 같은 오류가 납니다."
|
|
4350
|
+
};
|
|
4351
|
+
}
|
|
3988
4352
|
function writeFailure(dependencies, failure) {
|
|
3989
4353
|
dependencies.writeStderr(format(failure));
|
|
3990
4354
|
return 1;
|
|
3991
4355
|
}
|
|
3992
|
-
async function
|
|
4356
|
+
async function runCliCore(argv, dependencies, childEnv) {
|
|
3993
4357
|
if (argv.length === 0) return writeFailure(dependencies, {
|
|
3994
4358
|
code: "CLI_USAGE",
|
|
3995
4359
|
message: "실행할 CLI 명령이 없습니다.",
|
|
3996
|
-
hint:
|
|
4360
|
+
hint: commandDiscovery
|
|
3997
4361
|
});
|
|
3998
4362
|
if (argv[0] !== "test") {
|
|
4363
|
+
if (argv[0] === "verify") return writeFailure(dependencies, {
|
|
4364
|
+
code: "CLI_USAGE",
|
|
4365
|
+
message: "`mcpeak verify` 는 제거되었습니다. Tool 카세트와 함께 걷어냅니다(ADR-0059).",
|
|
4366
|
+
hint: "카세트 드리프트 확인이 목적이었다면 `mcpeak test` 로 실서버를 직접 검증하세요. 외부 API 호출을 막는 것이 목적이었다면 `mcpeak test <suite.json> --command <executable> --record-session <path>` 로 먼저 녹화한 뒤 `--session <path>` 로 재생하세요. 두 옵션은 함께 쓸 수 없습니다."
|
|
4367
|
+
});
|
|
4368
|
+
if (argv[0] === "replay") return writeFailure(dependencies, {
|
|
4369
|
+
code: "CLI_USAGE",
|
|
4370
|
+
message: "`mcpeak replay` 는 제거되었습니다. Tool 카세트와 함께 걷어냅니다(ADR-0059).",
|
|
4371
|
+
hint: "서버를 띄우지 않고 저장된 응답으로 스위트를 돌리는 것이 목적이었다면 `mcpeak-mock` 으로 서버를 대신하세요. 외부 API 호출만 막는 것이 목적이었다면 `mcpeak test <suite.json> --command <executable> --record-session <path>` 로 먼저 녹화한 뒤 `--session <path>` 로 재생하세요."
|
|
4372
|
+
});
|
|
3999
4373
|
if ([
|
|
4000
4374
|
"generate",
|
|
4001
4375
|
"record",
|
|
4002
4376
|
"mock"
|
|
4003
4377
|
].includes(argv[0] ?? "")) return writeFailure(dependencies, {
|
|
4004
4378
|
code: "COMMAND_NOT_IMPLEMENTED",
|
|
4005
|
-
message: `'${argv[0]}' 명령은 아직 구현되지 않았습니다.`,
|
|
4006
|
-
hint:
|
|
4379
|
+
message: `'${escapeTerminalText(argv[0] ?? "")}' 명령은 아직 구현되지 않았습니다.`,
|
|
4380
|
+
hint: commandDiscovery
|
|
4007
4381
|
});
|
|
4008
4382
|
return writeFailure(dependencies, {
|
|
4009
4383
|
code: "CLI_USAGE",
|
|
4010
|
-
message: `알 수 없는 CLI 명령 '${argv[0]}'입니다.`,
|
|
4011
|
-
hint:
|
|
4384
|
+
message: `알 수 없는 CLI 명령 '${escapeTerminalText(argv[0] ?? "")}'입니다.`,
|
|
4385
|
+
hint: commandDiscovery
|
|
4012
4386
|
});
|
|
4013
4387
|
}
|
|
4014
4388
|
let input;
|
|
@@ -4020,36 +4394,24 @@ async function runCli(argv, dependencies) {
|
|
|
4020
4394
|
...dictionary.CLI_INTERNAL_ERROR
|
|
4021
4395
|
});
|
|
4022
4396
|
}
|
|
4023
|
-
if (extname(input.suitePath).toLowerCase() !== ".json") return writeFailure(dependencies,
|
|
4024
|
-
code: "SUITE_FORMAT_UNSUPPORTED",
|
|
4025
|
-
...dictionary.SUITE_FORMAT_UNSUPPORTED
|
|
4026
|
-
});
|
|
4397
|
+
if (extname(input.suitePath).toLowerCase() !== ".json") return writeFailure(dependencies, suiteFormatFailure(input.suitePath));
|
|
4027
4398
|
let bytes;
|
|
4028
4399
|
try {
|
|
4029
4400
|
bytes = await dependencies.readFile(input.suitePath);
|
|
4030
|
-
} catch {
|
|
4031
|
-
return writeFailure(dependencies,
|
|
4032
|
-
code: "SUITE_READ_FAILED",
|
|
4033
|
-
...dictionary.SUITE_READ_FAILED
|
|
4034
|
-
});
|
|
4401
|
+
} catch (error) {
|
|
4402
|
+
return writeFailure(dependencies, suiteReadFailure(input.suitePath, error));
|
|
4035
4403
|
}
|
|
4036
4404
|
let decoded;
|
|
4037
4405
|
try {
|
|
4038
4406
|
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
4039
4407
|
} catch {
|
|
4040
|
-
return writeFailure(dependencies,
|
|
4041
|
-
code: "SUITE_ENCODING_INVALID",
|
|
4042
|
-
...dictionary.SUITE_ENCODING_INVALID
|
|
4043
|
-
});
|
|
4408
|
+
return writeFailure(dependencies, suiteEncodingFailure(input.suitePath));
|
|
4044
4409
|
}
|
|
4045
4410
|
let parsed;
|
|
4046
4411
|
try {
|
|
4047
4412
|
parsed = JSON.parse(decoded);
|
|
4048
|
-
} catch {
|
|
4049
|
-
return writeFailure(dependencies,
|
|
4050
|
-
code: "SUITE_JSON_INVALID",
|
|
4051
|
-
...dictionary.SUITE_JSON_INVALID
|
|
4052
|
-
});
|
|
4413
|
+
} catch (error) {
|
|
4414
|
+
return writeFailure(dependencies, suiteJsonFailure(input.suitePath, error));
|
|
4053
4415
|
}
|
|
4054
4416
|
let validated;
|
|
4055
4417
|
try {
|
|
@@ -4084,21 +4446,32 @@ async function runCli(argv, dependencies) {
|
|
|
4084
4446
|
...dictionary.CLI_INTERNAL_ERROR
|
|
4085
4447
|
});
|
|
4086
4448
|
}
|
|
4449
|
+
/**
|
|
4450
|
+
* 두 transport 의 연결 주입점을 한 묶음으로 만든다. `--determinism` 의 2회차도 같은 것을
|
|
4451
|
+
* 쓴다 — 회차마다 다른 대상에 붙으면 비교가 뜻을 잃는다.
|
|
4452
|
+
*/
|
|
4453
|
+
const connectDependencies = {
|
|
4454
|
+
connectStdio: dependencies.connect,
|
|
4455
|
+
...dependencies.connectHttp === void 0 ? {} : { connectHttp: dependencies.connectHttp },
|
|
4456
|
+
...dependencies.readEnv === void 0 ? {} : { readEnv: dependencies.readEnv }
|
|
4457
|
+
};
|
|
4087
4458
|
let connection;
|
|
4088
4459
|
try {
|
|
4089
|
-
connection = await
|
|
4090
|
-
command: input.command,
|
|
4091
|
-
args: input.args
|
|
4092
|
-
});
|
|
4460
|
+
connection = await openConnection(input.target, connectDependencies, childEnv === void 0 ? void 0 : { env: childEnv });
|
|
4093
4461
|
} catch (error) {
|
|
4462
|
+
if (error instanceof ConnectTargetError) return writeFailure(dependencies, {
|
|
4463
|
+
code: "CLI_USAGE",
|
|
4464
|
+
message: error.message,
|
|
4465
|
+
hint: TEST_USAGE_HINT
|
|
4466
|
+
});
|
|
4094
4467
|
const core = coreError(error);
|
|
4095
4468
|
const failed = writeFailure(dependencies, core === void 0 ? {
|
|
4096
4469
|
code: "MCP_CONNECTION_FAILED",
|
|
4097
4470
|
...dictionary.MCP_CONNECTION_FAILED
|
|
4098
4471
|
} : {
|
|
4099
4472
|
code: "MCP_CONNECTION_FAILED",
|
|
4100
|
-
message: core.message,
|
|
4101
|
-
hint: core.hint,
|
|
4473
|
+
message: escapeTerminalText(core.message),
|
|
4474
|
+
hint: escapeTerminalText(core.hint),
|
|
4102
4475
|
coreCode: core.code
|
|
4103
4476
|
});
|
|
4104
4477
|
const diagnostics = core?.diagnostics;
|
|
@@ -4106,14 +4479,30 @@ async function runCli(argv, dependencies) {
|
|
|
4106
4479
|
const block = renderProcessDiagnostics(diagnostics, { maxLines: input.stderrLines });
|
|
4107
4480
|
if (block !== "") dependencies.writeStderr(`\n${block}`);
|
|
4108
4481
|
}
|
|
4482
|
+
/**
|
|
4483
|
+
* 원격 대상의 진단. `--stderr-lines` 로 억제하지 않는다 — 파서가 그 옵션과 `--url` 의
|
|
4484
|
+
* 동시 사용을 막으므로 값이 항상 기본값이라 조건이 뜻을 잃는다.
|
|
4485
|
+
*
|
|
4486
|
+
* 내용 판정(`hasHttpDiagnosticContent`)도 걸지 않는다. 연결이 실패한 자리에서는 상태
|
|
4487
|
+
* 코드가 없어도(DNS 실패, 연결 거부) **어느 엔드포인트에 붙으려다 실패했는지**가 정보다.
|
|
4488
|
+
*/
|
|
4489
|
+
const remote = core?.httpDiagnostics;
|
|
4490
|
+
if (remote !== void 0) dependencies.writeStderr(`\n${renderHttpDiagnostics(remote)}`);
|
|
4109
4491
|
return failed;
|
|
4110
4492
|
}
|
|
4111
4493
|
/** 진단 출력 실패가 판정을 바꾸면 안 된다. getDiagnostics 가 던지면 삼킨다. §4.3.1. */
|
|
4112
4494
|
const snapshotDiagnostics = () => {
|
|
4113
4495
|
try {
|
|
4114
|
-
|
|
4496
|
+
const raw = connection.getDiagnostics();
|
|
4497
|
+
return {
|
|
4498
|
+
value: processDiagnostics(raw),
|
|
4499
|
+
remote: httpDiagnostics(raw)
|
|
4500
|
+
};
|
|
4115
4501
|
} catch {
|
|
4116
|
-
return {
|
|
4502
|
+
return {
|
|
4503
|
+
value: void 0,
|
|
4504
|
+
remote: void 0
|
|
4505
|
+
};
|
|
4117
4506
|
}
|
|
4118
4507
|
};
|
|
4119
4508
|
/**
|
|
@@ -4123,8 +4512,21 @@ async function runCli(argv, dependencies) {
|
|
|
4123
4512
|
* 않기 위해서다(설계 문서 §4.3).
|
|
4124
4513
|
*/
|
|
4125
4514
|
const writeDiagnostics = (snapshot) => {
|
|
4515
|
+
const taken = snapshot ?? snapshotDiagnostics();
|
|
4516
|
+
/**
|
|
4517
|
+
* 원격 대상. `--stderr-lines` 로 억제하지 않는다 — stdio 전용 옵션이고 파서가 `--url`
|
|
4518
|
+
* 과의 동시 사용을 막으므로 여기서는 조건이 뜻을 잃는다.
|
|
4519
|
+
*
|
|
4520
|
+
* 다만 내용 판정은 **건다.** 연결 실패 경로와 달리 여기는 실행이 끝난 뒤라, core 가
|
|
4521
|
+
* 상태 코드를 채우지 않은 성공한 실행에서도 불린다. 판정 없이 그리면 초록불 뒤에 매번
|
|
4522
|
+
* 빈 진단 블록이 붙는다.
|
|
4523
|
+
*/
|
|
4524
|
+
if (taken.remote !== void 0) {
|
|
4525
|
+
if (hasHttpDiagnosticContent(taken.remote)) dependencies.writeStderr(`\n${renderHttpDiagnostics(taken.remote)}`);
|
|
4526
|
+
return;
|
|
4527
|
+
}
|
|
4126
4528
|
if (input.stderrLines === 0) return;
|
|
4127
|
-
const diagnostics =
|
|
4529
|
+
const diagnostics = taken.value;
|
|
4128
4530
|
if (diagnostics === void 0) return;
|
|
4129
4531
|
if (!hasDiagnosticContent(diagnostics)) return;
|
|
4130
4532
|
const block = renderProcessDiagnostics(diagnostics, { maxLines: input.stderrLines });
|
|
@@ -4188,8 +4590,8 @@ async function runCli(argv, dependencies) {
|
|
|
4188
4590
|
execution,
|
|
4189
4591
|
shutdown
|
|
4190
4592
|
});
|
|
4191
|
-
} catch {
|
|
4192
|
-
const failed = writeFailure(dependencies, {
|
|
4593
|
+
} catch (error) {
|
|
4594
|
+
const failed = writeFailure(dependencies, error instanceof RunnerPayloadLimitError ? payloadLimitFailure(error) : {
|
|
4193
4595
|
code: "RUNNER_FINALIZATION_FAILED",
|
|
4194
4596
|
...dictionary.RUNNER_FINALIZATION_FAILED
|
|
4195
4597
|
});
|
|
@@ -4202,6 +4604,7 @@ async function runCli(argv, dependencies) {
|
|
|
4202
4604
|
* 있는데, 그때 사용자가 `--junit` 으로 명시적으로 요청한 산출물까지 함께 잃을 이유가 없다.
|
|
4203
4605
|
* `--junit` 을 주지 않으면 이 블록을 통째로 건너뛰므로 기존 순서와 동일하다. ADR-0019.
|
|
4204
4606
|
*/
|
|
4607
|
+
let deferredJunitFailure;
|
|
4205
4608
|
if (input.junitPath !== void 0) {
|
|
4206
4609
|
let xml;
|
|
4207
4610
|
try {
|
|
@@ -4214,11 +4617,8 @@ async function runCli(argv, dependencies) {
|
|
|
4214
4617
|
}
|
|
4215
4618
|
try {
|
|
4216
4619
|
await dependencies.writeFile(input.junitPath, xml);
|
|
4217
|
-
} catch {
|
|
4218
|
-
|
|
4219
|
-
code: "JUNIT_WRITE_FAILED",
|
|
4220
|
-
...dictionary.JUNIT_WRITE_FAILED
|
|
4221
|
-
});
|
|
4620
|
+
} catch (error) {
|
|
4621
|
+
deferredJunitFailure = junitWriteFailure(input.junitPath, error);
|
|
4222
4622
|
}
|
|
4223
4623
|
}
|
|
4224
4624
|
/**
|
|
@@ -4234,7 +4634,7 @@ async function runCli(argv, dependencies) {
|
|
|
4234
4634
|
if (firstCapture === void 0) return void 0;
|
|
4235
4635
|
const snapshotOf = (target) => {
|
|
4236
4636
|
try {
|
|
4237
|
-
return target.getDiagnostics();
|
|
4637
|
+
return processDiagnostics(target.getDiagnostics());
|
|
4238
4638
|
} catch {
|
|
4239
4639
|
return;
|
|
4240
4640
|
}
|
|
@@ -4249,10 +4649,7 @@ async function runCli(argv, dependencies) {
|
|
|
4249
4649
|
}
|
|
4250
4650
|
let second;
|
|
4251
4651
|
try {
|
|
4252
|
-
second = await
|
|
4253
|
-
command: input.command,
|
|
4254
|
-
args: input.args
|
|
4255
|
-
});
|
|
4652
|
+
second = await openConnection(input.target, connectDependencies, childEnv === void 0 ? void 0 : { env: childEnv });
|
|
4256
4653
|
} catch (error) {
|
|
4257
4654
|
const core = coreError(error);
|
|
4258
4655
|
return {
|
|
@@ -4326,7 +4723,8 @@ async function runCli(argv, dependencies) {
|
|
|
4326
4723
|
/**
|
|
4327
4724
|
* 툴 목록이 비면 입력 계약 대조는 건너뛴다. 목록이 비었을 때 대조하면 모든 케이스가
|
|
4328
4725
|
* `TOOL_NOT_DECLARED` 로 걸려 실패 원인과 무관한 줄만 늘어난다. 단언 실질성은 툴이 필요
|
|
4329
|
-
* 없으므로 항상 돈다.
|
|
4726
|
+
* 없으므로 항상 돈다. 위반 참고는 실패한 케이스에만 표시하지만, 검사를 건너뛴 사실은
|
|
4727
|
+
* 통과 케이스에서도 보존한다. 설계 문서 §7.
|
|
4330
4728
|
*
|
|
4331
4729
|
* 검사가 던져도 판정과 exit code 는 바뀌지 않아야 하므로 삼킨다. `validated.value` 는 이미
|
|
4332
4730
|
* `validateMcpSuite` 를 통과했으니 도달할 일이 없는 경로이고, 도달했다면 그것은 비차단
|
|
@@ -4334,14 +4732,18 @@ async function runCli(argv, dependencies) {
|
|
|
4334
4732
|
*/
|
|
4335
4733
|
const specFindings = (() => {
|
|
4336
4734
|
/**
|
|
4337
|
-
*
|
|
4735
|
+
* 케이스 버킷을 보고서의 케이스 순서로 먼저 만든다. 두 검사 결과를 이어 붙인
|
|
4338
4736
|
* 뒤에 `caseId` 로 묶으면 앞 케이스에 단언 finding 만 있고 뒤 케이스에 입력 계약 finding
|
|
4339
4737
|
* 이 있을 때 뒤 케이스가 먼저 들어와 순서가 뒤집힌다. 케이스 사이 순서는 검사 종류가
|
|
4340
4738
|
* 아니라 보고서가 정한다. 한 케이스 안의 블록 순서는 `FINDING_GROUP_ORDER` 가 맡는다.
|
|
4341
4739
|
* 없는 키를 만들지 않으므로 버킷에 없는 caseId 는 그대로 걸러진다.
|
|
4342
4740
|
*/
|
|
4343
4741
|
const buckets = /* @__PURE__ */ new Map();
|
|
4344
|
-
|
|
4742
|
+
const statuses = /* @__PURE__ */ new Map();
|
|
4743
|
+
for (const item of finalReport.cases) {
|
|
4744
|
+
buckets.set(item.spec.id, []);
|
|
4745
|
+
statuses.set(item.spec.id, item.status);
|
|
4746
|
+
}
|
|
4345
4747
|
try {
|
|
4346
4748
|
const inputContract = dependencies.checkInputContract ?? checkInputContract;
|
|
4347
4749
|
const assertionSubstance = dependencies.checkAssertionSubstance ?? checkAssertionSubstance;
|
|
@@ -4349,7 +4751,10 @@ async function runCli(argv, dependencies) {
|
|
|
4349
4751
|
suite: validated.value,
|
|
4350
4752
|
tools
|
|
4351
4753
|
}).findings, ...assertionSubstance(validated.value).findings];
|
|
4352
|
-
for (const finding of found)
|
|
4754
|
+
for (const finding of found) {
|
|
4755
|
+
if (statuses.get(finding.caseId) === "passed" && FINDING_GROUP[finding.code] !== "skipped") continue;
|
|
4756
|
+
buckets.get(finding.caseId)?.push(finding);
|
|
4757
|
+
}
|
|
4353
4758
|
return [...buckets.values()].flat();
|
|
4354
4759
|
} catch {
|
|
4355
4760
|
return [];
|
|
@@ -4428,18 +4833,18 @@ async function runCli(argv, dependencies) {
|
|
|
4428
4833
|
* `byCase` 에 없어서 이 목록으로 순회해야 빠지지 않는다.
|
|
4429
4834
|
*/
|
|
4430
4835
|
for (const item of finalReport.cases) {
|
|
4431
|
-
if (item.status === "passed") continue;
|
|
4432
4836
|
const caseId = item.spec.id;
|
|
4433
|
-
const
|
|
4837
|
+
const allFindings = byCase.get(caseId) ?? [];
|
|
4838
|
+
const list = item.status === "passed" ? allFindings.filter((finding) => FINDING_GROUP[finding.code] === "skipped") : allFindings;
|
|
4434
4839
|
for (const group of FINDING_GROUP_ORDER) {
|
|
4435
|
-
const grouped = list.filter((finding) => FINDING_GROUP[finding.code] === group);
|
|
4840
|
+
const grouped = list.filter((finding) => FINDING_GROUP[finding.code] === group && !(group === "inputContract" && responseRepeatsTypeMismatch(finding, item)));
|
|
4436
4841
|
if (grouped.length === 0) continue;
|
|
4437
4842
|
dependencies.writeStdout(`\n${FINDING_HEADING[group](escapeTerminalText(caseId))}\n${grouped.map((finding) => ` → ${describeSpecFinding(finding)}\n`).join("")}`);
|
|
4438
4843
|
}
|
|
4439
4844
|
if (serverDefectCases.has(caseId)) dependencies.writeStdout(`\n${SERVER_DEFECT_NOTE_LINE}`);
|
|
4440
4845
|
}
|
|
4441
4846
|
}
|
|
4442
|
-
if (shouldShowSpecApproval(specApproval, allPassed)) dependencies.writeStdout(`\n${renderSpecApproval(specApproval)}`);
|
|
4847
|
+
if (shouldShowSpecApproval(specApproval, allPassed)) dependencies.writeStdout(`\n${renderSpecApproval(specApproval, allPassed)}`);
|
|
4443
4848
|
/**
|
|
4444
4849
|
* 결정론성 블록은 보고서 뒤, 다른 블록과 같은 레이아웃 규칙(앞에 빈 줄)을 따른다.
|
|
4445
4850
|
* `--determinism` 없이는 한 줄도 찍지 않는다. 설계 문서 §8.
|
|
@@ -4470,6 +4875,7 @@ async function runCli(argv, dependencies) {
|
|
|
4470
4875
|
* 상태를 서버 탓으로 적게 된다. `--repair-bundle` 을 주지 않으면 이 블록을 통째로 건너뛰므로
|
|
4471
4876
|
* 기존 경로의 출력과 종료 코드가 그대로다. 계획서 완료 조건 2.
|
|
4472
4877
|
*/
|
|
4878
|
+
let deferredRepairBundleFailure;
|
|
4473
4879
|
if (input.repairBundlePath !== void 0) {
|
|
4474
4880
|
const bundle = buildRepairBundle({
|
|
4475
4881
|
report: finalReport,
|
|
@@ -4480,14 +4886,330 @@ async function runCli(argv, dependencies) {
|
|
|
4480
4886
|
if (bundle === void 0) dependencies.writeStdout(`\n${REPAIR_BUNDLE_EMPTY_LINE}`);
|
|
4481
4887
|
else try {
|
|
4482
4888
|
await dependencies.writeFile(input.repairBundlePath, serializeRepairBundle(bundle));
|
|
4483
|
-
} catch {
|
|
4484
|
-
|
|
4485
|
-
code: "REPAIR_BUNDLE_WRITE_FAILED",
|
|
4486
|
-
...dictionary.REPAIR_BUNDLE_WRITE_FAILED
|
|
4487
|
-
});
|
|
4889
|
+
} catch (error) {
|
|
4890
|
+
deferredRepairBundleFailure = repairBundleWriteFailure(input.repairBundlePath, error);
|
|
4488
4891
|
}
|
|
4489
4892
|
}
|
|
4893
|
+
for (const failure of [deferredJunitFailure, deferredRepairBundleFailure]) if (failure !== void 0) writeFailure(dependencies, failure);
|
|
4894
|
+
if (deferredJunitFailure !== void 0 || deferredRepairBundleFailure !== void 0) return 1;
|
|
4490
4895
|
return allPassed ? 0 : 1;
|
|
4491
4896
|
}
|
|
4897
|
+
/**
|
|
4898
|
+
* 재생 원본에서 찾지 못한 호출들을 그린다. `misses` 가 비어 있으면 빈 문자열이다.
|
|
4899
|
+
*
|
|
4900
|
+
* **MCP 오류 채널을 타지 않는다(#259).** `record` 의 `REPLAY_MISS` 진단은 실패한 툴 호출의
|
|
4901
|
+
* MCP 오류 메시지로도 나가는데, 그 채널은 `runner` 가 "테스트 대상 서버가 보낸 텍스트"로
|
|
4902
|
+
* 취급해 이스케이프(개행 포함)와 200자 절단을 건다 — 우리 자신이 여러 줄로 공들여 쓴 진단이
|
|
4903
|
+
* 서버 텍스트와 똑같이 망가진다. 이 함수는 `record` 가 `finish()` 요약에 구조화해 담아 준
|
|
4904
|
+
* 값을 CLI 가 직접 stderr 로 쓴다. `runner` 도 그 이스케이프 규칙도 거치지 않는다.
|
|
4905
|
+
*
|
|
4906
|
+
* `method`·`url`·`matchKeyPrefix` 는 값 자체는 `record` 가 만들지만 원본은 테스트 대상
|
|
4907
|
+
* 서버가 시도한 요청이라, 혹시 모를 제어 문자에 대비해 필드 단위로 이스케이프한다(줄 구조를
|
|
4908
|
+
* 만드는 정적 문구는 이스케이프 대상이 아니다 — `process-diagnostics.ts` 와 같은 원칙).
|
|
4909
|
+
*/
|
|
4910
|
+
function renderReplayMissDiagnostics(misses) {
|
|
4911
|
+
if (misses.length === 0) return "";
|
|
4912
|
+
const body = misses.map((miss) => ` ${escapeTerminalText(miss.method)} ${escapeTerminalText(miss.url)}\n occurrence ${miss.occurrence} · matchKey ${escapeTerminalText(miss.matchKeyPrefix)}…\n`).join("");
|
|
4913
|
+
return `\nExternal 진단: 재생 원본에서 찾지 못한 호출 ${misses.length}건\n` + body + "→ 이 호출이 녹화된 뒤에 추가되었거나, 요청이 녹화 때와 달라져 다른 matchKey가 되었습니다.\n→ 녹화를 다시 하거나, 요청이 실행마다 달라지는 값을 담고 있는지 확인하세요.\n";
|
|
4914
|
+
}
|
|
4915
|
+
/**
|
|
4916
|
+
* External 세션 요약을 사용자에게 보이는 한 문단으로 옮긴다. `undefined` 는 할 말이 없다는 뜻이다.
|
|
4917
|
+
*
|
|
4918
|
+
* **순서가 곧 계약이다**(ADR-0057). `consumedCount === 0` 과 `unusedCount > 0` 은 동시에 참일 수
|
|
4919
|
+
* 있어서, 조건을 각각 독립적으로 세우면 한 실행에 경고가 두 번 찍힌다. 아래 순서로 먼저 걸리는
|
|
4920
|
+
* 하나만 낸다.
|
|
4921
|
+
*
|
|
4922
|
+
* 갈래를 넷으로 나눈 이유는 **사용자가 다음에 볼 곳이 다르기 때문이다.** 원본이 비어 있는 것
|
|
4923
|
+
* (녹화 단계가 아무것도 못 잡았다)과 원본은 찼는데 하나도 안 쓴 것(세션 파일을 잘못 짚었거나
|
|
4924
|
+
* 서버의 호출 방식이 바뀌었다)은 같은 `consumedCount === 0` 이지만 원인이 정반대 방향에 있다.
|
|
4925
|
+
* 하나로 합치면 [#258](https://github.com/2026-Engineering-Contest/MCPeak/issues/258) 의 실제
|
|
4926
|
+
* 경로에서 "녹화된 호출이 재생되지 않았다" 고 말하게 되는데, 그 문장은 녹화가 있었다는 전제를
|
|
4927
|
+
* 깔아 사용자를 재생 쪽으로 보낸다. 정작 볼 곳은 그 앞 단계다.
|
|
4928
|
+
*
|
|
4929
|
+
* 판정만 하고 쓰기는 하지 않는다 — 순수 함수라 배타성 자체를 프로세스 없이 고정할 수 있다.
|
|
4930
|
+
*/
|
|
4931
|
+
/**
|
|
4932
|
+
* External 세션을 열지 못한 실패를 사용자 문장으로 옮긴다(#260, #290).
|
|
4933
|
+
*
|
|
4934
|
+
* **원인마다 다음에 할 일이 다르므로 갈래마다 다른 문장을 쓴다.** 한때 이 자리가 문장 하나로
|
|
4935
|
+
* 모든 실패를 덮었고, 그래서 경로를 잘못 친 사람에게 "완료된 Replay 원본이 아닙니다" 와
|
|
4936
|
+
* "쓰기 권한을 확인하세요" 를 동시에 말했다 — 앞은 손상된 세션의 문안이고 뒤는 녹화의 문안이라
|
|
4937
|
+
* 재생 실패에는 둘 다 맞지 않았다.
|
|
4938
|
+
*
|
|
4939
|
+
* **경로를 `message` 에 싣는다.** 세션 id 는 넣지 않는다 — 사용자가 준 적 없는 `"default"`
|
|
4940
|
+
* 대신 사용자가 아는 경로를 보여준다.
|
|
4941
|
+
*
|
|
4942
|
+
* `path`·`detail` 은 우리 문장이 아니다. `path` 는 사용자가 CLI 에 그대로 타이핑한 값이고
|
|
4943
|
+
* `detail` 은 record 가 던진 오류의 원문(SQLite 원문을 포함할 수 있다)이라, 둘 다 화면을
|
|
4944
|
+
* 깨뜨릴 값이 실릴 수 있어 이스케이프한다(#289) — `message` 안에서 그 부분만 걸고, `format()`
|
|
4945
|
+
* 은 이제 필드 전체를 다시 이스케이프하지 않는다.
|
|
4946
|
+
*/
|
|
4947
|
+
function externalOpenFailure(mode, path, error) {
|
|
4948
|
+
const code = error?.code;
|
|
4949
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
4950
|
+
const shownPath = escapeTerminalText(path);
|
|
4951
|
+
if (error instanceof SessionFileMissingError) return {
|
|
4952
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4953
|
+
message: `세션 파일을 찾을 수 없습니다: ${shownPath}`,
|
|
4954
|
+
hint: "경로를 확인하거나, `--record-session <path>` 로 먼저 녹화하세요."
|
|
4955
|
+
};
|
|
4956
|
+
if (code === "SESSION_NOT_FOUND") return {
|
|
4957
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4958
|
+
message: `세션 파일에 녹화된 외부 호출이 없습니다: ${shownPath}`,
|
|
4959
|
+
hint: "`--record-session` 으로 다시 녹화하세요. 빈 세션으로는 아무 호출도 막지 못합니다."
|
|
4960
|
+
};
|
|
4961
|
+
if (code === "REPLAY_SOURCE_INVALID") return {
|
|
4962
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4963
|
+
message: `녹화가 완료되지 않은 세션입니다: ${shownPath}`,
|
|
4964
|
+
hint: "녹화 실행이 실패했을 수 있습니다. `--record-session` 으로 다시 녹화하세요."
|
|
4965
|
+
};
|
|
4966
|
+
if (code === "SCHEMA_VERSION_UNSUPPORTED") return {
|
|
4967
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4968
|
+
message: `이 버전의 mcpeak 이 지원하지 않는 세션 파일입니다: ${shownPath}`,
|
|
4969
|
+
hint: "이 버전의 mcpeak 으로 다시 녹화하세요."
|
|
4970
|
+
};
|
|
4971
|
+
if (code === "SESSION_ALREADY_EXISTS") return {
|
|
4972
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4973
|
+
message: `세션 파일에 이미 녹화가 있습니다: ${shownPath}`,
|
|
4974
|
+
hint: "기존 녹화를 덮어쓰지 않습니다. 다른 `--record-session` 경로를 쓰거나, 다시 녹화하려면 그 파일을 지우세요."
|
|
4975
|
+
};
|
|
4976
|
+
return {
|
|
4977
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4978
|
+
message: `External 세션을 열지 못했습니다: ${shownPath} (${escapeTerminalText(detail)})`,
|
|
4979
|
+
hint: mode === "replay" ? "경로가 맞는지와 그 파일을 읽을 수 있는지 확인하세요." : "경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요."
|
|
4980
|
+
};
|
|
4981
|
+
}
|
|
4982
|
+
/**
|
|
4983
|
+
* `wiring.finish()` 가 세션을 닫는 데 실패했을 때(#289). record 의 store `finish()` 는 코드
|
|
4984
|
+
* 둘만 던진다 — `SESSION_NOT_RUNNING`(한 줄) 과 `INCOMPLETE_SESSION`(record 가 여러 줄로
|
|
4985
|
+
* 쓴, 이미 마스킹된 안전한 진단. `display` 는 ADR-0053 이 마스킹을 보장한다).
|
|
4986
|
+
*
|
|
4987
|
+
* `INCOMPLETE_SESSION` 은 그대로 둬야 구조가 산다. 그 밖은 한 줄이라 이스케이프해도 잃을
|
|
4988
|
+
* 구조가 없고, 어떤 값이 올지 모르는 자리이므로 기본은 이스케이프한다.
|
|
4989
|
+
*/
|
|
4990
|
+
function externalCloseFailure(error) {
|
|
4991
|
+
const code = error?.code;
|
|
4992
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
4993
|
+
return {
|
|
4994
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4995
|
+
message: "External 세션을 닫지 못했습니다.",
|
|
4996
|
+
hint: code === "INCOMPLETE_SESSION" ? detail : escapeTerminalText(detail)
|
|
4997
|
+
};
|
|
4998
|
+
}
|
|
4999
|
+
/** 같은 단서의 재생 판. 남지 않는 것이 아니라 **실제 네트워크로 나가는** 것이 요점이다. */
|
|
5000
|
+
const PARTIAL_REPLAY_NOTE = " 이 수는 어댑터가 잡은 호출만 셉니다. 범위 밖 호출은 재생 중에도 실제 네트워크로 나갑니다.\n";
|
|
5001
|
+
/**
|
|
5002
|
+
* 재생 중 범위 밖으로 나간 호출을 **사실로** 알린다(ADR-0068).
|
|
5003
|
+
*
|
|
5004
|
+
* `externalSessionOutcome` 과 축이 다르므로 따로 낸다 — 그쪽은 "무엇을 했는가", 이쪽은 "그
|
|
5005
|
+
* 실행이 재현 가능한가" 다. 같은 실행에서 둘 다 나간다(ADR-0062 의 `bodyUrlNotice` 와 같은
|
|
5006
|
+
* 이유).
|
|
5007
|
+
*
|
|
5008
|
+
* **세 갈래를 정확히 구분한다.** `undefined`(못 셌음)에서 침묵하는 것이 핵심이다 — 거기서
|
|
5009
|
+
* "0건" 이라고 말하면 이 기능이 없애려던 거짓 안심을 그대로 되살린다. 그 경우의 방어는
|
|
5010
|
+
* `PARTIAL_REPLAY_NOTE` 조건절이 계속 맡는다.
|
|
5011
|
+
*/
|
|
5012
|
+
function outOfScopeNotice(summary) {
|
|
5013
|
+
if (summary.mode !== "replay") return void 0;
|
|
5014
|
+
if (summary.outOfScope === void 0) return void 0;
|
|
5015
|
+
if (summary.outOfScope === 0) return void 0;
|
|
5016
|
+
return `\n경고: 범위 밖 호출 ${summary.outOfScope}건이 실제 네트워크로 나갔습니다.\n→ 이 실행은 재현 가능하지 않습니다. 그 호출의 응답은 녹화본이 아니라 오늘의 것입니다.
|
|
5017
|
+
→ 서버가 \`node:http\`·axios 등으로 부른 호출입니다. 재생하려면 \`fetch\` 로 바꾸거나,
|
|
5018
|
+
그 외부 의존을 \`mcpeak mock\` 으로 대신하세요.
|
|
5019
|
+
`;
|
|
5020
|
+
}
|
|
5021
|
+
/**
|
|
5022
|
+
* 조건절은 **모를 때만** 단다. 세었으면 개수가 그 자리를 대신한다.
|
|
5023
|
+
*
|
|
5024
|
+
* 0 건이면 붙일 이유가 없고, **N 건이어도 붙이지 않는다** — `outOfScopeNotice` 가 "N건이 실제
|
|
5025
|
+
* 네트워크로 나갔습니다" 를 이미 사실로 말하는데 그 위에 "나갈 수 있습니다" 를 얹으면 같은
|
|
5026
|
+
* 말을 두 번 하는 것이고, 조건절이 사실보다 먼저 읽혀 경고를 흐린다. 화면에서 실제로 그렇게
|
|
5027
|
+
* 나오는 것을 보고 고쳤다.
|
|
5028
|
+
*/
|
|
5029
|
+
function replayCaveat(summary) {
|
|
5030
|
+
if (summary.mode === "replay" && summary.outOfScope !== void 0) return "";
|
|
5031
|
+
return PARTIAL_REPLAY_NOTE;
|
|
5032
|
+
}
|
|
5033
|
+
/**
|
|
5034
|
+
* 이 실행이 External 세션으로 **무엇을 했는지** 한 줄로 말한다(ADR-0066).
|
|
5035
|
+
*
|
|
5036
|
+
* 성공한 실행이 아무 말도 하지 않으면, 사용자가 방금 본 리포트가 실제 네트워크를 탄 결과인지
|
|
5037
|
+
* 녹화본을 재생한 결과인지 구분할 방법이 없다. 판정이 같아도 그 판정의 근거는 다른 사실이다.
|
|
5038
|
+
*
|
|
5039
|
+
* **`externalSessionNotice` 와 정확히 배타다.** 그쪽이 말하는 갈래에서 여기는 침묵한다 — 둘 다
|
|
5040
|
+
* 나오면 같은 사실을 두 번 말하고, 둘 다 침묵하면 이 함수를 만든 이유가 그 갈래에서 사라진다.
|
|
5041
|
+
* 그 배타성은 이 주석이 아니라 `external-session.test.ts` 의 매트릭스가 지킨다.
|
|
5042
|
+
*
|
|
5043
|
+
* **`EXTERNAL_SCOPE_NOTE` 전문은 여기서 반복하지 않는다.** 경고 갈래마다 이미 나오고, 정상
|
|
5044
|
+
* 경로에서까지 매번 붙이면 그 문장이 읽히지 않게 된다. 대신 아래 한 줄짜리 단서만 붙인다.
|
|
5045
|
+
*/
|
|
5046
|
+
function externalSessionOutcome(summary, sessionPath) {
|
|
5047
|
+
const shownPath = escapeTerminalText(sessionPath);
|
|
5048
|
+
if (summary.mode === "record") {
|
|
5049
|
+
if (summary.interactionCount === 0) return void 0;
|
|
5050
|
+
if (summary.status !== "completed") return `\n→ 이 실행이 실패해 녹화를 완료하지 않았습니다: ${shownPath}\n 외부 호출 ${summary.interactionCount}건을 잡았지만 재생 원본으로 쓸 수 없습니다.\n→ 실패 원인을 고친 뒤 다시 녹화하세요.
|
|
5051
|
+
`;
|
|
5052
|
+
return `\n→ 외부 호출 ${summary.interactionCount}건을 녹화했습니다: ${shownPath}\n 이 수는 어댑터가 잡은 호출만 셉니다. 범위 밖 호출은 세션에 남지 않습니다.
|
|
5053
|
+
`;
|
|
5054
|
+
}
|
|
5055
|
+
if (summary.interactionCount === 0) return void 0;
|
|
5056
|
+
if (summary.consumedCount === 0) return void 0;
|
|
5057
|
+
if (summary.unusedCount > 0) return void 0;
|
|
5058
|
+
return `\n→ 녹화된 외부 호출 ${summary.interactionCount}건을 재생했습니다: ${shownPath}${recordedAtSuffix(summary)}\n` + replayCaveat(summary);
|
|
5059
|
+
}
|
|
5060
|
+
/**
|
|
5061
|
+
* 원본을 **언제** 녹화했는지 덧붙인다(ADR-0069). 없으면 아무것도 안 붙인다.
|
|
5062
|
+
*
|
|
5063
|
+
* **나이가 아니라 시각이다.** "12일 전" 이나 "30일 넘었습니다" 는 지금 시각을 읽어야 하고,
|
|
5064
|
+
* 그러면 같은 세션의 같은 재생이 날마다 다른 바이트를 낸다 — 결정론이 이 저장소의 핵심
|
|
5065
|
+
* 가치이고 대시보드 e2e 가 SSE 바이트 동일을 단언한다. 낡았는지 판정하는 것은 사람의 몫이다.
|
|
5066
|
+
*
|
|
5067
|
+
* **`toLocale*` 를 쓰지 않는다.** 기계의 로캘·타임존에 따라 같은 세션이 CI 와 로컬에서 다른
|
|
5068
|
+
* 문자열을 낸다. ISO 를 자르고 `UTC` 를 명시한다 — 표기를 안 붙이면 KST 사용자가 자기 시간으로
|
|
5069
|
+
* 읽어 9시간을 착각한다. 밀리초는 낡음을 판단하는 데 쓸모가 없어 뺀다.
|
|
5070
|
+
*/
|
|
5071
|
+
function recordedAtSuffix(summary) {
|
|
5072
|
+
if (summary.mode !== "replay") return "";
|
|
5073
|
+
const recordedAt = summary.recordedAt;
|
|
5074
|
+
if (recordedAt === void 0) return "";
|
|
5075
|
+
const [, date, time] = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/.exec(recordedAt) ?? [];
|
|
5076
|
+
return ` (${date === void 0 || time === void 0 ? escapeTerminalText(recordedAt) : formatUtc(recordedAt, date, time)} 녹화)`;
|
|
5077
|
+
}
|
|
5078
|
+
/**
|
|
5079
|
+
* 모양이 맞아도 **값이 달력에 없을 수 있다.** `2026-02-30` 은 `NaN` 이 아니라 **3월 2일로
|
|
5080
|
+
* 굴러간다**(실측). 그래서 `isNaN` 검사로는 못 잡고 왕복 비교를 해야 한다 — 다시 직렬화한
|
|
5081
|
+
* 값이 원래 문자열과 같을 때만 우리가 읽은 대로인 것이다.
|
|
5082
|
+
*
|
|
5083
|
+
* 파싱은 시계를 읽지 않으므로 결정론에 영향이 없다. 확인에 실패하면 원문을 그대로 보여준다 —
|
|
5084
|
+
* 손대지 않는 쪽이 없는 날짜를 그럴듯하게 포장하는 것보다 낫다.
|
|
5085
|
+
*
|
|
5086
|
+
* **그래서 `recordedAt` 원문을 받는다.** 잘라낸 조각으로 되짚어 만들면 정규식이 허용한 소수
|
|
5087
|
+
* 초가 사라져(`2026-02-30T12:00:00.123Z` → `...12:00:00Z`), "원문을 그대로 보여준다" 는 이
|
|
5088
|
+
* 함수의 계약이 실패 경로에서만 조용히 깨진다.
|
|
5089
|
+
*/
|
|
5090
|
+
function formatUtc(recordedAt, date, time) {
|
|
5091
|
+
const iso = `${date}T${time}`;
|
|
5092
|
+
const parsed = /* @__PURE__ */ new Date(`${iso}Z`);
|
|
5093
|
+
if (Number.isNaN(parsed.getTime()) || !parsed.toISOString().startsWith(iso)) return escapeTerminalText(recordedAt);
|
|
5094
|
+
return `${date} ${time} UTC`;
|
|
5095
|
+
}
|
|
5096
|
+
function externalSessionNotice(summary) {
|
|
5097
|
+
if (summary.mode === "record") {
|
|
5098
|
+
if (summary.interactionCount > 0) return void 0;
|
|
5099
|
+
return "\n알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.\n→ 서버가 외부 API를 호출했다면 지원 범위를 벗어났는지 확인하세요.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
5100
|
+
}
|
|
5101
|
+
if (summary.interactionCount === 0) return "\n알림: 이 세션에는 녹화된 외부 호출이 0건입니다. 재생할 것이 없었습니다.\n→ 녹화 실행이 외부 호출을 하나도 잡지 못했다는 뜻입니다. 이 세션은 아무 호출도 막지 못합니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
5102
|
+
if (summary.consumedCount === 0) return "\n알림: 이 실행에서 녹화된 외부 호출이 하나도 재생되지 않았습니다.\n→ 지원 범위 밖의 호출은 실제 네트워크로 나갔을 수 있습니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
5103
|
+
if (summary.unusedCount > 0) return `\n알림: 녹화된 외부 호출 ${summary.interactionCount}건 중 ${summary.unusedCount}건이 이번 실행에서 재생되지 않았습니다.\n→ 서버 코드나 실행 경로가 녹화 때와 달라졌을 수 있습니다.
|
|
5104
|
+
→ MCPeak은 서버가 \`globalThis.fetch\`로 부른 것만 잡습니다.
|
|
5105
|
+
`;
|
|
5106
|
+
}
|
|
5107
|
+
/**
|
|
5108
|
+
* 세션 본문에 남은 URL 을 알린다(ADR-0062).
|
|
5109
|
+
*
|
|
5110
|
+
* **`externalSessionNotice` 와 축이 다르므로 따로 낸다.** 그쪽은 "무엇이 녹화·재생됐는가" 를
|
|
5111
|
+
* 배타적 갈래 하나로 말하는데, 이 알림은 "그 세션에 무엇이 남았는가" 라서 같은 실행에서 둘 다
|
|
5112
|
+
* 나갈 수 있다. 한 함수에 합치면 그쪽의 배타성 계약이 깨진다.
|
|
5113
|
+
*
|
|
5114
|
+
* **재생에는 나오지 않는다.** 이 판정은 녹화 경로에서만 돌고(ADR-0062), 재생은 이미 있는
|
|
5115
|
+
* 파일을 읽을 뿐이라 새로 남는 것이 없다.
|
|
5116
|
+
*
|
|
5117
|
+
* 개수만 말하고 **URL 도 그 일부도 싣지 않는다.** 알림이 새 유출 경로가 되면 고치려던 것을
|
|
5118
|
+
* 그 자리에서 다시 만든다 — ADR-0062 결정 3번이 그 방어이고, `record` 가 지문만 넘기는 것도
|
|
5119
|
+
* 같은 이유다. 사용자가 확인할 곳은 세션 파일이지 이 화면이 아니다.
|
|
5120
|
+
*/
|
|
5121
|
+
function bodyUrlNotice(summary) {
|
|
5122
|
+
if (summary.mode !== "record") return void 0;
|
|
5123
|
+
const counts = summary.bodyUrls;
|
|
5124
|
+
if (counts === void 0) return void 0;
|
|
5125
|
+
const total = counts.echoed + counts.other;
|
|
5126
|
+
if (total === 0) return void 0;
|
|
5127
|
+
const count = (value) => `${value}건${counts.truncated ? " 이상" : ""}`;
|
|
5128
|
+
const lines = [`\n알림: 세션 파일 본문에 URL 이 ${count(total)} 남아 있습니다.`];
|
|
5129
|
+
if (counts.echoed > 0) lines.push(` 되돌아온 경로 ${count(counts.echoed)} — 이 실행의 요청 경로가 본문에 그대로 실렸습니다`);
|
|
5130
|
+
if (counts.other > 0) lines.push(` 그 밖의 URL ${count(counts.other)}`);
|
|
5131
|
+
lines.push("→ 경로 자체가 자격증명인 endpoint(Slack·Discord webhook 등)를 녹화했다면 그 값이 세션 파일에", " 원문으로 들어 있습니다. 이름으로 판정할 수 없는 자리라 마스킹이 닿지 않습니다.", "→ 커밋하기 전에 세션 파일 내용을 확인하세요. 이미 커밋했다면 그 자격증명을 폐기·재발급하세요.");
|
|
5132
|
+
return `${lines.join("\n")}\n`;
|
|
5133
|
+
}
|
|
5134
|
+
/**
|
|
5135
|
+
* `test` 실행에 External Record/Replay 수명주기를 씌운다.
|
|
5136
|
+
*
|
|
5137
|
+
* Coordinator 를 **먼저 열고 마지막에 닫는다**(ADR-0052). 그 사이에 들어가는 것이 기존 실행
|
|
5138
|
+
* 전부라, 성공·실패·예외 어느 경로로 빠져나가도 `finish` 가 불려야 한다. 안 불리면 SQLite
|
|
5139
|
+
* 파일 핸들이 남고 Record 세션이 `running` 인 채로 남아 다음 실행이 이어 쓸 수 없다.
|
|
5140
|
+
*
|
|
5141
|
+
* 세션 옵션이 없으면 배선을 아예 만들지 않는다 — 기존 실행 경로가 한 글자도 달라지지 않는다.
|
|
5142
|
+
*/
|
|
5143
|
+
async function runCli(argv, dependencies) {
|
|
5144
|
+
const mode = externalModeOf(argv);
|
|
5145
|
+
if (mode === void 0) return runCliCore(argv, dependencies);
|
|
5146
|
+
let wiring;
|
|
5147
|
+
try {
|
|
5148
|
+
wiring = await startExternalWiring({
|
|
5149
|
+
mode: mode.mode,
|
|
5150
|
+
sessionPath: mode.path,
|
|
5151
|
+
existingNodeOptions: process.env.NODE_OPTIONS
|
|
5152
|
+
});
|
|
5153
|
+
} catch (error) {
|
|
5154
|
+
return writeFailure(dependencies, externalOpenFailure(mode.mode, mode.path, error));
|
|
5155
|
+
}
|
|
5156
|
+
let exitCode;
|
|
5157
|
+
try {
|
|
5158
|
+
exitCode = await runCliCore(argv, dependencies, wiring.env);
|
|
5159
|
+
} catch (error) {
|
|
5160
|
+
await wiring.finish("failed").catch(() => void 0);
|
|
5161
|
+
throw error;
|
|
5162
|
+
}
|
|
5163
|
+
try {
|
|
5164
|
+
const summary = await wiring.finish(exitCode === 0 ? "completed" : "failed");
|
|
5165
|
+
const outcome = externalSessionOutcome(summary, mode.path);
|
|
5166
|
+
if (outcome !== void 0) dependencies.writeStderr(outcome);
|
|
5167
|
+
if (summary.mode === "replay") {
|
|
5168
|
+
const missBlock = renderReplayMissDiagnostics(summary.misses);
|
|
5169
|
+
if (missBlock !== "") dependencies.writeStderr(missBlock);
|
|
5170
|
+
}
|
|
5171
|
+
const notice = externalSessionNotice(summary);
|
|
5172
|
+
if (notice !== void 0) dependencies.writeStderr(notice);
|
|
5173
|
+
const leaked = outOfScopeNotice(summary);
|
|
5174
|
+
if (leaked !== void 0) dependencies.writeStderr(leaked);
|
|
5175
|
+
const urls = bodyUrlNotice(summary);
|
|
5176
|
+
if (urls !== void 0) dependencies.writeStderr(urls);
|
|
5177
|
+
} catch (error) {
|
|
5178
|
+
return writeFailure(dependencies, externalCloseFailure(error));
|
|
5179
|
+
}
|
|
5180
|
+
return exitCode;
|
|
5181
|
+
}
|
|
5182
|
+
/**
|
|
5183
|
+
* argv 에서 External 모드를 정한다. **파싱은 `parseTestCommand` 하나만 한다.**
|
|
5184
|
+
*
|
|
5185
|
+
* 한때 여기서 argv 를 따로 훑었다. 그러면 토큰 소비 규칙이 두 벌이 되고, 두 벌은 갈라진다.
|
|
5186
|
+
* 실제로 갈렸다 — `--arg` 는 하이픈으로 시작하는 값을 의도적으로 받으므로
|
|
5187
|
+
*
|
|
5188
|
+
* mcpeak test s.json --command node --arg --session=/tmp/x
|
|
5189
|
+
*
|
|
5190
|
+
* 에서 `parseTestCommand` 는 그 토큰을 **서버 인자**로 소비하는데, 따로 훑는 쪽은 같은 것을
|
|
5191
|
+
* replay 지시로 읽었다. 사용자가 요청한 적 없는 세션 파일이 열리고 Bootstrap 이 주입되며,
|
|
5192
|
+
* Replay 라 서버의 외부 호출이 전부 실패한다. 원인을 짐작할 방법이 없는 실패다.
|
|
5193
|
+
*
|
|
5194
|
+
* 파싱 오류는 여기서 삼킨다. 배선은 파싱보다 먼저 서지만 **오류 보고는 기존 경로의 몫**이다.
|
|
5195
|
+
* 여기서 던지면 `--session` 이 붙었다는 이유만으로 오류 문구가 달라진다.
|
|
5196
|
+
*/
|
|
5197
|
+
function externalModeOf(argv) {
|
|
5198
|
+
if (argv[0] !== "test") return void 0;
|
|
5199
|
+
let input;
|
|
5200
|
+
try {
|
|
5201
|
+
input = parseTestCommand(argv.slice(1));
|
|
5202
|
+
} catch {
|
|
5203
|
+
return;
|
|
5204
|
+
}
|
|
5205
|
+
if (input.recordSessionPath !== void 0) return {
|
|
5206
|
+
mode: "record",
|
|
5207
|
+
path: input.recordSessionPath
|
|
5208
|
+
};
|
|
5209
|
+
if (input.sessionPath !== void 0) return {
|
|
5210
|
+
mode: "replay",
|
|
5211
|
+
path: input.sessionPath
|
|
5212
|
+
};
|
|
5213
|
+
}
|
|
4492
5214
|
//#endregion
|
|
4493
|
-
export {
|
|
5215
|
+
export { nodeGenerateDependencies as a, GLOBAL_HELP as c, escapeTerminalText$2 as d, version as f, runRepairCommand as i, commandDiscovery as l, runCli as n, nodeReviewIO as o, parseRepairCommand as r, runGenerateCommand as s, parseTestCommand as t, commandHelp as u };
|