@mcpeak/cli 0.11.0 → 0.12.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/dist/commands.cjs +2 -1
- package/dist/commands.d.cts +2 -2
- package/dist/commands.d.mts +2 -2
- package/dist/commands.mjs +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{repair-command-D3zL3G5W.d.cts → repair-command-8GUlJJ47.d.mts} +57 -7
- package/dist/{repair-command-DQh2h4tQ.d.mts → repair-command-CVFqxU6V.d.cts} +57 -7
- package/dist/{test-command-B_faNSfA.cjs → test-command-1m3myzTS.cjs} +1212 -308
- package/dist/{test-command-DEp9hrfH.mjs → test-command-P8_uaAIy.mjs} +1211 -313
- package/package.json +6 -6
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
let node_fs_promises = require("node:fs/promises");
|
|
2
2
|
let node_path = require("node:path");
|
|
3
3
|
let node_readline_promises = require("node:readline/promises");
|
|
4
|
+
let _mcpeak_generate = require("@mcpeak/generate");
|
|
4
5
|
let _mcpeak_runner = require("@mcpeak/runner");
|
|
5
6
|
let node_child_process = require("node:child_process");
|
|
6
7
|
let _mcpeak_core = require("@mcpeak/core");
|
|
7
8
|
let node_fs = require("node:fs");
|
|
8
9
|
//#region package.json
|
|
9
|
-
var version = "0.
|
|
10
|
+
var version = "0.12.0";
|
|
10
11
|
//#endregion
|
|
11
12
|
//#region src/repair-render.ts
|
|
12
13
|
/**
|
|
@@ -43,12 +44,21 @@ const APPROVAL_LABEL = {
|
|
|
43
44
|
mismatched: "승인 지문 불일치",
|
|
44
45
|
absent: "승인 지문 없음"
|
|
45
46
|
};
|
|
47
|
+
const RUN_HISTORY_LABEL = {
|
|
48
|
+
present: "실행 기록 있음",
|
|
49
|
+
absent: "실행 기록 없음"
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* 명세를 오라클로 볼 수 있는가. `generate` 의 `specIsOracle` 과 같은 뜻이지만 여기서 다시
|
|
53
|
+
* 판정한다. 화면 렌더러는 진단 통로를 로드하지 않는다(설계서 §4.5).
|
|
54
|
+
*/
|
|
55
|
+
const isOracle = (bundle) => bundle.spec.approval === "matched" && bundle.spec.runHistory === "present";
|
|
46
56
|
/**
|
|
47
57
|
* 지문이 승인 상태가 아닐 때 결과 맨 위에 붙는 블록. 결과가 `unsure` 여도 붙는다. 설계서 §6.5.
|
|
48
58
|
* `matched` 면 빈 문자열이다.
|
|
49
59
|
*/
|
|
50
|
-
function renderApprovalNotice(approval) {
|
|
51
|
-
if (approval === "matched") return "";
|
|
60
|
+
function renderApprovalNotice(approval, runHistory) {
|
|
61
|
+
if (approval === "matched") return runHistory === "present" ? "" : "⚠ 이 명세는 승인 지문이 일치하지만 실제 서버 실행 기록이 없습니다.\n --baseline-only 나 --no-dry-run 으로 저장하면 케이스가 한 번도 실행되지 않은 채 승인됩니다.\n 입력값이 생성 시점의 자리값일 수 있어, 아래 제안은 명세 쪽 원인도 함께 받았습니다.\n\n";
|
|
52
62
|
if (approval === "mismatched") return "⚠ 이 명세는 승인 상태가 아닙니다 (지문 불일치).\n 실패 원인이 서버가 아니라 명세일 수 있습니다. 아래 제안은 그 전제로 받았습니다.\n\n";
|
|
53
63
|
return "⚠ 이 명세는 승인 지문이 없습니다.\n 실제 서버로 검증된 적이 없는 명세일 수 있습니다. 아래 제안은 그 전제로 받았습니다.\n\n";
|
|
54
64
|
}
|
|
@@ -61,6 +71,7 @@ function kilobytes(bytes) {
|
|
|
61
71
|
*/
|
|
62
72
|
function renderRepairConfirm(view) {
|
|
63
73
|
const scope = view.omittedFailures === 0 ? `실패 ${view.totalFailures}건 중 ${view.sentFailures}건` : `실패 ${view.totalFailures}건 중 ${view.sentFailures}건 (--max-cases ${view.maxCases}, ${view.omittedFailures}건 제외)`;
|
|
74
|
+
const toolLine = view.omittedToolSchemas === 0 ? `${view.sentTools}개` : `${view.sentTools}개 (스키마 제외 ${view.omittedToolSchemas}개)`;
|
|
64
75
|
let stderrLine;
|
|
65
76
|
if (!view.includeStderr) stderrLine = "(전송하지 않음)";
|
|
66
77
|
else if (view.stderr === void 0 || view.stderr === "") stderrLine = "없음";
|
|
@@ -74,7 +85,8 @@ function renderRepairConfirm(view) {
|
|
|
74
85
|
"\n",
|
|
75
86
|
` provider ${view.providerId} (${escapeTerminalText$2(view.model)})\n`,
|
|
76
87
|
` 대상 ${scope}\n`,
|
|
77
|
-
` 명세 상태 ${APPROVAL_LABEL[view.approval]}\n`,
|
|
88
|
+
` 명세 상태 ${APPROVAL_LABEL[view.approval]} · ${RUN_HISTORY_LABEL[view.runHistory]}\n`,
|
|
89
|
+
` 도구 ${toolLine}\n`,
|
|
78
90
|
` stderr ${stderrLine}\n`,
|
|
79
91
|
` 전송 크기 ${kilobytes(view.requestBytes)}\n`,
|
|
80
92
|
"\n",
|
|
@@ -130,7 +142,7 @@ function renderRepairResult(options) {
|
|
|
130
142
|
const parts = [
|
|
131
143
|
`── 서버 수정 방향 (${options.providerId} / ${escapeTerminalText$2(options.model)}) ──\n`,
|
|
132
144
|
"\n",
|
|
133
|
-
renderApprovalNotice(options.bundle.spec.approval)
|
|
145
|
+
renderApprovalNotice(options.bundle.spec.approval, options.bundle.spec.runHistory)
|
|
134
146
|
];
|
|
135
147
|
if (options.result.status === "unsure") {
|
|
136
148
|
/**
|
|
@@ -166,7 +178,7 @@ function renderRepairResult(options) {
|
|
|
166
178
|
parts.push(` 원인 후보 ${escapeTerminalText$2(cause.summary)}\n`);
|
|
167
179
|
if (cause.location !== "") parts.push(` 확인할 곳 ${escapeTerminalText$2(cause.location)}\n`);
|
|
168
180
|
if (cause.evidence !== "") parts.push(` 근거 ${escapeTerminalText$2(cause.evidence)}\n`);
|
|
169
|
-
if (cause.target === "spec" && options.bundle
|
|
181
|
+
if (cause.target === "spec" && !isOracle(options.bundle)) parts.push(" 분류 명세 쪽 원인으로 봄\n");
|
|
170
182
|
}
|
|
171
183
|
}
|
|
172
184
|
}
|
|
@@ -241,6 +253,11 @@ function echoValue(value) {
|
|
|
241
253
|
*/
|
|
242
254
|
const SECRET_INPUT_HINT = "값은 셸 히스토리에 남지 않게 넣으세요. 예: `read -rs MCP_TOKEN; export MCP_TOKEN`";
|
|
243
255
|
/**
|
|
256
|
+
* 같은 `read -rs` 예시를 명령줄 모양 그대로 둔 것. `--env` 안내가 올바른 호출 예시 바로 위에
|
|
257
|
+
* 적는다. `MCP_TOKEN` 자리는 `replaceAll` 로 실제 이름으로 바꿔 쓴다.
|
|
258
|
+
*/
|
|
259
|
+
const SECRET_ENV_EXAMPLE = " read -rs MCP_TOKEN; export MCP_TOKEN";
|
|
260
|
+
/**
|
|
244
261
|
* `--url` 값을 검증한다.
|
|
245
262
|
*
|
|
246
263
|
* 스킴을 http·https 로 제한한다. `file:` 이나 `ws:` 를 넘기면 `core` 의 SDK 안쪽에서
|
|
@@ -296,6 +313,34 @@ function createHeaderEnvCollector() {
|
|
|
296
313
|
snapshot: () => Object.freeze({ ...values })
|
|
297
314
|
};
|
|
298
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* `--env <NAME>` 값 하나를 검사한다. **이름만** 받는다. 값을 여기 넣으면 `ps` 와 셸
|
|
318
|
+
* 히스토리와 녹화 출처에 그대로 남는다. 그것이 이 옵션이 이름만 받는 이유고, 거절 문장이
|
|
319
|
+
* 그 이유를 말해야 한다.
|
|
320
|
+
*
|
|
321
|
+
* `NODE_OPTIONS` 와 `MCPEAK_*` 는 External 배선이 자식 환경에 직접 쓰는 이름이다. 배선이
|
|
322
|
+
* 사용자 값 **뒤에** 얹히므로 덮이지는 않지만, 받아 놓고 조용히 덮어쓰면 "막은 척" 이 된다.
|
|
323
|
+
*/
|
|
324
|
+
function parseEnvForwardOption(raw) {
|
|
325
|
+
const name = raw.trim();
|
|
326
|
+
if (name === "") return err("`--env` 옵션 값이 필요합니다.");
|
|
327
|
+
if (!ENV_NAME_PATTERN.test(name)) return err(`\`--env\` 는 환경변수 **이름**만 받습니다: '${echoValue(raw)}'\n→ 값을 명령줄에 쓰면 \`ps\` 목록과 셸 히스토리, 녹화 출처에 그대로 남기 때문입니다.
|
|
328
|
+
→ 값은 환경변수에 넣고 이름만 넘기세요:
|
|
329
|
+
${SECRET_ENV_EXAMPLE.replaceAll("MCP_TOKEN", "SUPABASE_ACCESS_TOKEN")}\n mcpeak test suite.json --command npx --arg -y --arg <서버> --env SUPABASE_ACCESS_TOKEN`);
|
|
330
|
+
if (name === "NODE_OPTIONS") return err("`--env NODE_OPTIONS` 는 받지 않습니다.\n→ 부모의 `NODE_OPTIONS` 는 녹화·재생 배선이 자식에게 이미 이어 붙입니다.");
|
|
331
|
+
if (name.startsWith("MCPEAK_")) return err(`\`--env ${name}\` 는 받지 않습니다.\n→ \`MCPEAK_\` 로 시작하는 이름은 녹화·재생 배선이 자식 환경에 직접 씁니다. 사용자 값이 그것을 덮으면 녹화가 조용히 꺼집니다.`);
|
|
332
|
+
return ok(name);
|
|
333
|
+
}
|
|
334
|
+
function createEnvForwardCollector() {
|
|
335
|
+
const names = [];
|
|
336
|
+
return {
|
|
337
|
+
add(name) {
|
|
338
|
+
if (names.includes(name)) return `\`--env ${name}\` 이 두 번 있습니다. 한 번만 쓰세요.`;
|
|
339
|
+
names.push(name);
|
|
340
|
+
},
|
|
341
|
+
snapshot: () => Object.freeze([...names])
|
|
342
|
+
};
|
|
343
|
+
}
|
|
299
344
|
/** 대상을 사람이 읽는 한 조각으로 만든다. 오류 문장이 무엇에 붙으려 했는지 말할 때 쓴다. */
|
|
300
345
|
function describeTarget(target) {
|
|
301
346
|
return target.transport === "stdio" ? [target.command, ...target.args].join(" ") : target.url;
|
|
@@ -308,13 +353,23 @@ var ConnectTargetError = class extends Error {};
|
|
|
308
353
|
*
|
|
309
354
|
* `env` 는 stdio 전용이다. External 배선이 만든 자식 환경 변수라 띄울 프로세스가 있어야
|
|
310
355
|
* 뜻이 있고, 파서가 `--url` 과 External 세션 옵션의 동시 사용을 이미 막는다.
|
|
356
|
+
*
|
|
357
|
+
* 자식 환경은 세 겹이고 순서가 계약이다(설계 §4.1):
|
|
358
|
+
* SDK 기본 허용 목록 < `--env` 로 넘긴 것 < External 배선(`options.env`).
|
|
359
|
+
* 배선이 마지막이다. 배선 변수가 사용자 값에 덮이면 녹화가 조용히 꺼진다.
|
|
311
360
|
*/
|
|
312
361
|
async function openConnection(target, dependencies, options) {
|
|
313
|
-
if (target.transport === "stdio")
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
362
|
+
if (target.transport === "stdio") {
|
|
363
|
+
const env = {
|
|
364
|
+
...resolveForwardedEnv(target.envNames, dependencies.readEnv),
|
|
365
|
+
...options?.env
|
|
366
|
+
};
|
|
367
|
+
return await dependencies.connectStdio({
|
|
368
|
+
command: target.command,
|
|
369
|
+
args: target.args,
|
|
370
|
+
...Object.keys(env).length === 0 ? {} : { env }
|
|
371
|
+
});
|
|
372
|
+
}
|
|
318
373
|
const connectHttp = dependencies.connectHttp;
|
|
319
374
|
if (connectHttp === void 0) throw new ConnectTargetError("이 진입점은 원격(Streamable HTTP) 서버를 지원하지 않습니다.\n→ `--url` 대신 `--command` 로 서버를 직접 띄우세요.");
|
|
320
375
|
const headers = resolveHeaders(target.headerEnv, dependencies.readEnv);
|
|
@@ -355,6 +410,22 @@ function resolveHeaders(headerEnv, readEnv) {
|
|
|
355
410
|
}
|
|
356
411
|
return ok(headers);
|
|
357
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* `--env` 이름을 값으로 바꾼다. **비어 있으면 이름만 말한다.** 값이 비었다는 사실을 알리려다
|
|
415
|
+
* 값을 화면에 찍으면 이 옵션이 존재하는 이유가 없어진다(ADR-0070 §3).
|
|
416
|
+
*/
|
|
417
|
+
function resolveForwardedEnv(names, readEnv) {
|
|
418
|
+
if (names.length === 0) return {};
|
|
419
|
+
if (readEnv === void 0) throw new ConnectTargetError("이 진입점은 `--env` 를 지원하지 않습니다.\n→ 환경변수를 읽는 주입점(readEnv)이 배선되지 않았습니다.");
|
|
420
|
+
const env = {};
|
|
421
|
+
for (const name of names) {
|
|
422
|
+
const value = readEnv(name);
|
|
423
|
+
if (value === void 0 || value === "") throw new ConnectTargetError(`환경변수 \`${name}\` 가 비어 있습니다.\n→ 값을 넣고 다시 실행하세요:
|
|
424
|
+
${SECRET_ENV_EXAMPLE.replaceAll("MCP_TOKEN", name)}`);
|
|
425
|
+
env[name] = value;
|
|
426
|
+
}
|
|
427
|
+
return env;
|
|
428
|
+
}
|
|
358
429
|
//#endregion
|
|
359
430
|
//#region src/dry-run.ts
|
|
360
431
|
/** 보고서가 상한을 넘긴 경우의 안내. 케이스 수 말고는 사용자가 손댈 수 있는 것이 없다. */
|
|
@@ -389,6 +460,24 @@ const caseBlocks = (report) => {
|
|
|
389
460
|
}
|
|
390
461
|
return report.cases.map((_, index) => (blocks[index] ?? []).join("\n"));
|
|
391
462
|
};
|
|
463
|
+
/** `renderReport` 가 건너뛴 단언 줄에 붙이는 표시. reporter.ts 와 같은 값이다. */
|
|
464
|
+
const SKIPPED_MARK$1 = "(건너뜀) ";
|
|
465
|
+
/** 케이스 본문 줄에서 들여쓰기를 벗긴다. 들여쓰기가 없는 줄은 본문이 아니다. */
|
|
466
|
+
const bodyLines$1 = (block) => block.split("\n").filter((line) => line.startsWith(INDENT$1)).map((line) => line.slice(4));
|
|
467
|
+
/**
|
|
468
|
+
* 실패 사유 첫 줄을 고른다. **문장을 새로 만들지 않고 렌더된 블록에서 고르기만 한다.**
|
|
469
|
+
*
|
|
470
|
+
* 판정은 구조화된 값으로 한다. `operation.status` 와 `assertions[].status` 가 이미 사실을
|
|
471
|
+
* 담고 있는데 렌더 문자열을 다시 파싱하면, 문안이 바뀌는 날 조용히 어긋난다. `(건너뜀) ` 줄을
|
|
472
|
+
* 실패한 단언으로 읽던 것이 정확히 그 방식의 결과다(설계 §3.3).
|
|
473
|
+
*/
|
|
474
|
+
const failureLineOf = (result, block) => {
|
|
475
|
+
const body = bodyLines$1(block);
|
|
476
|
+
if (result.operation.status !== "completed") return body[0] ?? "";
|
|
477
|
+
const failed = result.assertions.find((assertion) => assertion.status === "failed");
|
|
478
|
+
if (failed === void 0) return "";
|
|
479
|
+
return body.find((line) => line.startsWith(failed.spec.type) && !line.includes(SKIPPED_MARK$1)) ?? "";
|
|
480
|
+
};
|
|
392
481
|
/**
|
|
393
482
|
* 연결 상실 사유별 문장. `runner` 의 `reporter.ts`·`junit.ts` 가 쓰는 어휘를 그대로 따른다.
|
|
394
483
|
* 같은 죽음을 `test` 에서 볼 때와 `generate` 에서 볼 때 다른 낱말로 부르면 두 번 배우게 된다.
|
|
@@ -446,6 +535,8 @@ const toResult = (report) => {
|
|
|
446
535
|
status: result.status,
|
|
447
536
|
detail: blocks[index] ?? "",
|
|
448
537
|
rejectionBasis: result.rejectionBasis,
|
|
538
|
+
operationFailed: result.operation.status !== "completed",
|
|
539
|
+
failureLine: failureLineOf(result, blocks[index] ?? ""),
|
|
449
540
|
...result.rejectionBody === void 0 ? {} : { rejectionBody: result.rejectionBody }
|
|
450
541
|
}));
|
|
451
542
|
const aborted = abortedFromStop(report);
|
|
@@ -644,7 +735,22 @@ const REMOTE_TARGET_OPTIONS = ` --url <URL> 이미 떠 있는 Streama
|
|
|
644
735
|
남기 때문입니다. 값을 넣을 때도 히스토리에 남기지 마세요:
|
|
645
736
|
read -rs MCP_TOKEN; export MCP_TOKEN
|
|
646
737
|
mcpeak … --header-env Authorization=MCP_TOKEN`;
|
|
647
|
-
|
|
738
|
+
/**
|
|
739
|
+
* stdio 대상의 환경변수 전달 옵션. `test` 와 `generate` 가 같은 문장을 쓴다.
|
|
740
|
+
*
|
|
741
|
+
* `--env` 가 이름만 받는 이유를 여기서 말한다. 이유를 모르면 사용자는 `--arg KEY=값` 이나
|
|
742
|
+
* `--command env` 래퍼로 우회하고, 그것이 이 옵션이 막으려던 노출이다. `--header-env` 와 같은
|
|
743
|
+
* 근거다.
|
|
744
|
+
*/
|
|
745
|
+
const ENV_FORWARD_OPTION = ` --env <NAME> 부모 환경변수 NAME 을 서버 프로세스에 넘깁니다. 반복할 수 있습니다.
|
|
746
|
+
SDK 는 HOME·PATH 등 고정 목록만 자식에게 주므로, API 키처럼 서버가
|
|
747
|
+
환경에서 읽는 값은 이 옵션으로 이름을 지정해야 갑니다. 값을 직접
|
|
748
|
+
받지 않는 이유는 명령줄에 쓴 토큰이 \`ps\` 목록과 셸 히스토리, 녹화
|
|
749
|
+
출처에 그대로 남기 때문입니다. \`NODE_OPTIONS\` 와 \`MCPEAK_\` 로
|
|
750
|
+
시작하는 이름은 녹화·재생 배선이 쓰므로 받지 않습니다:
|
|
751
|
+
read -rs SUPABASE_ACCESS_TOKEN; export SUPABASE_ACCESS_TOKEN
|
|
752
|
+
mcpeak … --command npx --arg -y --arg <서버> --env SUPABASE_ACCESS_TOKEN`;
|
|
753
|
+
const TEST_USAGE = "사용법: mcpeak test <suite.json> (-- <executable> [args...] | --command <executable> [--arg <value> ...] [--env <NAME> ...] | --url <URL> [--header-env <헤더이름>=<환경변수이름> ...]) [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>] [--session <path> | --record-session <path>]";
|
|
648
754
|
/**
|
|
649
755
|
* 시험 실행 옵션 설명. `--determinism` 은 툴을 2회 호출하므로 부작용이 있는 서버에서 모르고
|
|
650
756
|
* 쓰면 사고가 난다. 그 경고가 사용법 한 줄에는 들어가지 않는다.
|
|
@@ -668,6 +774,7 @@ const TEST_OPTIONS = `옵션:
|
|
|
668
774
|
출력을 CI 로그에 남기기 전에 확인하세요
|
|
669
775
|
--reset-cmd <command> 각 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지
|
|
670
776
|
않으므로 파이프나 && 는 쓸 수 없습니다
|
|
777
|
+
${ENV_FORWARD_OPTION}
|
|
671
778
|
${REMOTE_TARGET_OPTIONS}
|
|
672
779
|
--record-session <path>
|
|
673
780
|
서버가 \`globalThis.fetch\` 로 밖에 부른 HTTP 호출만 녹화합니다.
|
|
@@ -692,7 +799,7 @@ feature\` 가 stderr 에 한 줄 찍힙니다(실측: Node 22.18.0 에서 나오
|
|
|
692
799
|
\`node:sqlite\` 를 처음 로드할 때 한 번 나오므로 실행당 한 줄이며, 세션 옵션이 없는 실행에는
|
|
693
800
|
붙지 않습니다. Node 가 그 모듈을 아직 실험적으로 표시한다는 뜻이고, **저장된 녹화는 표준
|
|
694
801
|
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]";
|
|
802
|
+
const GENERATE_USAGE = "사용법: mcpeak generate --out <suite.json> (-- <executable> [args...] | --command <executable> [--arg <value> ...] [--env <NAME> ...] | --url <URL> [--header-env <헤더이름>=<환경변수이름> ...]) [--suite-id <id>] [--name <name>] [--baseline-only] [--provider <codex|claude>] [--model <model>] [--no-dry-run] [--reset-cmd <command>] [--fixtures <path>] [--no-repair] [--diagnose-rejections] [--force]";
|
|
696
803
|
/**
|
|
697
804
|
* 시험 실행 옵션 설명. 사용법 한 줄로는 `--reset-cmd` 가 셸을 거치지 않는다는 제약을 알 수
|
|
698
805
|
* 없다. 모르고 쓰면 사고가 나는 값이다.
|
|
@@ -709,13 +816,25 @@ const GENERATE_DRY_RUN_OPTIONS = `옵션:
|
|
|
709
816
|
달라져 재승인이 뜹니다. 고정하려면 --suite-id 와 --name 을
|
|
710
817
|
직접 지정하세요
|
|
711
818
|
--no-dry-run 승인 전 시험 실행을 건너뜁니다. 케이스가 실제 서버에서 확인되지
|
|
712
|
-
않은 채
|
|
819
|
+
않은 채 저장되고, 실행이 필요한 AI 사전보완도 건너뜁니다
|
|
713
820
|
--reset-cmd <command> 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지 않으므로
|
|
714
821
|
파이프나 && 는 쓸 수 없습니다
|
|
822
|
+
--fixtures <path> 실재하는 자원을 가리키는 값을 도구·필드별로 적은 JSON 파일입니다.
|
|
823
|
+
생략하면 \`mcpeak.fixtures.json\` 을 찾고, 없으면 그냥 진행합니다.
|
|
824
|
+
픽스처 파일에 토큰·비밀번호·API 키를 적지 마세요. 이 파일의 값은
|
|
825
|
+
생성된 명세에 그대로 들어갑니다. 인증이 필요한 서버는 서버 기동
|
|
826
|
+
환경변수로 자격을 넘기고, 픽스처에는 그 자격으로 접근 가능한
|
|
827
|
+
자원 식별자만 적으세요
|
|
715
828
|
--no-repair 시험 실행이 실패해도 입력값을 고쳐 다시 시도하지 않습니다.
|
|
716
829
|
실패가 곧바로 분류 화면으로 갑니다
|
|
830
|
+
--diagnose-rejections
|
|
831
|
+
통과한 거절 케이스의 응답을 나열하고, provider 가 있으면 그 거절이
|
|
832
|
+
입력 검증 때문인지 서버 내부 오류인지 AI 에게 참고 의견을 묻습니다.
|
|
833
|
+
기본은 끕니다. 손으로 쓴 거절 문장은 전부 이 목록에 오르므로,
|
|
834
|
+
서버가 잘못된 입력에 그냥 터지는지 의심될 때 켜세요
|
|
717
835
|
--force \`--out\` 경로에 파일이 있으면 지우고 새로 씁니다. 기본은 저장을
|
|
718
836
|
멈추는 것입니다
|
|
837
|
+
${ENV_FORWARD_OPTION}
|
|
719
838
|
${REMOTE_TARGET_OPTIONS}`;
|
|
720
839
|
const REPAIR_USAGE = "사용법: mcpeak repair <bundle.json> --provider <codex|claude> --model <model> [--max-cases <N>] [--no-stderr] [--yes]";
|
|
721
840
|
/**
|
|
@@ -854,15 +973,9 @@ function renderHttpDiagnostics(diagnostics) {
|
|
|
854
973
|
//#region src/input-repair.ts
|
|
855
974
|
/** 케이스 머리글 들여쓰기. 분류 화면(§8.3)과 같은 값이라 번호가 이어 읽힌다. */
|
|
856
975
|
const HEAD = " ";
|
|
857
|
-
/** 본문 들여쓰기.
|
|
976
|
+
/** 본문 들여쓰기. 교정 화면의 모든 본문 줄이 이 폭이다. */
|
|
858
977
|
const BODY = " ";
|
|
859
978
|
/**
|
|
860
|
-
* 교정 대상의 실패 사유는 항상 이 한 줄이다. 대상 판별이 `isError` 단언의 `expected` 가
|
|
861
|
-
* true 가 아닌 실패만 통과시키므로(§4.2), 진단 문장이 이것 말고 나올 수 없다.
|
|
862
|
-
* `RepairTarget` 이 `detail` 을 나르지 않아 여기서 다시 적는다.
|
|
863
|
-
*/
|
|
864
|
-
const FAILURE_LINE = `${BODY}isError 정상 응답을 기대했지만 오류 응답을 받았습니다.`;
|
|
865
|
-
/**
|
|
866
979
|
* 제안값의 출처를 말한다. `propose` 는 provider 가 있을 때만 배선되므로 여기 오는 값은
|
|
867
980
|
* **항상 provider 가 만든 것**이다 — 그런데 전에는 서버에 귀속했다(#286).
|
|
868
981
|
* 표기를 못 받은 경우에도 서버라고 말하지 않는다.
|
|
@@ -920,20 +1033,21 @@ const parseAnswer = (answer) => {
|
|
|
920
1033
|
return answer;
|
|
921
1034
|
}
|
|
922
1035
|
};
|
|
923
|
-
/** §8.6 의 기본값 표시. 문자열은 따옴표 없이 그대로 보여준다. */
|
|
924
|
-
const renderDefault = (value) => typeof value === "string" ? value : JSON.stringify(value);
|
|
925
|
-
/** §8.6.1 의 현재값 표시. 값의 경계가 보여야 해서 JSON 으로 적는다. */
|
|
926
|
-
const renderCurrent = (value) => JSON.stringify(value) ?? "null";
|
|
927
1036
|
/** `(tool, 필드명)` 캐시 키. 필드 이름에 점이 있어도 툴 이름과 섞이지 않게 앞을 길이로 가른다. */
|
|
928
1037
|
const cacheKey = (tool, field) => `${tool.length}:${tool}.${field}`;
|
|
929
1038
|
/**
|
|
930
1039
|
* 한 필드를 사람에게 받는다. 선언 타입과 안 맞으면 같은 질문을 다시 한다(§4.5).
|
|
931
|
-
*
|
|
1040
|
+
*
|
|
1041
|
+
* 형식은 제안 유무와 **무관하게 하나다**(2026-09-13 설계 §4.5). 갈래가 둘이면 같은 화면이
|
|
1042
|
+
* 회차마다 다른 모양이 되고, 사용자가 무엇을 누르면 무엇이 들어가는지를 두 번 배운다.
|
|
932
1043
|
*/
|
|
933
1044
|
const askField = async (io, options) => {
|
|
934
1045
|
const fallback = options.proposed ?? options.current;
|
|
935
|
-
const
|
|
936
|
-
|
|
1046
|
+
const parts = [`필드 ${options.index}/${options.total}`];
|
|
1047
|
+
if (options.declared !== void 0) parts.push(options.declared);
|
|
1048
|
+
if (options.current !== void 0) parts.push(`현재 ${JSON.stringify(options.current)}`);
|
|
1049
|
+
parts.push(options.proposed === void 0 ? "엔터 = 현재 값 유지" : `엔터 = 제안 값 ${JSON.stringify(options.proposed)}`);
|
|
1050
|
+
const question = `${BODY}${options.tool}.${options.field} (${parts.join(", ")}): `;
|
|
937
1051
|
for (;;) {
|
|
938
1052
|
const answer = await io.input(question);
|
|
939
1053
|
if (answer.trim() === "") return fallback;
|
|
@@ -949,10 +1063,16 @@ const askRound = async (io, target, options) => {
|
|
|
949
1063
|
const next = {};
|
|
950
1064
|
const asked = [];
|
|
951
1065
|
let unchanged = true;
|
|
1066
|
+
const total = Object.keys(options.current).filter((field) => options.reuse?.get(field) === void 0).length;
|
|
1067
|
+
let index = 0;
|
|
952
1068
|
for (const [field, value] of Object.entries(options.current)) {
|
|
953
1069
|
const cached = options.reuse?.get(field);
|
|
1070
|
+
if (cached === void 0) index += 1;
|
|
954
1071
|
const answered = cached === void 0 ? await askField(io, {
|
|
1072
|
+
tool: target.tool,
|
|
955
1073
|
field,
|
|
1074
|
+
index,
|
|
1075
|
+
total,
|
|
956
1076
|
current: value,
|
|
957
1077
|
proposed: options.proposed?.[field],
|
|
958
1078
|
declared: declaredTypeOf(options.tools, target.tool, field)
|
|
@@ -974,11 +1094,27 @@ async function repairInputs(options) {
|
|
|
974
1094
|
* 이 호출이 끝나면 사라진다(§4.6).
|
|
975
1095
|
*/
|
|
976
1096
|
const cache = /* @__PURE__ */ new Map();
|
|
1097
|
+
/**
|
|
1098
|
+
* 이 값을 누가 정했는가(#390 사양 0).
|
|
1099
|
+
*
|
|
1100
|
+
* 캐시에서 재사용한 필드는 **값을 준 케이스의 출처를 그대로 물려받는다.** 캐시가 값만
|
|
1101
|
+
* 담으면 사람이 AI 제안을 고쳐 넣은 값이 뒤 케이스에서 "AI 제안" 으로 찍혀 화면이
|
|
1102
|
+
* 거짓말을 한다.
|
|
1103
|
+
*
|
|
1104
|
+
* 비교는 `===` 가 아니라 `canonicalJson` 이다. 제안값이 객체나 배열일 수 있다.
|
|
1105
|
+
*/
|
|
1106
|
+
const originOf = (tool, field, value, proposed) => {
|
|
1107
|
+
const cached = cache.get(cacheKey(tool, field));
|
|
1108
|
+
if (cached !== void 0 && (0, _mcpeak_runner.canonicalJson)(cached.value) === (0, _mcpeak_runner.canonicalJson)(value)) return cached.origin;
|
|
1109
|
+
if (proposed !== void 0 && Object.hasOwn(proposed, field) && (0, _mcpeak_runner.canonicalJson)(proposed[field]) === (0, _mcpeak_runner.canonicalJson)(value)) return "aiProposed";
|
|
1110
|
+
return "humanRepaired";
|
|
1111
|
+
};
|
|
977
1112
|
/** §8.6.3 을 이미 찍은 키. 같은 안내를 케이스마다 되풀이하지 않는다. */
|
|
978
1113
|
const announced = /* @__PURE__ */ new Set();
|
|
979
1114
|
for (const [index, target] of options.targets.entries()) {
|
|
980
|
-
options.io.write(`${HEAD}[${index + 1}] ${target.caseName}\n`);
|
|
981
|
-
options.io.write(`${
|
|
1115
|
+
options.io.write(`${HEAD}[${index + 1}/${options.targets.length}] ${target.caseName}\n`);
|
|
1116
|
+
options.io.write(`${BODY}${target.failureLine}\n`);
|
|
1117
|
+
for (const line of target.serverMessage.split("\n").filter((line) => line !== "")) options.io.write(`${BODY}→ ${line}\n`);
|
|
982
1118
|
options.io.write("\n");
|
|
983
1119
|
const attempts = [];
|
|
984
1120
|
const fields = Object.keys(target.input);
|
|
@@ -997,7 +1133,7 @@ async function repairInputs(options) {
|
|
|
997
1133
|
const first = await askRound(options.io, target, {
|
|
998
1134
|
current: target.input,
|
|
999
1135
|
proposed,
|
|
1000
|
-
reuse: new Map(reused.map((field) => [field, cache.get(cacheKey(target.tool, field))])),
|
|
1136
|
+
reuse: new Map(reused.map((field) => [field, cache.get(cacheKey(target.tool, field)).value])),
|
|
1001
1137
|
tools: options.tools
|
|
1002
1138
|
});
|
|
1003
1139
|
if (first.unchanged) {
|
|
@@ -1013,10 +1149,17 @@ async function repairInputs(options) {
|
|
|
1013
1149
|
for (const [field, value] of Object.entries(first.input)) attempts.push({
|
|
1014
1150
|
field,
|
|
1015
1151
|
value,
|
|
1016
|
-
passed: firstVerdict.passed
|
|
1152
|
+
passed: firstVerdict.passed,
|
|
1153
|
+
origin: originOf(target.tool, field, value, proposed)
|
|
1017
1154
|
});
|
|
1018
1155
|
if (firstVerdict.passed) {
|
|
1019
|
-
for (const field of first.asked)
|
|
1156
|
+
for (const field of first.asked) {
|
|
1157
|
+
const value = first.input[field];
|
|
1158
|
+
cache.set(cacheKey(target.tool, field), {
|
|
1159
|
+
value,
|
|
1160
|
+
origin: originOf(target.tool, field, value, proposed)
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1020
1163
|
options.io.write(`${PASSED_LINE}\n`);
|
|
1021
1164
|
outcomes.push({
|
|
1022
1165
|
caseId: target.caseId,
|
|
@@ -1054,10 +1197,17 @@ async function repairInputs(options) {
|
|
|
1054
1197
|
for (const [field, value] of Object.entries(second.input)) attempts.push({
|
|
1055
1198
|
field,
|
|
1056
1199
|
value,
|
|
1057
|
-
passed: secondVerdict.passed
|
|
1200
|
+
passed: secondVerdict.passed,
|
|
1201
|
+
origin: originOf(target.tool, field, value, void 0)
|
|
1058
1202
|
});
|
|
1059
1203
|
if (secondVerdict.passed) {
|
|
1060
|
-
for (const field of second.asked)
|
|
1204
|
+
for (const field of second.asked) {
|
|
1205
|
+
const value = second.input[field];
|
|
1206
|
+
cache.set(cacheKey(target.tool, field), {
|
|
1207
|
+
value,
|
|
1208
|
+
origin: originOf(target.tool, field, value, void 0)
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1061
1211
|
options.io.write(`${PASSED_LINE}\n`);
|
|
1062
1212
|
outcomes.push({
|
|
1063
1213
|
caseId: target.caseId,
|
|
@@ -1077,7 +1227,208 @@ async function repairInputs(options) {
|
|
|
1077
1227
|
return outcomes;
|
|
1078
1228
|
}
|
|
1079
1229
|
//#endregion
|
|
1230
|
+
//#region src/reset-hook.ts
|
|
1231
|
+
/**
|
|
1232
|
+
* 시험 실행 직전에 서버 상태를 되돌리는 초기화 명령을 실행한다. ADR-0023 의 결정을 구현한다.
|
|
1233
|
+
*
|
|
1234
|
+
* 셸을 거치지 않는다. `;`·`&&`·백틱은 해석되지 않고 그대로 인자가 된다. 실행 파일을 감싼
|
|
1235
|
+
* 큰따옴표만 경계로 인식하며 인자의 따옴표는 해석하지 않는다. 공백이 든 인자가 필요하면
|
|
1236
|
+
* 사용자가 스크립트 파일을 만들어야 한다. 이 제약은 도움말에 적힌 사양이지 구현 편의가 아니다.
|
|
1237
|
+
*/
|
|
1238
|
+
/** 초기화 명령의 제한 시간. 설계 문서 §6. */
|
|
1239
|
+
const TIMEOUT_MS = 6e4;
|
|
1240
|
+
/** 보관하는 stderr 상한. 실패 안내에 쓰는 꼬리만 있으면 되고 그 이상은 화면을 덮는다. */
|
|
1241
|
+
const MAX_STDERR_BYTES = 8 * 1024;
|
|
1242
|
+
/** 제한 시간 초과일 때 `stderr` 에 넣는 문장. 종료 코드가 없으므로 사유가 여기에만 남는다. */
|
|
1243
|
+
const TIMEOUT_STDERR = `타임아웃(60초)`;
|
|
1244
|
+
/**
|
|
1245
|
+
* 초기화 명령이 정상 종료하지 못한 경우. 시험 실행을 시작하지 않고 저장도 하지 않는 사유가
|
|
1246
|
+
* 되므로, 호출 측이 화면에 쓸 수 있도록 명령·종료 코드·stderr 을 그대로 들고 다닌다.
|
|
1247
|
+
*
|
|
1248
|
+
* `exitCode` 가 `null` 인 경우는 셋이다. 실행 파일이 없거나(ENOENT), 시그널로 죽었거나,
|
|
1249
|
+
* 제한 시간을 넘겨 우리가 죽였을 때다.
|
|
1250
|
+
*/
|
|
1251
|
+
var ResetCommandError = class extends Error {
|
|
1252
|
+
command;
|
|
1253
|
+
exitCode;
|
|
1254
|
+
stderr;
|
|
1255
|
+
constructor(command, exitCode, stderr) {
|
|
1256
|
+
super(`초기화 명령이 실패했습니다: ${command}`);
|
|
1257
|
+
this.command = command;
|
|
1258
|
+
this.exitCode = exitCode;
|
|
1259
|
+
this.stderr = stderr;
|
|
1260
|
+
this.name = "ResetCommandError";
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
/**
|
|
1264
|
+
* stderr 의 **마지막** 상한만큼을 모은다. 앞부분을 버리는 것이 요점이다. 화면에 쓰는 것은
|
|
1265
|
+
* 마지막 3줄이고, 실패 원인은 출력의 끝에 있다. 앞에서 잘라 보관하면 긴 출력을 내는 명령에서
|
|
1266
|
+
* 정작 사유가 담긴 마지막 줄이 사라진다.
|
|
1267
|
+
*/
|
|
1268
|
+
var StderrTail = class {
|
|
1269
|
+
chunks = [];
|
|
1270
|
+
size = 0;
|
|
1271
|
+
push(chunk) {
|
|
1272
|
+
this.chunks.push(chunk);
|
|
1273
|
+
this.size += chunk.byteLength;
|
|
1274
|
+
while (this.size > MAX_STDERR_BYTES) {
|
|
1275
|
+
const first = this.chunks[0];
|
|
1276
|
+
if (first === void 0) break;
|
|
1277
|
+
const excess = this.size - MAX_STDERR_BYTES;
|
|
1278
|
+
if (first.byteLength <= excess) {
|
|
1279
|
+
this.chunks.shift();
|
|
1280
|
+
this.size -= first.byteLength;
|
|
1281
|
+
} else {
|
|
1282
|
+
this.chunks[0] = first.subarray(excess);
|
|
1283
|
+
this.size -= excess;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
text() {
|
|
1288
|
+
return Buffer.concat(this.chunks).toString("utf8");
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
/**
|
|
1292
|
+
* 초기화 명령을 한 번 실행한다. 재시도하지 않는다.
|
|
1293
|
+
*
|
|
1294
|
+
* stdout 은 아예 받지 않는다. 성공 경로에서 남의 명령 출력을 우리 화면에 섞지 않기 위함이고,
|
|
1295
|
+
* 버퍼 상한에 걸려 멀쩡한 명령이 죽는 일도 없앤다.
|
|
1296
|
+
*
|
|
1297
|
+
* 빈 문자열이나 공백뿐인 명령은 `ResetCommandError` 가 아니라 `TypeError` 다. 옵션 파싱에서
|
|
1298
|
+
* 이미 걸러야 하는 값이고, 여기까지 왔다면 사용자 입력 문제가 아니라 호출 측 결함이다.
|
|
1299
|
+
*/
|
|
1300
|
+
async function runResetCommand(command) {
|
|
1301
|
+
let tokens;
|
|
1302
|
+
try {
|
|
1303
|
+
tokens = (0, _mcpeak_core.tokenizeCommand)(command);
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
if (!(error instanceof _mcpeak_core.CommandParseError)) throw error;
|
|
1306
|
+
const reason = {
|
|
1307
|
+
EMPTY_EXECUTABLE: "실행 파일 경로가 비어 있습니다.",
|
|
1308
|
+
UNTERMINATED_EXECUTABLE_QUOTE: "실행 파일을 감싼 큰따옴표가 닫히지 않았습니다.",
|
|
1309
|
+
MISSING_EXECUTABLE_ARGUMENT_SEPARATOR: "실행 파일을 감싼 큰따옴표 뒤에는 공백이 필요합니다."
|
|
1310
|
+
}[error.code];
|
|
1311
|
+
throw new ResetCommandError(command, null, `잘못된 초기화 명령: ${reason}`);
|
|
1312
|
+
}
|
|
1313
|
+
const [file, ...args] = tokens;
|
|
1314
|
+
if (file === void 0) throw new TypeError("초기화 명령이 비어 있습니다.");
|
|
1315
|
+
return new Promise((resolve, reject) => {
|
|
1316
|
+
const stderr = new StderrTail();
|
|
1317
|
+
let timedOut = false;
|
|
1318
|
+
let settled = false;
|
|
1319
|
+
const child = (0, node_child_process.spawn)(file, args, {
|
|
1320
|
+
stdio: [
|
|
1321
|
+
"ignore",
|
|
1322
|
+
"ignore",
|
|
1323
|
+
"pipe"
|
|
1324
|
+
],
|
|
1325
|
+
shell: false
|
|
1326
|
+
});
|
|
1327
|
+
const timer = setTimeout(() => {
|
|
1328
|
+
timedOut = true;
|
|
1329
|
+
child.kill("SIGKILL");
|
|
1330
|
+
}, TIMEOUT_MS);
|
|
1331
|
+
const finish = (settle) => {
|
|
1332
|
+
if (settled) return;
|
|
1333
|
+
settled = true;
|
|
1334
|
+
clearTimeout(timer);
|
|
1335
|
+
settle();
|
|
1336
|
+
};
|
|
1337
|
+
child.stderr?.on("data", (chunk) => {
|
|
1338
|
+
stderr.push(chunk);
|
|
1339
|
+
});
|
|
1340
|
+
child.on("error", (error) => {
|
|
1341
|
+
finish(() => {
|
|
1342
|
+
reject(new ResetCommandError(command, null, error.message));
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
child.on("close", (code, signal) => {
|
|
1346
|
+
finish(() => {
|
|
1347
|
+
if (timedOut) {
|
|
1348
|
+
reject(new ResetCommandError(command, null, TIMEOUT_STDERR));
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
if (code === 0) {
|
|
1352
|
+
resolve();
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
reject(new ResetCommandError(command, code, stderr.text() || describeSignal(signal)));
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
/** stderr 이 비어 있는데 시그널로 죽었으면 그 사실만이라도 남긴다. */
|
|
1361
|
+
const describeSignal = (signal) => signal === null ? "" : `시그널 ${signal} 로 종료되었습니다.`;
|
|
1362
|
+
/** 등급이 "같은 초기 상태" 를 보장하는가. 후보 비교의 전제다. */
|
|
1363
|
+
const resetIsComparable = (grade) => grade === "freshProcess" || grade === "commandOnly";
|
|
1364
|
+
/**
|
|
1365
|
+
* 등급별 화면 문안. **상수로 둔다.** 호출부에서 문자열을 조립하면 같은 등급이 화면마다
|
|
1366
|
+
* 다른 문장으로 나가고, 사용자는 두 화면이 같은 것을 말하는지 알 수 없다.
|
|
1367
|
+
*/
|
|
1368
|
+
const RESET_GRADE_LINE = {
|
|
1369
|
+
freshProcess: "초기 상태: 서버를 다시 띄우고 초기화 명령을 실행했습니다.",
|
|
1370
|
+
commandOnly: "초기 상태: 초기화 명령만 실행했습니다. 서버 프로세스의 메모리 상태는 그대로입니다.",
|
|
1371
|
+
none: "초기 상태: 초기화 수단이 없습니다. 앞 실행이 바꾼 상태가 남아 있을 수 있습니다. --reset-cmd 로 지정하세요.",
|
|
1372
|
+
failed: "초기 상태: 초기화 명령이 실패했습니다. 이 실행의 결과를 검증 완료로 보지 않습니다."
|
|
1373
|
+
};
|
|
1374
|
+
/**
|
|
1375
|
+
* 초기화를 시도하고 등급을 돌려준다. 던지지 않는다. 호출부가 등급을 보고 판단한다.
|
|
1376
|
+
*
|
|
1377
|
+
* `runResetCommand` 를 그대로 두는 이유: 그 함수는 "명령이 실패하면 던진다" 는 계약이고
|
|
1378
|
+
* 시험 실행 직전 경로가 그 계약에 의존한다(실패하면 실행을 시작하지 않는다). 등급이
|
|
1379
|
+
* 필요한 자리는 판단이 다르다. 실패해도 진행하되 무엇을 못 했는지 적는다.
|
|
1380
|
+
*
|
|
1381
|
+
* `ResetCommandError` 가 아닌 오류는 **던진다.** 우리 결함을 등급으로 위장하면 초기화가
|
|
1382
|
+
* 안 된 것과 우리 코드가 깨진 것이 화면에서 같아 보인다.
|
|
1383
|
+
*/
|
|
1384
|
+
async function attemptReset(resetCmd) {
|
|
1385
|
+
if (resetCmd === void 0) return { grade: "none" };
|
|
1386
|
+
try {
|
|
1387
|
+
await runResetCommand(resetCmd);
|
|
1388
|
+
return { grade: "commandOnly" };
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
if (!(error instanceof ResetCommandError)) throw error;
|
|
1391
|
+
return {
|
|
1392
|
+
grade: "failed",
|
|
1393
|
+
error
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
//#endregion
|
|
1080
1398
|
//#region src/pre-fill-wiring.ts
|
|
1399
|
+
/** 두 후보를 같은 초기 상태에서 못 돌렸을 때의 첫 줄. 아래에 등급 문장이 이어 붙는다. */
|
|
1400
|
+
const COMPARE_SKIPPED_HEAD = "사전보완 비교를 건너뜁니다: 두 후보를 같은 초기 상태에서 실행할 수 없습니다.";
|
|
1401
|
+
/**
|
|
1402
|
+
* 비교 불가 사유. 앞·사이 두 등급 중 비교 불가인 것의 문장을 이어 붙인다.
|
|
1403
|
+
*
|
|
1404
|
+
* 둘이 같은 등급이면 한 번만 찍는다. 다르면 **둘 다 찍는다.** `failed` 가 `none` 보다
|
|
1405
|
+
* 약하다고 보지 않는다. 둘은 다음에 할 일이 다르다. `none` 은 `--reset-cmd` 를 주는 것이고
|
|
1406
|
+
* `failed` 는 그 명령을 고치는 것이다.
|
|
1407
|
+
*/
|
|
1408
|
+
function compareSkippedReason(before, between) {
|
|
1409
|
+
const lines = [];
|
|
1410
|
+
for (const grade of [before, between]) if (!resetIsComparable(grade) && !lines.includes(RESET_GRADE_LINE[grade])) lines.push(RESET_GRADE_LINE[grade]);
|
|
1411
|
+
return [COMPARE_SKIPPED_HEAD, ...lines].join("\n");
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* 시험 실행 상세의 본문 들여쓰기. `dry-run.ts` 의 `INDENT` 와 같은 값이다.
|
|
1415
|
+
* 그 파일을 다른 태스크가 고치고 있어 import 하지 않고 여기에 둔다.
|
|
1416
|
+
*/
|
|
1417
|
+
const DETAIL_INDENT = " ";
|
|
1418
|
+
/** 서버 위반 줄의 접두사. 들여쓰기 뒤에 붙는다. */
|
|
1419
|
+
const VIOLATION_PREFIX = `${DETAIL_INDENT}→ `;
|
|
1420
|
+
/**
|
|
1421
|
+
* 시험 실행 상세에서 서버가 낸 위반 줄만 뽑아 개행으로 잇는다.
|
|
1422
|
+
* 위반 줄이 없으면 들여쓰기가 있는 첫 본문 줄을 그대로 쓴다. 그것도 없으면 빈 문자열이다.
|
|
1423
|
+
* 관측하지 못한 것을 관측했다고 적지 않는다(설계 §4.1).
|
|
1424
|
+
*/
|
|
1425
|
+
function serverMessageOf$1(detail) {
|
|
1426
|
+
const lines = detail.split("\n");
|
|
1427
|
+
const violations = lines.filter((line) => line.startsWith(VIOLATION_PREFIX)).map((line) => line.slice(VIOLATION_PREFIX.length));
|
|
1428
|
+
if (violations.length > 0) return violations.join("\n");
|
|
1429
|
+
const first = lines.find((line) => line.startsWith(DETAIL_INDENT) && line.trim() !== "");
|
|
1430
|
+
return first === void 0 ? "" : first.slice(4);
|
|
1431
|
+
}
|
|
1081
1432
|
const plainObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1082
1433
|
/** 케이스 하나에 제안 값들을 얹은 입력. 원본을 바꾸지 않는다. */
|
|
1083
1434
|
function withProposals(input, proposals) {
|
|
@@ -1135,8 +1486,39 @@ async function applyPreFill(options) {
|
|
|
1135
1486
|
suite: baseline,
|
|
1136
1487
|
cases: [],
|
|
1137
1488
|
adopted: 0,
|
|
1138
|
-
notAdopted: 0
|
|
1489
|
+
notAdopted: 0,
|
|
1490
|
+
held: 0,
|
|
1491
|
+
excluded: [],
|
|
1492
|
+
compared: true
|
|
1139
1493
|
};
|
|
1494
|
+
const excluded = [];
|
|
1495
|
+
const specById = new Map(baseline.cases.map((item) => [item.id, item]));
|
|
1496
|
+
for (const [caseId, proposals] of byCase) {
|
|
1497
|
+
const item = specById.get(caseId);
|
|
1498
|
+
for (const proposal of proposals) {
|
|
1499
|
+
if (item === void 0) {
|
|
1500
|
+
excluded.push({
|
|
1501
|
+
caseId,
|
|
1502
|
+
field: proposal.field,
|
|
1503
|
+
reason: "명세에 없는 케이스"
|
|
1504
|
+
});
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
if (item.operation.type !== "callTool") {
|
|
1508
|
+
excluded.push({
|
|
1509
|
+
caseId,
|
|
1510
|
+
field: proposal.field,
|
|
1511
|
+
reason: "callTool 이 아닌 케이스"
|
|
1512
|
+
});
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
if (!plainObject$1(item.operation.input)) excluded.push({
|
|
1516
|
+
caseId,
|
|
1517
|
+
field: proposal.field,
|
|
1518
|
+
reason: "입력이 객체가 아닌 케이스"
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1140
1522
|
const targetIds = /* @__PURE__ */ new Set();
|
|
1141
1523
|
const aiInputs = /* @__PURE__ */ new Map();
|
|
1142
1524
|
for (const item of baseline.cases) {
|
|
@@ -1152,31 +1534,44 @@ async function applyPreFill(options) {
|
|
|
1152
1534
|
suite: baseline,
|
|
1153
1535
|
cases: [],
|
|
1154
1536
|
adopted: 0,
|
|
1155
|
-
notAdopted: 0
|
|
1537
|
+
notAdopted: 0,
|
|
1538
|
+
held: 0,
|
|
1539
|
+
excluded,
|
|
1540
|
+
compared: true
|
|
1156
1541
|
};
|
|
1542
|
+
const resetOnce = options.reset ?? (async () => "none");
|
|
1543
|
+
const gradeBefore = await resetOnce();
|
|
1157
1544
|
const baselineRun = await run({
|
|
1158
1545
|
client,
|
|
1159
1546
|
suite: subsetSuite(baseline, targetIds)
|
|
1160
1547
|
});
|
|
1548
|
+
const gradeBetween = await resetOnce();
|
|
1161
1549
|
const aiRun = await run({
|
|
1162
1550
|
client,
|
|
1163
1551
|
suite: subsetSuite(baseline, targetIds, aiInputs)
|
|
1164
1552
|
});
|
|
1553
|
+
const comparable = resetIsComparable(gradeBefore) && resetIsComparable(gradeBetween);
|
|
1165
1554
|
const aborted = baselineRun.aborted !== void 0 || aiRun.aborted !== void 0;
|
|
1166
1555
|
const baselinePassed = passedIds(baselineRun);
|
|
1167
1556
|
const aiPassed = passedIds(aiRun);
|
|
1557
|
+
const aiDetails = new Map(aiRun.outcomes.map((outcome) => [outcome.caseId, outcome.detail]));
|
|
1168
1558
|
const cases = [];
|
|
1169
1559
|
const adoptedIds = /* @__PURE__ */ new Set();
|
|
1170
1560
|
for (const item of baseline.cases) {
|
|
1171
1561
|
if (!targetIds.has(item.id)) continue;
|
|
1172
1562
|
const basePass = baselinePassed.has(item.id);
|
|
1173
1563
|
const aiPass = aiPassed.has(item.id);
|
|
1174
|
-
const useAi = !aborted && !basePass && aiPass;
|
|
1564
|
+
const useAi = comparable && !aborted && !basePass && aiPass;
|
|
1175
1565
|
if (useAi) adoptedIds.add(item.id);
|
|
1566
|
+
const held = comparable && !aborted && !basePass && !aiPass;
|
|
1176
1567
|
cases.push({
|
|
1177
1568
|
caseId: item.id,
|
|
1178
1569
|
source: useAi ? "ai" : "baseline",
|
|
1179
|
-
needsClassification:
|
|
1570
|
+
needsClassification: held,
|
|
1571
|
+
...held ? {
|
|
1572
|
+
proposedFields: byCase.get(item.id) ?? [],
|
|
1573
|
+
serverMessage: serverMessageOf$1(aiDetails.get(item.id) ?? "")
|
|
1574
|
+
} : {}
|
|
1180
1575
|
});
|
|
1181
1576
|
}
|
|
1182
1577
|
const adopted = adoptedIds.size;
|
|
@@ -1187,7 +1582,11 @@ async function applyPreFill(options) {
|
|
|
1187
1582
|
},
|
|
1188
1583
|
cases,
|
|
1189
1584
|
adopted,
|
|
1190
|
-
notAdopted: cases.length - adopted
|
|
1585
|
+
notAdopted: cases.length - adopted,
|
|
1586
|
+
held: cases.filter((item) => item.needsClassification).length,
|
|
1587
|
+
excluded,
|
|
1588
|
+
compared: comparable,
|
|
1589
|
+
...comparable ? {} : { skippedReason: compareSkippedReason(gradeBefore, gradeBetween) }
|
|
1191
1590
|
};
|
|
1192
1591
|
}
|
|
1193
1592
|
/** 점 표기 경로를 따라 필드 스키마를 찾는다. 못 찾으면 undefined 다. */
|
|
@@ -1469,196 +1868,88 @@ async function proposeRepair(options) {
|
|
|
1469
1868
|
} catch {
|
|
1470
1869
|
return UNAVAILABLE;
|
|
1471
1870
|
}
|
|
1472
|
-
}
|
|
1473
|
-
//#endregion
|
|
1474
|
-
//#region src/repair-target.ts
|
|
1475
|
-
/** `renderReport` 가 케이스 본문 줄에 쓰는 들여쓰기. dry-run.ts 의 INDENT 와 같은 값이다. */
|
|
1476
|
-
const INDENT = " ";
|
|
1477
|
-
/** 단언 진단의 위반 줄 표시. 이 줄에만 서버가 돌려준 값이 실려 있다. */
|
|
1478
|
-
const VIOLATION_MARK = "→ ";
|
|
1479
|
-
/** 실패 사유 판정에 쓰는 단언 타입. 이 줄이 있어야 "정상 응답을 기대했는데 오류" 다. */
|
|
1480
|
-
const IS_ERROR = "isError";
|
|
1481
|
-
/**
|
|
1482
|
-
const
|
|
1483
|
-
/**
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
*
|
|
1490
|
-
*
|
|
1491
|
-
*
|
|
1492
|
-
*/
|
|
1493
|
-
const
|
|
1494
|
-
/**
|
|
1495
|
-
*
|
|
1496
|
-
*
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
const
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
* 큰따옴표만 경계로 인식하며 인자의 따옴표는 해석하지 않는다. 공백이 든 인자가 필요하면
|
|
1536
|
-
* 사용자가 스크립트 파일을 만들어야 한다. 이 제약은 도움말에 적힌 사양이지 구현 편의가 아니다.
|
|
1537
|
-
*/
|
|
1538
|
-
/** 초기화 명령의 제한 시간. 설계 문서 §6. */
|
|
1539
|
-
const TIMEOUT_MS = 6e4;
|
|
1540
|
-
/** 보관하는 stderr 상한. 실패 안내에 쓰는 꼬리만 있으면 되고 그 이상은 화면을 덮는다. */
|
|
1541
|
-
const MAX_STDERR_BYTES = 8 * 1024;
|
|
1542
|
-
/** 제한 시간 초과일 때 `stderr` 에 넣는 문장. 종료 코드가 없으므로 사유가 여기에만 남는다. */
|
|
1543
|
-
const TIMEOUT_STDERR = `타임아웃(60초)`;
|
|
1544
|
-
/**
|
|
1545
|
-
* 초기화 명령이 정상 종료하지 못한 경우. 시험 실행을 시작하지 않고 저장도 하지 않는 사유가
|
|
1546
|
-
* 되므로, 호출 측이 화면에 쓸 수 있도록 명령·종료 코드·stderr 을 그대로 들고 다닌다.
|
|
1547
|
-
*
|
|
1548
|
-
* `exitCode` 가 `null` 인 경우는 셋이다. 실행 파일이 없거나(ENOENT), 시그널로 죽었거나,
|
|
1549
|
-
* 제한 시간을 넘겨 우리가 죽였을 때다.
|
|
1550
|
-
*/
|
|
1551
|
-
var ResetCommandError = class extends Error {
|
|
1552
|
-
command;
|
|
1553
|
-
exitCode;
|
|
1554
|
-
stderr;
|
|
1555
|
-
constructor(command, exitCode, stderr) {
|
|
1556
|
-
super(`초기화 명령이 실패했습니다: ${command}`);
|
|
1557
|
-
this.command = command;
|
|
1558
|
-
this.exitCode = exitCode;
|
|
1559
|
-
this.stderr = stderr;
|
|
1560
|
-
this.name = "ResetCommandError";
|
|
1561
|
-
}
|
|
1562
|
-
};
|
|
1563
|
-
/**
|
|
1564
|
-
* stderr 의 **마지막** 상한만큼을 모은다. 앞부분을 버리는 것이 요점이다. 화면에 쓰는 것은
|
|
1565
|
-
* 마지막 3줄이고, 실패 원인은 출력의 끝에 있다. 앞에서 잘라 보관하면 긴 출력을 내는 명령에서
|
|
1566
|
-
* 정작 사유가 담긴 마지막 줄이 사라진다.
|
|
1567
|
-
*/
|
|
1568
|
-
var StderrTail = class {
|
|
1569
|
-
chunks = [];
|
|
1570
|
-
size = 0;
|
|
1571
|
-
push(chunk) {
|
|
1572
|
-
this.chunks.push(chunk);
|
|
1573
|
-
this.size += chunk.byteLength;
|
|
1574
|
-
while (this.size > MAX_STDERR_BYTES) {
|
|
1575
|
-
const first = this.chunks[0];
|
|
1576
|
-
if (first === void 0) break;
|
|
1577
|
-
const excess = this.size - MAX_STDERR_BYTES;
|
|
1578
|
-
if (first.byteLength <= excess) {
|
|
1579
|
-
this.chunks.shift();
|
|
1580
|
-
this.size -= first.byteLength;
|
|
1581
|
-
} else {
|
|
1582
|
-
this.chunks[0] = first.subarray(excess);
|
|
1583
|
-
this.size -= excess;
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
}
|
|
1587
|
-
text() {
|
|
1588
|
-
return Buffer.concat(this.chunks).toString("utf8");
|
|
1589
|
-
}
|
|
1590
|
-
};
|
|
1591
|
-
/**
|
|
1592
|
-
* 초기화 명령을 한 번 실행한다. 재시도하지 않는다.
|
|
1593
|
-
*
|
|
1594
|
-
* stdout 은 아예 받지 않는다. 성공 경로에서 남의 명령 출력을 우리 화면에 섞지 않기 위함이고,
|
|
1595
|
-
* 버퍼 상한에 걸려 멀쩡한 명령이 죽는 일도 없앤다.
|
|
1596
|
-
*
|
|
1597
|
-
* 빈 문자열이나 공백뿐인 명령은 `ResetCommandError` 가 아니라 `TypeError` 다. 옵션 파싱에서
|
|
1598
|
-
* 이미 걸러야 하는 값이고, 여기까지 왔다면 사용자 입력 문제가 아니라 호출 측 결함이다.
|
|
1599
|
-
*/
|
|
1600
|
-
async function runResetCommand(command) {
|
|
1601
|
-
let tokens;
|
|
1602
|
-
try {
|
|
1603
|
-
tokens = (0, _mcpeak_core.tokenizeCommand)(command);
|
|
1604
|
-
} catch (error) {
|
|
1605
|
-
if (!(error instanceof _mcpeak_core.CommandParseError)) throw error;
|
|
1606
|
-
const reason = {
|
|
1607
|
-
EMPTY_EXECUTABLE: "실행 파일 경로가 비어 있습니다.",
|
|
1608
|
-
UNTERMINATED_EXECUTABLE_QUOTE: "실행 파일을 감싼 큰따옴표가 닫히지 않았습니다.",
|
|
1609
|
-
MISSING_EXECUTABLE_ARGUMENT_SEPARATOR: "실행 파일을 감싼 큰따옴표 뒤에는 공백이 필요합니다."
|
|
1610
|
-
}[error.code];
|
|
1611
|
-
throw new ResetCommandError(command, null, `잘못된 초기화 명령: ${reason}`);
|
|
1612
|
-
}
|
|
1613
|
-
const [file, ...args] = tokens;
|
|
1614
|
-
if (file === void 0) throw new TypeError("초기화 명령이 비어 있습니다.");
|
|
1615
|
-
return new Promise((resolve, reject) => {
|
|
1616
|
-
const stderr = new StderrTail();
|
|
1617
|
-
let timedOut = false;
|
|
1618
|
-
let settled = false;
|
|
1619
|
-
const child = (0, node_child_process.spawn)(file, args, {
|
|
1620
|
-
stdio: [
|
|
1621
|
-
"ignore",
|
|
1622
|
-
"ignore",
|
|
1623
|
-
"pipe"
|
|
1624
|
-
],
|
|
1625
|
-
shell: false
|
|
1626
|
-
});
|
|
1627
|
-
const timer = setTimeout(() => {
|
|
1628
|
-
timedOut = true;
|
|
1629
|
-
child.kill("SIGKILL");
|
|
1630
|
-
}, TIMEOUT_MS);
|
|
1631
|
-
const finish = (settle) => {
|
|
1632
|
-
if (settled) return;
|
|
1633
|
-
settled = true;
|
|
1634
|
-
clearTimeout(timer);
|
|
1635
|
-
settle();
|
|
1636
|
-
};
|
|
1637
|
-
child.stderr?.on("data", (chunk) => {
|
|
1638
|
-
stderr.push(chunk);
|
|
1639
|
-
});
|
|
1640
|
-
child.on("error", (error) => {
|
|
1641
|
-
finish(() => {
|
|
1642
|
-
reject(new ResetCommandError(command, null, error.message));
|
|
1643
|
-
});
|
|
1644
|
-
});
|
|
1645
|
-
child.on("close", (code, signal) => {
|
|
1646
|
-
finish(() => {
|
|
1647
|
-
if (timedOut) {
|
|
1648
|
-
reject(new ResetCommandError(command, null, TIMEOUT_STDERR));
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
|
-
if (code === 0) {
|
|
1652
|
-
resolve();
|
|
1653
|
-
return;
|
|
1654
|
-
}
|
|
1655
|
-
reject(new ResetCommandError(command, code, stderr.text() || describeSignal(signal)));
|
|
1871
|
+
}
|
|
1872
|
+
//#endregion
|
|
1873
|
+
//#region src/repair-target.ts
|
|
1874
|
+
/** `renderReport` 가 케이스 본문 줄에 쓰는 들여쓰기. dry-run.ts 의 INDENT 와 같은 값이다. */
|
|
1875
|
+
const INDENT = " ";
|
|
1876
|
+
/** 단언 진단의 위반 줄 표시. 이 줄에만 서버가 돌려준 값이 실려 있다. */
|
|
1877
|
+
const VIOLATION_MARK = "→ ";
|
|
1878
|
+
/** 실패 사유 판정에 쓰는 단언 타입. 이 줄이 있어야 "정상 응답을 기대했는데 오류" 다. */
|
|
1879
|
+
const IS_ERROR = "isError";
|
|
1880
|
+
/** `renderReport` 가 건너뛴 단언 줄에 붙이는 표시. 건너뛴 단언은 실패가 아니다. */
|
|
1881
|
+
const SKIPPED_MARK = "(건너뜀) ";
|
|
1882
|
+
/** 케이스 본문 줄에서 들여쓰기를 벗긴다. 들여쓰기가 없는 줄은 본문이 아니다. */
|
|
1883
|
+
const bodyLines = (detail) => detail.split("\n").filter((line) => line.startsWith(INDENT)).map((line) => line.slice(4));
|
|
1884
|
+
/**
|
|
1885
|
+
* 실패 사유가 `isError` 단언인가. 단언 줄은 타입 이름으로 시작하고, 진단 문장과 해결 줄은
|
|
1886
|
+
* 그렇지 않다.
|
|
1887
|
+
*
|
|
1888
|
+
* **`(건너뜀) ` 이 붙은 줄은 실패가 아니다.** 호출이 끝나지 못하면 단언이 전부 건너뛰기로
|
|
1889
|
+
* 그려지는데, 그것을 실패로 읽어 못 고칠 케이스를 교정 대상에 올리던 것이 이번 결함이다.
|
|
1890
|
+
* 앞단에서 `operationFailed` 로 이미 가르지만 여기서도 방어적으로 뺀다.
|
|
1891
|
+
*/
|
|
1892
|
+
const failedByIsError = (detail) => bodyLines(detail).some((line) => line.startsWith(IS_ERROR) && !line.includes(SKIPPED_MARK));
|
|
1893
|
+
/**
|
|
1894
|
+
* 서버가 돌려준 오류 본문을 뽑는다. 문장을 새로 만들지 않고 위반 줄을 그대로 옮긴다.
|
|
1895
|
+
* `isError` 진단에는 값이 실리지 않으므로 근거가 되는 것은 본문 단언의 위반 줄뿐이다.
|
|
1896
|
+
* 뽑을 것이 없으면 빈 문자열이고, 그때 호출 측은 사람 입력으로 간다(§4.4).
|
|
1897
|
+
*/
|
|
1898
|
+
const serverMessageOf = (detail) => bodyLines(detail).filter((line) => line.startsWith(VIOLATION_MARK)).map((line) => line.slice(2)).join("\n");
|
|
1899
|
+
/**
|
|
1900
|
+
* 위반 케이스인가. ADR-0022 가 만드는 케이스는 입력을 일부러 어긋나게 만든 것이라
|
|
1901
|
+
* 고치면 케이스의 목적이 사라진다. 판별은 `isError` 단언의 `expected` 로 한다.
|
|
1902
|
+
*/
|
|
1903
|
+
const expectsError = (spec) => spec.assertions.some((assertion) => assertion.type === "isError" && assertion.expected === true);
|
|
1904
|
+
/**
|
|
1905
|
+
* 교정 갈래와 고지 갈래가 **공유하는** 앞단 조건. 둘로 나눠 쓰면 조건이 갈리는 날 어느
|
|
1906
|
+
* 케이스가 양쪽에 다 들어가거나 어디에도 안 들어간다.
|
|
1907
|
+
*/
|
|
1908
|
+
const eligible = (spec, outcome, origins) => {
|
|
1909
|
+
if (outcome.status === "passed") return false;
|
|
1910
|
+
if (spec.operation.type !== "callTool") return false;
|
|
1911
|
+
if (Object.keys(spec.operation.input).length === 0) return false;
|
|
1912
|
+
if (origins.get(outcome.caseId) === "user") return false;
|
|
1913
|
+
if (expectsError(spec)) return false;
|
|
1914
|
+
return true;
|
|
1915
|
+
};
|
|
1916
|
+
/**
|
|
1917
|
+
* 교정 대상과 못 고칠 실패를 한 번 순회해 가른다. 두 배열 모두 `outcomes` 순서다.
|
|
1918
|
+
* 정렬하지 않는다. 순서를 바꾸면 화면 번호가 앞선 결과 화면과 어긋난다.
|
|
1919
|
+
*/
|
|
1920
|
+
function selectRepairTargets(options) {
|
|
1921
|
+
const specs = new Map(options.suite.cases.map((spec) => [spec.id, spec]));
|
|
1922
|
+
const targets = [];
|
|
1923
|
+
const thrown = [];
|
|
1924
|
+
for (const outcome of options.outcomes) {
|
|
1925
|
+
const spec = specs.get(outcome.caseId);
|
|
1926
|
+
if (spec === void 0) continue;
|
|
1927
|
+
if (!eligible(spec, outcome, options.origins)) continue;
|
|
1928
|
+
if (outcome.operationFailed) {
|
|
1929
|
+
thrown.push({
|
|
1930
|
+
caseId: outcome.caseId,
|
|
1931
|
+
caseName: outcome.caseName,
|
|
1932
|
+
failureLine: outcome.failureLine,
|
|
1933
|
+
serverMessage: serverMessageOf(outcome.detail)
|
|
1656
1934
|
});
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1937
|
+
if (!failedByIsError(outcome.detail)) continue;
|
|
1938
|
+
if (spec.operation.type !== "callTool") continue;
|
|
1939
|
+
targets.push({
|
|
1940
|
+
caseId: outcome.caseId,
|
|
1941
|
+
caseName: outcome.caseName,
|
|
1942
|
+
tool: spec.operation.tool,
|
|
1943
|
+
input: spec.operation.input,
|
|
1944
|
+
serverMessage: serverMessageOf(outcome.detail),
|
|
1945
|
+
failureLine: outcome.failureLine
|
|
1657
1946
|
});
|
|
1658
|
-
}
|
|
1947
|
+
}
|
|
1948
|
+
return {
|
|
1949
|
+
targets,
|
|
1950
|
+
thrown
|
|
1951
|
+
};
|
|
1659
1952
|
}
|
|
1660
|
-
/** stderr 이 비어 있는데 시그널로 죽었으면 그 사실만이라도 남긴다. */
|
|
1661
|
-
const describeSignal = (signal) => signal === null ? "" : `시그널 ${signal} 로 종료되었습니다.`;
|
|
1662
1953
|
//#endregion
|
|
1663
1954
|
//#region src/generate-command.ts
|
|
1664
1955
|
var UsageError$1 = class extends Error {};
|
|
@@ -1757,6 +2048,7 @@ const optionNames$1 = /* @__PURE__ */ new Set([
|
|
|
1757
2048
|
"--out",
|
|
1758
2049
|
"--command",
|
|
1759
2050
|
"--arg",
|
|
2051
|
+
"--env",
|
|
1760
2052
|
"--url",
|
|
1761
2053
|
"--header-env",
|
|
1762
2054
|
"--baseline-only",
|
|
@@ -1764,7 +2056,9 @@ const optionNames$1 = /* @__PURE__ */ new Set([
|
|
|
1764
2056
|
"--model",
|
|
1765
2057
|
"--no-dry-run",
|
|
1766
2058
|
"--reset-cmd",
|
|
2059
|
+
"--fixtures",
|
|
1767
2060
|
"--no-repair",
|
|
2061
|
+
"--diagnose-rejections",
|
|
1768
2062
|
"--force"
|
|
1769
2063
|
]);
|
|
1770
2064
|
/** 값을 받지 않는 옵션. `=` 를 붙여 쓸 수 없고 두 번 쓸 수 없다. */
|
|
@@ -1772,6 +2066,7 @@ const flagNames$1 = /* @__PURE__ */ new Set([
|
|
|
1772
2066
|
"--baseline-only",
|
|
1773
2067
|
"--no-dry-run",
|
|
1774
2068
|
"--no-repair",
|
|
2069
|
+
"--diagnose-rejections",
|
|
1775
2070
|
"--force"
|
|
1776
2071
|
]);
|
|
1777
2072
|
const removedGenerateOptions = /* @__PURE__ */ new Set(["--cassette", "--record"]);
|
|
@@ -1803,6 +2098,7 @@ function parseGenerateCommand(argv) {
|
|
|
1803
2098
|
const args = [];
|
|
1804
2099
|
const flags = /* @__PURE__ */ new Set();
|
|
1805
2100
|
const headerEnv = createHeaderEnvCollector();
|
|
2101
|
+
const envForward = createEnvForwardCollector();
|
|
1806
2102
|
for (let index = 0; index < argv.length; index++) {
|
|
1807
2103
|
const item = argv[index];
|
|
1808
2104
|
if (item === void 0) continue;
|
|
@@ -1843,6 +2139,13 @@ function parseGenerateCommand(argv) {
|
|
|
1843
2139
|
if (rejected !== void 0) throw new UsageError$1(rejected);
|
|
1844
2140
|
continue;
|
|
1845
2141
|
}
|
|
2142
|
+
if (option === "--env") {
|
|
2143
|
+
const parsed = parseEnvForwardOption(value);
|
|
2144
|
+
if (!parsed.ok) throw new UsageError$1(parsed.message);
|
|
2145
|
+
const rejected = envForward.add(parsed.value);
|
|
2146
|
+
if (rejected !== void 0) throw new UsageError$1(rejected);
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
1846
2149
|
if (values.has(option)) throw new UsageError$1(`\`${option}\`는 한 번만 사용할 수 있습니다.`);
|
|
1847
2150
|
values.set(option, value);
|
|
1848
2151
|
}
|
|
@@ -1859,6 +2162,7 @@ function parseGenerateCommand(argv) {
|
|
|
1859
2162
|
if (command !== void 0 && rawUrl !== void 0) throw new UsageError$1("`--command` 와 `--url` 은 함께 쓸 수 없습니다.\n→ `--command` 는 서버를 프로세스로 띄우고, `--url` 은 이미 떠 있는 원격 서버에 붙습니다.\n→ 둘 중 무엇에서 명세를 뽑을지 하나만 고르세요.");
|
|
1860
2163
|
if (command === void 0 && rawUrl === void 0) throw new UsageError$1("`--command` 또는 `--url` 옵션이 필요합니다.");
|
|
1861
2164
|
if (rawUrl !== void 0 && args.length > 0) throw new UsageError$1("`--arg` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--arg` 는 우리가 띄우는 프로세스에 넘길 인자입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
|
|
2165
|
+
if (rawUrl !== void 0 && envForward.snapshot().length > 0) throw new UsageError$1("`--env` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--env` 는 우리가 띄우는 프로세스의 환경에 넣을 변수입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
|
|
1862
2166
|
if (command !== void 0 && !headerEnv.isEmpty()) throw new UsageError$1("`--header-env` 는 `--url` 과 함께만 쓸 수 있습니다.\n→ 헤더는 HTTP 요청에 실립니다. `--command` 로 띄운 서버와는 stdio 로 이야기합니다.");
|
|
1863
2167
|
const url = (() => {
|
|
1864
2168
|
if (rawUrl === void 0) return void 0;
|
|
@@ -1874,9 +2178,13 @@ function parseGenerateCommand(argv) {
|
|
|
1874
2178
|
const dryRun = !flags.has("--no-dry-run");
|
|
1875
2179
|
const repair = !flags.has("--no-repair");
|
|
1876
2180
|
if (!dryRun && !repair) throw new UsageError$1("`--no-dry-run`과 `--no-repair`는 함께 사용할 수 없습니다.");
|
|
2181
|
+
const diagnoseRejections = flags.has("--diagnose-rejections");
|
|
2182
|
+
if (diagnoseRejections && !dryRun) throw new UsageError$1("`--diagnose-rejections`는 `--no-dry-run`과 함께 사용할 수 없습니다. 시험 실행이 없으면 확인할 거절이 없습니다.");
|
|
1877
2183
|
const resetCmd = values.get("--reset-cmd");
|
|
1878
2184
|
if (!dryRun && resetCmd !== void 0) throw new UsageError$1("`--no-dry-run`과 `--reset-cmd`는 함께 사용할 수 없습니다.");
|
|
1879
2185
|
if (resetCmd !== void 0 && resetCmd.trim() === "") throw new UsageError$1("`--reset-cmd` 옵션 값이 필요합니다.");
|
|
2186
|
+
const fixturesPath = values.get("--fixtures");
|
|
2187
|
+
if (fixturesPath !== void 0 && fixturesPath.trim() === "") throw new UsageError$1("`--fixtures` 옵션 값이 필요합니다.");
|
|
1880
2188
|
return Object.freeze({
|
|
1881
2189
|
suiteId: values.get("--suite-id"),
|
|
1882
2190
|
name: values.get("--name"),
|
|
@@ -1884,7 +2192,8 @@ function parseGenerateCommand(argv) {
|
|
|
1884
2192
|
target: url === void 0 ? Object.freeze({
|
|
1885
2193
|
transport: "stdio",
|
|
1886
2194
|
command,
|
|
1887
|
-
args: Object.freeze(args)
|
|
2195
|
+
args: Object.freeze(args),
|
|
2196
|
+
envNames: envForward.snapshot()
|
|
1888
2197
|
}) : Object.freeze({
|
|
1889
2198
|
transport: "http",
|
|
1890
2199
|
url,
|
|
@@ -1896,7 +2205,9 @@ function parseGenerateCommand(argv) {
|
|
|
1896
2205
|
dryRun,
|
|
1897
2206
|
force: flags.has("--force"),
|
|
1898
2207
|
resetCmd,
|
|
1899
|
-
repair
|
|
2208
|
+
repair,
|
|
2209
|
+
diagnoseRejections,
|
|
2210
|
+
fixturesPath
|
|
1900
2211
|
});
|
|
1901
2212
|
}
|
|
1902
2213
|
/**
|
|
@@ -1963,6 +2274,8 @@ async function saveSuite(input, suite, fingerprint, deps, approvals = []) {
|
|
|
1963
2274
|
}
|
|
1964
2275
|
}
|
|
1965
2276
|
const defaultModel = (provider) => provider === "codex" ? "gpt-5.6-luna" : "haiku";
|
|
2277
|
+
/** 대시보드의 검토 하위 입력에서 상위 메뉴로 돌아갈 때 쓰는 내부 제어값. */
|
|
2278
|
+
const REVIEW_BACK_VALUE = "__mcpeak_review_back__";
|
|
1966
2279
|
/**
|
|
1967
2280
|
* 전송 승인 화면. **통로마다 보내는 것이 다르므로 목록을 받는다**(#286) — 입력값 교정은
|
|
1968
2281
|
* 실패한 케이스의 서버 응답 원문까지 싣는데, 기본 문구로 덮으면 화면이 사실보다 적게 말한다.
|
|
@@ -2194,8 +2507,221 @@ function writeDryRunNotice(io, notice) {
|
|
|
2194
2507
|
}
|
|
2195
2508
|
io.write("\n이 실행은 서버 상태를 바꿀 수 있습니다. 입력 검증이 없는 서버라면 외부 API 호출도\n그대로 나갑니다.\n");
|
|
2196
2509
|
}
|
|
2510
|
+
/** `--fixtures` 를 안 주면 보는 경로. 없으면 조용히 지나간다(설계 §2.3). */
|
|
2511
|
+
const DEFAULT_FIXTURES_PATH = "mcpeak.fixtures.json";
|
|
2512
|
+
/**
|
|
2513
|
+
* 픽스처 파일을 읽는다. 읽을 수 없으면 사유를 stderr 에 적고 `null` 을 돌려준다(호출자가
|
|
2514
|
+
* 종료 코드 1 로 끝낸다).
|
|
2515
|
+
*
|
|
2516
|
+
* 파일 접근은 `deps.exists` · `deps.readFile` 주입점만 쓴다. `node:fs` 를 직접 부르면
|
|
2517
|
+
* 유닛테스트가 실제 파일 시스템을 타게 되고, 그것이 ADR-0013 이 막은 것이다.
|
|
2518
|
+
*/
|
|
2519
|
+
async function loadFixtures(input, deps) {
|
|
2520
|
+
const explicit = input.fixturesPath !== void 0;
|
|
2521
|
+
const path = input.fixturesPath ?? DEFAULT_FIXTURES_PATH;
|
|
2522
|
+
let found;
|
|
2523
|
+
try {
|
|
2524
|
+
found = await deps.exists(path);
|
|
2525
|
+
} catch (error) {
|
|
2526
|
+
if (!explicit) return {};
|
|
2527
|
+
const code = error?.code;
|
|
2528
|
+
deps.writeStderr(`오류 [GENERATE_FIXTURE_UNREADABLE]: 픽스처 파일을 확인하지 못했습니다. 경로: ${path}${typeof code === "string" ? ` (${code})` : ""}\n해결: 그 경로와 상위 디렉터리의 권한을 확인하세요.\n`);
|
|
2529
|
+
return null;
|
|
2530
|
+
}
|
|
2531
|
+
if (!found) {
|
|
2532
|
+
if (!explicit) return {};
|
|
2533
|
+
deps.writeStderr(`오류 [GENERATE_FIXTURE_NOT_FOUND]: 픽스처 파일이 없습니다. 경로: ${path}\n해결: 경로를 확인하거나 \`--fixtures\` 를 빼고 실행하세요.\n`);
|
|
2534
|
+
return null;
|
|
2535
|
+
}
|
|
2536
|
+
let text;
|
|
2537
|
+
try {
|
|
2538
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(await deps.readFile(path));
|
|
2539
|
+
} catch (error) {
|
|
2540
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2541
|
+
deps.writeStderr(`오류 [GENERATE_FIXTURE_UNREADABLE]: 픽스처 파일을 읽지 못했습니다. 경로: ${path} (${escapeTerminalText$2(reason)})\n해결: 파일이 UTF-8 텍스트인지, 읽기 권한이 있는지 확인하세요.\n`);
|
|
2542
|
+
return null;
|
|
2543
|
+
}
|
|
2544
|
+
const read = (0, _mcpeak_generate.readFixtureFile)(text);
|
|
2545
|
+
if (read.status === "invalid") {
|
|
2546
|
+
deps.writeStderr(`오류 [GENERATE_FIXTURE_INVALID]: ${escapeTerminalText$2(read.reason)} 경로: ${path}\n해결: 픽스처 파일을 고친 뒤 다시 실행하세요.\n`);
|
|
2547
|
+
return null;
|
|
2548
|
+
}
|
|
2549
|
+
const tools = Object.values(read.file.tools ?? {});
|
|
2550
|
+
const fields = tools.reduce((total, item) => total + Object.keys(item).length, 0);
|
|
2551
|
+
deps.writeStdout(`▸ 픽스처: ${escapeTerminalText$2(path)} (도구 ${tools.length}개, 필드 ${fields}개)\n`);
|
|
2552
|
+
return { file: read.file };
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* 교정한 값을 픽스처 파일로 되돌린다(#390 T3).
|
|
2556
|
+
*
|
|
2557
|
+
* **기존 파일을 절대 고치지 않는다.** 병합 로직을 만들지 않는다. 파일이 있으면 붙여 넣을
|
|
2558
|
+
* 조각만 찍는다. 덮어쓸 수 있는 primitive 를 두지 않는다는 것이 이 저장소의 결론이고
|
|
2559
|
+
* (`docs/reports/task-r4.md`), `rename` 으로 떨어지고 싶어지는 자리에서 그 결정을
|
|
2560
|
+
* 우회하지 않는다. `openTemp` → `link` → `unlink` 만 쓴다.
|
|
2561
|
+
*/
|
|
2562
|
+
async function writeBackFixtures(input, deps, io, targets, repairs) {
|
|
2563
|
+
const { entries, nestedFields } = collectFixtureWriteback(targets, repairs);
|
|
2564
|
+
if (nestedFields.length > 0) io.write(`▸ 중첩 경로 ${nestedFields.length}건은 픽스처에 적을 수 없습니다(최상위 필드만 받습니다): ${nestedFields.join(", ")}\n`);
|
|
2565
|
+
if (entries.length === 0) return;
|
|
2566
|
+
const path = input.fixturesPath ?? DEFAULT_FIXTURES_PATH;
|
|
2567
|
+
let exists;
|
|
2568
|
+
/** 확인 자체가 실패했을 때의 오류 코드. 파일이 **있는 것과 다르다.** */
|
|
2569
|
+
let checkFailed;
|
|
2570
|
+
try {
|
|
2571
|
+
exists = await deps.exists(path);
|
|
2572
|
+
} catch (error) {
|
|
2573
|
+
exists = true;
|
|
2574
|
+
const code = error?.code;
|
|
2575
|
+
checkFailed = typeof code === "string" ? code : "알 수 없는 오류";
|
|
2576
|
+
}
|
|
2577
|
+
if (checkFailed !== void 0) {
|
|
2578
|
+
io.write(`픽스처 파일이 있는지 확인하지 못해 쓰지 않았습니다: ${path} (${checkFailed})\n아래 조각을 직접 붙여 넣으세요.
|
|
2579
|
+
|
|
2580
|
+
` + renderFixtureSnippet(entries));
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
if (exists) {
|
|
2584
|
+
io.write(`교정한 값 ${entries.length}건을 픽스처에 반영하려면 ${path} 의 "tools" 아래에 붙이세요.\n기존 파일이 있어 우리가 고치지 않습니다.
|
|
2585
|
+
|
|
2586
|
+
` + renderFixtureSnippet(entries));
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
if (!io.interactive) {
|
|
2590
|
+
io.write(`교정한 값 ${entries.length}건을 픽스처로 저장할 수 있습니다. 대화형으로 다시 실행하면 ${path} 에 저장할지 묻습니다.\n\n` + renderFixtureSnippet(entries));
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
io.write(`교정한 값 ${entries.length}건을 ${path} 에 저장할까요?\n`);
|
|
2594
|
+
const width = Math.max(...entries.map((entry) => `${entry.tool}.${entry.field}`.length));
|
|
2595
|
+
for (const entry of entries) io.write(` ${`${entry.tool}.${entry.field}`.padEnd(width)} ${JSON.stringify(entry.value)} ${ORIGIN_LABEL[entry.origin]}\n`);
|
|
2596
|
+
if (!await io.confirm("저장할까요?")) return;
|
|
2597
|
+
const temporary = temporaryPath(path);
|
|
2598
|
+
let created = false;
|
|
2599
|
+
try {
|
|
2600
|
+
const handle = await deps.openTemp(temporary);
|
|
2601
|
+
created = true;
|
|
2602
|
+
try {
|
|
2603
|
+
await handle.writeFile(renderFixtureFile(entries), "utf8");
|
|
2604
|
+
await handle.sync();
|
|
2605
|
+
} finally {
|
|
2606
|
+
await handle.close();
|
|
2607
|
+
}
|
|
2608
|
+
await deps.link(temporary, path);
|
|
2609
|
+
io.write(`▸ 픽스처를 저장했습니다: ${path}\n`);
|
|
2610
|
+
} catch (error) {
|
|
2611
|
+
const code = error?.code;
|
|
2612
|
+
io.write(`▸ 픽스처를 저장하지 못했습니다: ${path}${typeof code === "string" ? ` (${code})` : ""}\n 아래 조각을 직접 붙여 넣으세요.
|
|
2613
|
+
|
|
2614
|
+
` + renderFixtureSnippet(entries));
|
|
2615
|
+
} finally {
|
|
2616
|
+
if (created) await deps.unlink(temporary).catch(() => void 0);
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
/** UTF-16 코드 단위 안정 비교. `localeCompare` 는 로캘·ICU 데이터에 따라 결과가 달라진다. */
|
|
2620
|
+
const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
2621
|
+
/** 출처 표기. `humanRepaired` 와 `aiProposed` 를 갈라 적는다. */
|
|
2622
|
+
const ORIGIN_LABEL = {
|
|
2623
|
+
humanRepaired: "(사람이 입력)",
|
|
2624
|
+
aiProposed: "(AI 제안, 사람이 확인)"
|
|
2625
|
+
};
|
|
2626
|
+
/**
|
|
2627
|
+
* 교정으로 통과한 값을 도구·필드로 모은다.
|
|
2628
|
+
*
|
|
2629
|
+
* 통과한 시도만 본다. 안 통한 값을 픽스처에 적으면 다음 실행이 그 값으로 시작해 같은 이유로
|
|
2630
|
+
* 죽는다. 같은 `(도구, 필드)` 가 여러 케이스에서 나오면 **뒤 케이스가 앞을 덮지 않는다.**
|
|
2631
|
+
* 앞 케이스의 값이 캐시로 뒤에 퍼진 것이라 값이 같고, 다르다면 먼저 통과한 쪽을 쓴다.
|
|
2632
|
+
*/
|
|
2633
|
+
function collectFixtureWriteback(targets, repairs) {
|
|
2634
|
+
const toolOf = new Map(targets.map((item) => [item.caseId, item.tool]));
|
|
2635
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2636
|
+
const nested = /* @__PURE__ */ new Set();
|
|
2637
|
+
for (const repair of repairs) {
|
|
2638
|
+
const tool = toolOf.get(repair.caseId);
|
|
2639
|
+
if (tool === void 0) continue;
|
|
2640
|
+
for (const attempt of repair.attempts) {
|
|
2641
|
+
if (!attempt.passed) continue;
|
|
2642
|
+
if (attempt.field.includes(".") || attempt.field.includes("[")) {
|
|
2643
|
+
nested.add(`${tool}.${attempt.field}`);
|
|
2644
|
+
continue;
|
|
2645
|
+
}
|
|
2646
|
+
const key = `${tool}\u0000${attempt.field}`;
|
|
2647
|
+
if (entries.has(key)) continue;
|
|
2648
|
+
entries.set(key, {
|
|
2649
|
+
tool,
|
|
2650
|
+
field: attempt.field,
|
|
2651
|
+
value: attempt.value,
|
|
2652
|
+
origin: attempt.origin
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
return {
|
|
2657
|
+
entries: [...entries.values()].sort((a, b) => a.tool === b.tool ? byCodeUnit(a.field, b.field) : byCodeUnit(a.tool, b.tool)),
|
|
2658
|
+
nestedFields: [...nested].sort(byCodeUnit)
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
/**
|
|
2662
|
+
* 픽스처 파일 본문. **결정론적이어야 한다.** 도구·필드 이름 코드 단위 오름차순,
|
|
2663
|
+
* `JSON.stringify(x, null, 2)`, 끝 개행 하나다. 순서가 흔들리면 diff 가 매번 커진다.
|
|
2664
|
+
*/
|
|
2665
|
+
function renderFixtureFile(entries) {
|
|
2666
|
+
const tools = {};
|
|
2667
|
+
for (const entry of entries) {
|
|
2668
|
+
const fields = tools[entry.tool] ?? {};
|
|
2669
|
+
fields[entry.field] = entry.value;
|
|
2670
|
+
tools[entry.tool] = fields;
|
|
2671
|
+
}
|
|
2672
|
+
return `${JSON.stringify({
|
|
2673
|
+
schemaVersion: 1,
|
|
2674
|
+
tools
|
|
2675
|
+
}, null, 2)}\n`;
|
|
2676
|
+
}
|
|
2677
|
+
/**
|
|
2678
|
+
* 기존 파일이 있을 때 찍는 조각. **붙여 넣을 수 있는 모양이어야 한다.**
|
|
2679
|
+
*
|
|
2680
|
+
* 사용자가 손으로 JSON 을 고치는 자리라 쉼표 하나가 틀리면 파일이 깨진다. 마지막 줄에
|
|
2681
|
+
* 쉼표를 붙이지 않는다.
|
|
2682
|
+
*/
|
|
2683
|
+
function renderFixtureSnippet(entries) {
|
|
2684
|
+
const byTool = /* @__PURE__ */ new Map();
|
|
2685
|
+
for (const entry of entries) {
|
|
2686
|
+
const fields = byTool.get(entry.tool) ?? {};
|
|
2687
|
+
fields[entry.field] = entry.value;
|
|
2688
|
+
byTool.set(entry.tool, fields);
|
|
2689
|
+
}
|
|
2690
|
+
return `${[...byTool.entries()].map(([tool, fields]) => ` ${JSON.stringify(tool)}: ${JSON.stringify(fields)}`).join(",\n")}\n`;
|
|
2691
|
+
}
|
|
2692
|
+
/**
|
|
2693
|
+
* 실패한 케이스에 붙이는 자리값 안내(설계 §2.2).
|
|
2694
|
+
*
|
|
2695
|
+
* **필드 이름으로 추측하지 않는다.** 판정 근거는 "그 값을 우리가 지어냈는가" 하나이고,
|
|
2696
|
+
* 그것은 `fieldOrigins` 가 말해 주는 사실이다. 이름 표를 만들면 `q` 나 `target` 인 식별자를
|
|
2697
|
+
* 놓치고 이름이 `name` 인 순수 문자열에 잘못 붙는다.
|
|
2698
|
+
*
|
|
2699
|
+
* `userFixture` · `schemaDeclared` 에는 안 붙인다. 사용자가 실재를 보증했거나 서버가 준
|
|
2700
|
+
* 값이라 서버 쪽을 먼저 봐야 한다.
|
|
2701
|
+
*/
|
|
2702
|
+
function placeholderGuidance(caseId, suite, fixtures) {
|
|
2703
|
+
if (fixtures === void 0) return "";
|
|
2704
|
+
const spec = suite.cases.find((item) => item.id === caseId);
|
|
2705
|
+
if (spec === void 0 || spec.operation.type !== "callTool") return "";
|
|
2706
|
+
if (spec.assertions.some((assertion) => assertion.type === "isError" && assertion.expected === true)) return "";
|
|
2707
|
+
const { tool, input } = spec.operation;
|
|
2708
|
+
let text = "";
|
|
2709
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2710
|
+
for (const origin of fixtures.fieldOrigins) {
|
|
2711
|
+
if (origin.tool !== tool || origin.origin !== "schemaHint") continue;
|
|
2712
|
+
if (!Object.hasOwn(input, origin.field) || seen.has(origin.field)) continue;
|
|
2713
|
+
seen.add(origin.field);
|
|
2714
|
+
const field = escapeTerminalText$2(origin.field);
|
|
2715
|
+
const value = escapeTerminalText$2(JSON.stringify(input[origin.field]) ?? "undefined");
|
|
2716
|
+
text += ` ℹ 이 케이스의 '${field}' 값 ${value} 은 스키마의 type 만 보고 만든 자리값입니다.\n 실재하는 자원을 가리키지 않으므로 서버 결함이 아닐 수 있습니다.
|
|
2717
|
+
해결: ${escapeTerminalText$2(fixtures.path)} 에 ${escapeTerminalText$2(tool)}.${field} 값을 지정하세요.\n`;
|
|
2718
|
+
const note = fixtures.notes?.[tool]?.[origin.field];
|
|
2719
|
+
if (note !== void 0) text += ` 메모: ${escapeTerminalText$2(note)}\n`;
|
|
2720
|
+
}
|
|
2721
|
+
return text;
|
|
2722
|
+
}
|
|
2197
2723
|
/** 시험 실행 결과(§8.2). 0건인 종류는 찍지 않는다. */
|
|
2198
|
-
function writeDryRunResult(io, result) {
|
|
2724
|
+
function writeDryRunResult(io, result, suite, fixtures) {
|
|
2199
2725
|
const failures = result.outcomes.filter((outcome) => outcome.status !== "passed");
|
|
2200
2726
|
const passed = result.outcomes.length - failures.length;
|
|
2201
2727
|
io.write("\n");
|
|
@@ -2205,11 +2731,16 @@ function writeDryRunResult(io, result) {
|
|
|
2205
2731
|
io.write("\n");
|
|
2206
2732
|
io.write(failureHeading(index, outcome.caseName));
|
|
2207
2733
|
io.write(detailBlock(outcome.detail));
|
|
2734
|
+
if (suite !== void 0) io.write(placeholderGuidance(outcome.caseId, suite, fixtures));
|
|
2208
2735
|
}
|
|
2209
2736
|
if (failures.length > 0) io.write("\n");
|
|
2210
2737
|
}
|
|
2211
2738
|
/**
|
|
2212
2739
|
* 거절 근거 미확인 목록 (#89 · 설계 문서 §5.2). 시험 실행 결과 블록 바로 아래에 붙는다.
|
|
2740
|
+
* `--diagnose-rejections` 를 켰을 때만 부른다(ADR-0098).
|
|
2741
|
+
*
|
|
2742
|
+
* 무엇을 셀지는 부르는 쪽이 정해 배열로 넘긴다. 여기서 다시 거르면 같은 규칙이 두 곳에
|
|
2743
|
+
* 생기고, 한쪽만 고쳐지면 목록의 건수와 진단 건수가 소리 없이 갈린다.
|
|
2213
2744
|
*
|
|
2214
2745
|
* **이 케이스들은 통과했다.** 목록은 판정도 저장 여부도 바꾸지 않는다. `unverified` 는
|
|
2215
2746
|
* "거절이 아니다" 가 아니라 "확인하지 못했다" 는 뜻이라, 문장이 실패나 결함이라고 말하지 않고
|
|
@@ -2219,8 +2750,7 @@ function writeDryRunResult(io, result) {
|
|
|
2219
2750
|
* 여러 줄 응답이 한 줄이 되고 제어 문자도 함께 무해해진다. 자르기는 `runner` 가 이미 진단 값과
|
|
2220
2751
|
* 같은 상한에서 했다(`clampObservedText`). 여기서 규칙을 새로 만들지 않는다.
|
|
2221
2752
|
*/
|
|
2222
|
-
function writeRejectionUnverified(io,
|
|
2223
|
-
const unverified = result.outcomes.filter((outcome) => outcome.rejectionBasis === "unverified");
|
|
2753
|
+
function writeRejectionUnverified(io, unverified) {
|
|
2224
2754
|
if (unverified.length === 0) return;
|
|
2225
2755
|
const ids = unverified.map((outcome) => escapeTerminalText$2(outcome.caseId));
|
|
2226
2756
|
const column = Math.max(...ids.map((id) => Array.from(id).length));
|
|
@@ -2272,11 +2802,10 @@ function writeRejectionDiagnosis(io, requested, results) {
|
|
|
2272
2802
|
* 실패는 흐름을 끊지 않는다. 안내만 찍고 승인 화면이 이어진다. 진단은 참고이지 전제가 아니다.
|
|
2273
2803
|
*/
|
|
2274
2804
|
async function askRejectionDiagnosis(options) {
|
|
2275
|
-
const { io, deps, provider } = options;
|
|
2805
|
+
const { io, deps, provider, unverified } = options;
|
|
2276
2806
|
const prepare = deps.prepareRejectionDiagnosisRequests;
|
|
2277
2807
|
const dispatch = deps.dispatchRejectionDiagnosis;
|
|
2278
2808
|
if (provider === void 0 || prepare === void 0 || dispatch === void 0) return;
|
|
2279
|
-
const unverified = options.result.outcomes.filter((outcome) => outcome.rejectionBasis === "unverified");
|
|
2280
2809
|
if (unverified.length === 0) return;
|
|
2281
2810
|
const cases = unverified.flatMap((outcome) => {
|
|
2282
2811
|
if (outcome.rejectionBody === void 0) return [];
|
|
@@ -2321,6 +2850,28 @@ async function askRejectionDiagnosis(options) {
|
|
|
2321
2850
|
writeRejectionDiagnosis(io, cases.length, dispatched.results);
|
|
2322
2851
|
}
|
|
2323
2852
|
/**
|
|
2853
|
+
* 최종 전량 재검증을 못 했을 때의 문장. 상수로 둔다.
|
|
2854
|
+
*
|
|
2855
|
+
* 개별 케이스가 단독으로 통과한 것과 전량을 순서대로 돌려 통과한 것은 다르다. A 가 통과하고
|
|
2856
|
+
* B 가 단독으로 통과해도, A 다음에 B 를 돌리면 B 가 실패할 수 있다. 그 사실을 안 적으면
|
|
2857
|
+
* 사용자는 저장 뒤 첫 `mcpeak test` 에서 처음 안다(이슈 #399).
|
|
2858
|
+
*/
|
|
2859
|
+
const FINAL_REVALIDATION_SKIPPED_LINES = "최종 전량 재검증을 하지 않았습니다. 개별 케이스 통과 기록만 있습니다.\n저장 후 `mcpeak test` 로 전체를 한 번 실행해 확인하세요.\n";
|
|
2860
|
+
/** 재검증 확인 화면. 실제 서버 호출이 는다는 사실을 묻기 전에 적는다. */
|
|
2861
|
+
function writeFinalRevalidationNotice(io, grade, caseCount) {
|
|
2862
|
+
io.write(`최종 명세를 처음부터 다시 실행해 검증합니다.
|
|
2863
|
+
${RESET_GRADE_LINE[grade]}\n 실행할 케이스: ${caseCount}건 (실제 서버 호출입니다)\n`);
|
|
2864
|
+
}
|
|
2865
|
+
/** 저장 화면의 검증 범위 줄(§5). 검증 범위를 남기는 **유일한 자리**다. */
|
|
2866
|
+
function writeVerificationScope(io, verification, caseCount) {
|
|
2867
|
+
if (verification === "fullSuite") {
|
|
2868
|
+
io.write(` 검증 범위: ${caseCount}건을 최종 전량 실행에서 확인했습니다.\n`);
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
io.write(` 검증 범위: ${caseCount}건은 개별 실행에서만 확인했습니다. 전량 재검증을 하지 않았습니다.\n 저장 후 \`mcpeak test\` 로 전체를 한 번 실행해 확인하세요.
|
|
2872
|
+
`);
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2324
2875
|
* 반영 요약(§8.8). 저장 확인 직전에 찍는다. 교정이 0건이면 아무것도 찍지 않는다.
|
|
2325
2876
|
* 지문이 바뀐 이유가 화면에 남아야 사용자가 나중에 diff 를 보고 놀라지 않는다(§5.4).
|
|
2326
2877
|
*/
|
|
@@ -2417,7 +2968,7 @@ function writeResetFailure(io, error) {
|
|
|
2417
2968
|
for (const line of error.stderr.split("\n").filter(Boolean).slice(-3)) io.write(` → ${line}\n`);
|
|
2418
2969
|
io.write("저장하지 않았습니다. 초기화 명령을 고친 뒤 다시 save 를 고르세요.\n");
|
|
2419
2970
|
}
|
|
2420
|
-
async function runInteractiveReview(input, tools, session, deps, connection, skippedTools = []) {
|
|
2971
|
+
async function runInteractiveReview(input, tools, session, deps, connection, skippedTools = [], outputContractSkips = [], validBranchSkips = [], fixtures) {
|
|
2421
2972
|
const io = deps.reviewIO;
|
|
2422
2973
|
const prepare = deps.prepareAuthoringRequest;
|
|
2423
2974
|
const dispatch = deps.dispatchAuthoringRequest;
|
|
@@ -2510,22 +3061,41 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2510
3061
|
writeDryRunAborted(io, result, caseCount, diagnostics());
|
|
2511
3062
|
continue;
|
|
2512
3063
|
}
|
|
2513
|
-
writeDryRunResult(io, result);
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
io,
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
3064
|
+
writeDryRunResult(io, result, dryRunSuite, fixtures);
|
|
3065
|
+
const unverified = result.outcomes.filter((outcome) => outcome.status === "passed" && outcome.rejectionBasis === "unverified");
|
|
3066
|
+
if (input.diagnoseRejections) {
|
|
3067
|
+
writeRejectionUnverified(io, unverified);
|
|
3068
|
+
await askRejectionDiagnosis({
|
|
3069
|
+
io,
|
|
3070
|
+
deps,
|
|
3071
|
+
unverified,
|
|
3072
|
+
suite: dryRunSuite,
|
|
3073
|
+
tools,
|
|
3074
|
+
provider: preferred === void 0 ? void 0 : deps.rejectionProviders?.[preferred]?.(model ?? defaultModel(preferred)),
|
|
3075
|
+
model: model ?? (preferred === void 0 ? "" : defaultModel(preferred))
|
|
3076
|
+
});
|
|
3077
|
+
} else if (unverified.length > 0) io.write(` 통과한 거절 케이스 ${unverified.length}건의 근거는 확인하지 않았습니다. --diagnose-rejections 로 목록과 AI 진단을 볼 수 있습니다.
|
|
3078
|
+
|
|
3079
|
+
`);
|
|
3080
|
+
const selection = input.repair ? selectRepairTargets({
|
|
2525
3081
|
suite: dryRunSuite,
|
|
2526
3082
|
outcomes: result.outcomes,
|
|
2527
3083
|
origins: originsOf(session)
|
|
2528
|
-
}) :
|
|
3084
|
+
}) : {
|
|
3085
|
+
targets: [],
|
|
3086
|
+
thrown: []
|
|
3087
|
+
};
|
|
3088
|
+
if (selection.thrown.length > 0) {
|
|
3089
|
+
io.write(` 교정 대상이 아닌 실패 ${selection.thrown.length}건 (호출 자체가 실패했습니다. 입력값 문제가 아닙니다)\n`);
|
|
3090
|
+
for (const item of selection.thrown) {
|
|
3091
|
+
io.write(` ${item.caseName}\n`);
|
|
3092
|
+
const body = item.serverMessage.split("\n").filter((line) => line !== "");
|
|
3093
|
+
if (body.length === 0) io.write(` ${item.failureLine}\n`);
|
|
3094
|
+
else for (const line of body) io.write(` → ${line}\n`);
|
|
3095
|
+
}
|
|
3096
|
+
io.write("\n");
|
|
3097
|
+
}
|
|
3098
|
+
const targets = selection.targets;
|
|
2529
3099
|
let attempts;
|
|
2530
3100
|
let effective = result;
|
|
2531
3101
|
if (targets.length > 0) {
|
|
@@ -2579,6 +3149,7 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2579
3149
|
});
|
|
2580
3150
|
const repaired = new Map(repairs.filter((item) => item.repaired && item.input !== void 0).map((item) => [item.caseId, item.input]));
|
|
2581
3151
|
attempts = new Map(repairs.filter((item) => !item.repaired && item.attempts.length > 0).map((item) => [item.caseId, item.attempts]));
|
|
3152
|
+
await writeBackFixtures(input, deps, io, targets, repairs);
|
|
2582
3153
|
if (repaired.size > 0) {
|
|
2583
3154
|
repairedCases = repaired.size;
|
|
2584
3155
|
repairChanges = repairApplications(dryRunSuite, repaired);
|
|
@@ -2624,9 +3195,47 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2624
3195
|
}
|
|
2625
3196
|
approvals = review.approvals;
|
|
2626
3197
|
}
|
|
3198
|
+
/**
|
|
3199
|
+
* 11.5. 최종 전량 재검증(§2, 이슈 #399).
|
|
3200
|
+
*
|
|
3201
|
+
* 입력값 교정은 케이스 하나만 담은 스위트로 재실행한다. 그 판단은 그 자리에서 옳다.
|
|
3202
|
+
* 문제는 그 뒤다. 저장 판정이 원래 실행 결과와 교정된 케이스의 **단독 통과**를 합친
|
|
3203
|
+
* 것이었다. **합집합은 실행이 아니다.** A 가 통과하고 B 가 단독으로 통과해도 A 다음에
|
|
3204
|
+
* B 를 돌리면 B 가 실패할 수 있고, 그 사실은 저장 뒤 첫 `mcpeak test` 에서야 드러난다.
|
|
3205
|
+
*
|
|
3206
|
+
* 여기서 초기 상태로 되돌리고 최종 스위트 전량을 한 번에 돌린다. **그 결과가 저장
|
|
3207
|
+
* 판정이다.**
|
|
3208
|
+
*/
|
|
3209
|
+
let verification = "isolated";
|
|
3210
|
+
const revalidationSuite = session.approvedDraft.suite;
|
|
3211
|
+
if (input.dryRun) {
|
|
3212
|
+
const finalCaseCount = revalidationSuite.cases.length;
|
|
3213
|
+
const grade = (await (deps.attemptReset ?? attemptReset)(input.resetCmd)).grade;
|
|
3214
|
+
writeFinalRevalidationNotice(io, grade, finalCaseCount);
|
|
3215
|
+
if (await io.confirm(" 다시 실행할까요?")) {
|
|
3216
|
+
const again = await runDryRun({
|
|
3217
|
+
client: connection.client,
|
|
3218
|
+
suite: revalidationSuite
|
|
3219
|
+
});
|
|
3220
|
+
if (again.aborted !== void 0) {
|
|
3221
|
+
writeDryRunAborted(io, again, finalCaseCount, diagnostics());
|
|
3222
|
+
continue;
|
|
3223
|
+
}
|
|
3224
|
+
writeDryRunResult(io, again);
|
|
3225
|
+
const expectedToFail = new Set(approvals.filter((item) => item.status === "serverDefect").map((item) => item.id));
|
|
3226
|
+
const regressed = again.outcomes.filter((outcome) => outcome.status !== "passed" && !expectedToFail.has(outcome.caseId));
|
|
3227
|
+
if (regressed.length > 0) {
|
|
3228
|
+
io.write(`최종 전량 실행에서 ${regressed.length}건이 실패했습니다. 개별 실행에서는 통과했지만 순서대로 돌리면 실패합니다.\n 앞 케이스가 바꾼 상태를 뒤 케이스가 물려받습니다. 케이스 순서나 입력값을 확인하세요.
|
|
3229
|
+
`);
|
|
3230
|
+
continue;
|
|
3231
|
+
}
|
|
3232
|
+
verification = "fullSuite";
|
|
3233
|
+
} else io.write(FINAL_REVALIDATION_SKIPPED_LINES);
|
|
3234
|
+
}
|
|
2627
3235
|
const fingerprint = session.approvedDraft.suiteFingerprint;
|
|
2628
3236
|
io.write(`Final fingerprint: ${fingerprint}\n`);
|
|
2629
3237
|
writeRepairSummary(io, repairedCases, repairChanges);
|
|
3238
|
+
writeVerificationScope(io, verification, revalidationSuite.cases.length);
|
|
2630
3239
|
if (!await io.confirm("최종 JSON을 저장할까요?")) continue;
|
|
2631
3240
|
const final = deps.finalizeAuthoringDraft({
|
|
2632
3241
|
session,
|
|
@@ -2646,7 +3255,7 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2646
3255
|
reportCoverageSafely(deps, () => deps.computeCoverage?.({
|
|
2647
3256
|
suite: finalSuite,
|
|
2648
3257
|
tools: tools.filter((_tool, index) => !skippedIndexes.has(index))
|
|
2649
|
-
}), finalSuite, skippedTools);
|
|
3258
|
+
}), finalSuite, skippedTools, outputContractSkips, validBranchSkips);
|
|
2650
3259
|
return 0;
|
|
2651
3260
|
} catch (error) {
|
|
2652
3261
|
if (error instanceof OutputExistsError) outputExistsFailure(deps, error.path);
|
|
@@ -2666,7 +3275,9 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2666
3275
|
candidate
|
|
2667
3276
|
});
|
|
2668
3277
|
showDiff(io, diff);
|
|
2669
|
-
const
|
|
3278
|
+
const selectedInput = action === "select" ? await io.input("적용할 change ID를 쉼표로 입력하세요: ") : null;
|
|
3279
|
+
if (selectedInput === "__mcpeak_review_back__") continue;
|
|
3280
|
+
const selected = selectedInput === null ? diff.changes.map((change) => change.id) : selectedInput.split(",").map((id) => id.trim()).filter(Boolean);
|
|
2670
3281
|
if (!await confirmSpecFindings(io, candidate, diff, selected)) continue;
|
|
2671
3282
|
if (!await io.confirm("선택한 변경을 적용할까요?")) continue;
|
|
2672
3283
|
const result = apply({
|
|
@@ -2684,6 +3295,7 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2684
3295
|
}
|
|
2685
3296
|
if (action === "edit") {
|
|
2686
3297
|
const path = await io.input("편집한 JSON 파일 경로: ");
|
|
3298
|
+
if (path === "__mcpeak_review_back__") continue;
|
|
2687
3299
|
try {
|
|
2688
3300
|
const result = reviewLocal({
|
|
2689
3301
|
session,
|
|
@@ -2707,15 +3319,24 @@ async function runInteractiveReview(input, tools, session, deps, connection, ski
|
|
|
2707
3319
|
io.write("지원하지 않는 메뉴입니다.\n");
|
|
2708
3320
|
continue;
|
|
2709
3321
|
}
|
|
3322
|
+
const previousPreferred = preferred;
|
|
3323
|
+
const previousModel = model;
|
|
2710
3324
|
if (action !== "revise" && providerId !== preferred) model = input.provider === providerId ? input.model : void 0;
|
|
2711
3325
|
preferred = providerId;
|
|
2712
|
-
|
|
3326
|
+
const selectedModel = model ?? await io.input(`${providerId} model (${defaultModel(providerId)}): `);
|
|
3327
|
+
if (selectedModel === "__mcpeak_review_back__") {
|
|
3328
|
+
preferred = previousPreferred;
|
|
3329
|
+
model = previousModel;
|
|
3330
|
+
continue;
|
|
3331
|
+
}
|
|
3332
|
+
model = selectedModel || defaultModel(providerId);
|
|
2713
3333
|
const provider = deps.providers?.[providerId]?.(model);
|
|
2714
3334
|
if (!provider) {
|
|
2715
3335
|
io.write(`${providerId} provider를 사용할 수 없습니다.\n`);
|
|
2716
3336
|
continue;
|
|
2717
3337
|
}
|
|
2718
3338
|
const instruction = await io.input(action === "revise" ? "피드백: " : "AI 요청: ");
|
|
3339
|
+
if (instruction === "__mcpeak_review_back__") continue;
|
|
2719
3340
|
const preview = prepare({
|
|
2720
3341
|
mode: action === "revise" ? "revise" : "initial",
|
|
2721
3342
|
instruction,
|
|
@@ -2770,7 +3391,8 @@ const AXIS_LABEL = {
|
|
|
2770
3391
|
REQUIRED_OMITTED: "필수 필드 누락 거절",
|
|
2771
3392
|
TYPE_VIOLATION: "타입 위반 거절",
|
|
2772
3393
|
ENUM_VIOLATION: "선언되지 않은 값 거절",
|
|
2773
|
-
RANGE_VIOLATION: "선언된 범위 밖 값 거절"
|
|
3394
|
+
RANGE_VIOLATION: "선언된 범위 밖 값 거절",
|
|
3395
|
+
UNDECLARED_FIELD: "선언되지 않은 필드 거절"
|
|
2774
3396
|
};
|
|
2775
3397
|
/**
|
|
2776
3398
|
* runner 보고서 상한(1MB)에 닿기 전에 알리는 임계.
|
|
@@ -2797,9 +3419,44 @@ function padToWidth(text, width) {
|
|
|
2797
3419
|
const padding = width - displayWidth(text);
|
|
2798
3420
|
return padding > 0 ? text + " ".repeat(padding) : text;
|
|
2799
3421
|
}
|
|
2800
|
-
/**
|
|
2801
|
-
|
|
2802
|
-
|
|
3422
|
+
/**
|
|
3423
|
+
* 범위 경계를 사람이 읽는 말로. 화면에만 쓰는 이름이라 축 정체성(`bound`)과는 별개다.
|
|
3424
|
+
* `Record<ContractRangeBound, string>` 이라서 `runner` 가 경계를 늘리면 여기서 타입 오류가
|
|
3425
|
+
* 난다. AXIS_LABEL 과 같은 이유로 같은 형태다.
|
|
3426
|
+
*/
|
|
3427
|
+
const RANGE_BOUND_LABEL = {
|
|
3428
|
+
lower: "하한",
|
|
3429
|
+
upper: "상한"
|
|
3430
|
+
};
|
|
3431
|
+
/**
|
|
3432
|
+
* 축을 못 만든 사유를 사람 문장으로. `Record<UnanalyzedReason, string>` 이라서 `runner` 가
|
|
3433
|
+
* 사유를 늘리면 여기서 타입 오류가 난다. AXIS_LABEL 과 같은 이유로 같은 형태다.
|
|
3434
|
+
*
|
|
3435
|
+
* 이름만 나열하던 종전 화면은 사용자가 할 수 있는 일이 없었다. `filters` 하나만 보고는
|
|
3436
|
+
* 선언을 고쳐야 하는지, 우리 상한에 걸린 것인지, 애초에 읽을 근거가 없는 것인지 알 수 없다.
|
|
3437
|
+
* 이 프로젝트에서 실패 메시지는 곧 제품이다.
|
|
3438
|
+
*/
|
|
3439
|
+
const UNANALYZED_REASON_LABEL = {
|
|
3440
|
+
blockingKeyword: "anyOf·oneOf·allOf·not·$ref 가 선언돼 있어 읽지 못했습니다.",
|
|
3441
|
+
noProperties: "object 인데 properties 선언이 없어 안을 읽지 못했습니다.",
|
|
3442
|
+
tupleItems: "items 가 배열(튜플)이라 원소 스키마를 하나로 묶지 못했습니다.",
|
|
3443
|
+
depthLimit: "중첩 깊이 상한(3)을 넘어 더 내려가지 않았습니다.",
|
|
3444
|
+
pathLimit: "한 도구의 경로 수 상한(64)을 넘어 더 만들지 않았습니다.",
|
|
3445
|
+
pathCollision: "경로 표기가 다른 필드와 겹쳐 양쪽 다 제외했습니다.",
|
|
3446
|
+
noGround: "type·enum·범위를 하나도 읽지 못해 요구할 근거가 없습니다.",
|
|
3447
|
+
unreadablePath: "이름에 '.' 이나 '[' 가 들어 경로로 읽으면 다른 자리를 가리켜 제외했습니다."
|
|
3448
|
+
};
|
|
3449
|
+
/**
|
|
3450
|
+
* 축 하나를 사람이 읽는 문장으로. 필드가 없는 축(HAPPY_PATH)은 종류만 적는다.
|
|
3451
|
+
*
|
|
3452
|
+
* 괄호는 범위 축에만 붙는다. 상한 축과 하한 축은 종류도 필드도 같아서, 경계를 안 적으면
|
|
3453
|
+
* 미검증 줄이 두 번 똑같이 나온다(이슈 #387). 같은 문장이 두 줄이면 사용자는 화면이 고장 난
|
|
3454
|
+
* 것으로 읽지, 한쪽 경계만 비었다는 뜻으로 읽지 않는다. 다른 축은 그럴 일이 없으므로 문장을
|
|
3455
|
+
* 그대로 둔다.
|
|
3456
|
+
*/
|
|
3457
|
+
function axisLabel(kind, field, bound) {
|
|
3458
|
+
const base = field === null ? AXIS_LABEL[kind] : `${field} 의 ${AXIS_LABEL[kind]}`;
|
|
3459
|
+
return bound === null ? base : `${base} (${RANGE_BOUND_LABEL[bound]})`;
|
|
2803
3460
|
}
|
|
2804
3461
|
/** 툴 한 개의 줄과 그 아래 들여쓴 줄들. */
|
|
2805
3462
|
function coverageToolLines(tool, nameWidth) {
|
|
@@ -2809,11 +3466,15 @@ function coverageToolLines(tool, nameWidth) {
|
|
|
2809
3466
|
` → 입력 스키마를 해석하지 못해 이 툴의 축을 세지 못했습니다 (${tool.unanalyzableReason ?? "schema"})`,
|
|
2810
3467
|
" → 이 툴은 커버리지 숫자에 들어가지 않습니다"
|
|
2811
3468
|
];
|
|
2812
|
-
const labels = tool.axes.filter((axis) => axis.caseId === null).map((axis) => axisLabel(axis.kind, axis.field));
|
|
3469
|
+
const labels = tool.axes.filter((axis) => axis.caseId === null).map((axis) => axisLabel(axis.kind, axis.field, axis.bound));
|
|
2813
3470
|
const labelWidth = Math.max(0, ...labels.map(displayWidth)) + 5;
|
|
2814
3471
|
const lines = [`${head}${tool.verified}/${tool.total}`];
|
|
2815
3472
|
for (const label of labels) lines.push(` ? ${padToWidth(label, labelWidth)}미검증`);
|
|
2816
|
-
if (tool.unanalyzedFields.length > 0)
|
|
3473
|
+
if (tool.unanalyzedFields.length > 0) {
|
|
3474
|
+
lines.push(` → 해석 못 한 필드 ${tool.unanalyzedFields.length}개. 이 필드의 축은 세지 않았습니다`);
|
|
3475
|
+
const pathWidth = Math.max(0, ...tool.unanalyzedFields.map((f) => displayWidth(f.path))) + 3;
|
|
3476
|
+
for (const field of tool.unanalyzedFields) lines.push(` ${padToWidth(field.path, pathWidth)}${UNANALYZED_REASON_LABEL[field.reason]}`);
|
|
3477
|
+
}
|
|
2817
3478
|
return lines;
|
|
2818
3479
|
}
|
|
2819
3480
|
/**
|
|
@@ -2856,13 +3517,17 @@ function renderSkippedTools(skipped) {
|
|
|
2856
3517
|
* authoring 화면의 어법을 따르되 **전송 데이터 목록은 사실대로** 적는다. 여기서 나가는 것은
|
|
2857
3518
|
* suite 전량이 아니라 툴 선언과 baseline 이 넣은 값뿐이다. authoring 문안을 그대로 태우면
|
|
2858
3519
|
* 사용자가 승인하는 내용과 실제로 나가는 내용이 달라진다.
|
|
3520
|
+
*
|
|
3521
|
+
* 실제 서버 호출이 이 확인 뒤에 일어난다는 사실도 여기서 말한다. 저장 메뉴의 "계속할까요?" 만
|
|
3522
|
+
* 보고 온 사용자가 그 앞의 호출을 모른 채 승인하면 안 된다(#397).
|
|
2859
3523
|
*/
|
|
2860
3524
|
function showPreFillRequest(io, preview) {
|
|
2861
3525
|
const { request } = preview;
|
|
2862
3526
|
const fields = request.cases.reduce((sum, item) => sum + item.assistFields.length, 0);
|
|
2863
3527
|
io.write(`AI 사전보완 요청\nProvider: ${preview.providerId}\nModel: ${preview.model}\nPayload: ${preview.byteLength} bytes\nResult limit: ${preview.maxResultBytes} bytes\nTimeout: ${preview.providerTimeoutMs}ms\nFingerprint: ${preview.fingerprint}\n대상: 툴 ${request.tools.length}개, 케이스 ${request.cases.length}개, 채울 필드 ${fields}개\n전송 데이터: 툴 이름·설명·inputSchema, baseline 이 넣은 값, 그 값의 출처
|
|
2864
3528
|
받는 것: 값 제안뿐입니다. 케이스를 더하거나 구조를 바꾸지 않습니다.
|
|
2865
|
-
|
|
3529
|
+
채택 판정: 제안이 돌아온 케이스마다 baseline 값과 제안 값으로 실제 서버를 각각 한 번씩
|
|
3530
|
+
부릅니다. 위 케이스 ${request.cases.length}개가 그 상한입니다. 상태를 바꾸는 툴이면 그 부작용이 남습니다.\n`);
|
|
2866
3531
|
if (request.omitted.tools > 0) io.write(`⚠ 크기 상한 때문에 툴 ${request.omitted.tools}개를 요청에서 뺐습니다. 그 툴은 baseline 값 그대로 갑니다.\n`);
|
|
2867
3532
|
}
|
|
2868
3533
|
/**
|
|
@@ -2881,21 +3546,54 @@ function renderUnknownFormatSkips(skips) {
|
|
|
2881
3546
|
}
|
|
2882
3547
|
return `${lines.join("\n")}\n`;
|
|
2883
3548
|
}
|
|
3549
|
+
/** 입력 케이스는 남겼지만 저장된 출력 계약을 만들지 못한 범위를 명시한다. */
|
|
3550
|
+
function renderOutputContractSkips(skips) {
|
|
3551
|
+
if (skips.length === 0) return "";
|
|
3552
|
+
return `${skips.map((skip) => `경고: 툴 '${skip.name}'의 출력 계약을 자동 검증하지 않습니다.\n 위치: ${skip.path}\n 이유: ${skip.message}\n 정상 호출의 isError 검사는 유지되지만 structuredContent 계약은 미검증입니다.`).join("\n")}\n`;
|
|
3553
|
+
}
|
|
3554
|
+
/**
|
|
3555
|
+
* 정상 입력 중 케이스를 만들지 못한 분기를 명시한다. 없으면 빈 문자열이다.
|
|
3556
|
+
*
|
|
3557
|
+
* 침묵하면 "정상 입력은 전부 밟았다" 로 읽힌다. 커버리지 표의 축은 위반 축이라 여기서 빠진
|
|
3558
|
+
* 분기를 세지 않으므로, 이 덩어리가 없으면 사용자가 빈틈을 볼 수 있는 자리가 없다.
|
|
3559
|
+
*
|
|
3560
|
+
* `reason` 은 `generate` 가 만들어 보낸 문장을 그대로 쓴다. 여기서 다시 쓰면 같은 상황이 두
|
|
3561
|
+
* 문안으로 갈린다. 순서도 `skips` 배열 그대로다. `generate` 가 이미 결정론적으로 정렬했고,
|
|
3562
|
+
* 여기서 다시 정렬하면 두 곳이 갈린다.
|
|
3563
|
+
*/
|
|
3564
|
+
function renderValidBranchSkips(skips) {
|
|
3565
|
+
if (skips.length === 0) return "";
|
|
3566
|
+
const lines = [`정상 분기: 툴 ${new Set(skips.map((skip) => skip.tool)).size}개에서 ${skips.length}개 분기를 실행하지 않았습니다.`];
|
|
3567
|
+
for (const skip of skips) lines.push(` ${skip.tool}.${skip.field} ${skip.reason}`);
|
|
3568
|
+
return `${lines.join("\n")}\n`;
|
|
3569
|
+
}
|
|
2884
3570
|
/**
|
|
2885
3571
|
* AI 사전보완 결과 요약. 대상이 없으면 빈 문자열이다.
|
|
2886
3572
|
*
|
|
3573
|
+
* 네 갈래를 나눠 적는다(설계 §4.3). `미채택` 과 `보류` 를 합쳐 "baseline 값이 이미 통과" 라고
|
|
3574
|
+
* 쓰면 baseline 이 실패한 케이스에 사실과 다른 말이 붙는다.
|
|
3575
|
+
*
|
|
2887
3576
|
* **`버림` 은 사유와 대상을 반드시 적는다.** 개수만 적으면 사용자가 무엇을 잃었는지 모른다
|
|
2888
3577
|
* (이슈 #120 이 `discarded` 가 개수뿐이라고 지적한 것과 같은 계열이다). 버림이 0건이면 그 줄을
|
|
2889
3578
|
* 아예 찍지 않는다.
|
|
2890
3579
|
*/
|
|
2891
3580
|
function renderPreFillSummary(options) {
|
|
2892
|
-
const { toolCount, proposedToolCount, adopted, notAdopted, discarded } = options;
|
|
2893
|
-
if (proposedToolCount === 0 && discarded.length === 0) return "";
|
|
3581
|
+
const { toolCount, proposedToolCount, adopted, notAdopted, held, heldCases, excluded, discarded } = options;
|
|
3582
|
+
if (proposedToolCount === 0 && discarded.length === 0 && excluded.length === 0) return "";
|
|
2894
3583
|
const lines = [
|
|
2895
3584
|
`AI 사전보완: 툴 ${toolCount}개 중 ${proposedToolCount}개에 값 제안을 받았습니다.`,
|
|
2896
3585
|
` 채택 ${adopted} (실제 서버에서 baseline 값이 실패하고 제안 값이 통과)`,
|
|
2897
|
-
` 미채택 ${notAdopted} (baseline 값이 이미 통과)`
|
|
3586
|
+
` 미채택 ${notAdopted - held} (baseline 값이 이미 통과)`
|
|
2898
3587
|
];
|
|
3588
|
+
if (held > 0) {
|
|
3589
|
+
lines.push(` 보류 ${held} (baseline 값도 제안 값도 실패. 분류 화면에서 정합니다)`);
|
|
3590
|
+
for (const item of heldCases) {
|
|
3591
|
+
const proposals = item.proposedFields.map((p) => `${p.field}: ${JSON.stringify(p.value)}`).join(", ");
|
|
3592
|
+
lines.push(` ${item.caseId} ${proposals}`);
|
|
3593
|
+
for (const line of item.serverMessage.split("\n").filter((s) => s !== "")) lines.push(` → ${line}`);
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
for (const item of excluded) lines.push(` 제외 1 (${item.reason}: ${item.caseId}.${item.field})`);
|
|
2899
3597
|
for (const item of discarded) lines.push(` 버림 1 (${item.reason}: ${item.caseId}.${item.field})`);
|
|
2900
3598
|
return `${lines.join("\n")}\n`;
|
|
2901
3599
|
}
|
|
@@ -2916,13 +3614,17 @@ function renderCaseCountNotice(caseCount) {
|
|
|
2916
3614
|
`;
|
|
2917
3615
|
}
|
|
2918
3616
|
/** 커버리지·건너뜀·케이스 수 고지를 stdout 에 찍는다. 전부 빈 문자열이면 아무것도 안 찍는다. */
|
|
2919
|
-
function writeCoverageReport(deps, coverage, suite, skippedTools) {
|
|
3617
|
+
function writeCoverageReport(deps, coverage, suite, skippedTools, outputContractSkips, validBranchSkips) {
|
|
2920
3618
|
if (coverage !== void 0) {
|
|
2921
3619
|
const text = renderCoverage(coverage);
|
|
2922
3620
|
if (text !== "") deps.writeStdout(text);
|
|
2923
3621
|
}
|
|
2924
3622
|
const skippedText = renderSkippedTools(skippedTools);
|
|
2925
3623
|
if (skippedText !== "") deps.writeStdout(skippedText);
|
|
3624
|
+
const outputContractText = renderOutputContractSkips(outputContractSkips);
|
|
3625
|
+
if (outputContractText !== "") deps.writeStdout(outputContractText);
|
|
3626
|
+
const validBranchText = renderValidBranchSkips(validBranchSkips);
|
|
3627
|
+
if (validBranchText !== "") deps.writeStdout(validBranchText);
|
|
2926
3628
|
const notice = renderCaseCountNotice(suite.cases.length);
|
|
2927
3629
|
if (notice !== "") deps.writeStdout(notice);
|
|
2928
3630
|
}
|
|
@@ -2936,9 +3638,9 @@ function writeCoverageReport(deps, coverage, suite, skippedTools) {
|
|
|
2936
3638
|
* coverage 를 값이 아니라 thunk 로 받는 이유는 `computeCoverage` 자체가 던지는 경우까지 이 경계
|
|
2937
3639
|
* 안에 넣기 위해서다.
|
|
2938
3640
|
*/
|
|
2939
|
-
function reportCoverageSafely(deps, coverage, suite, skippedTools = []) {
|
|
3641
|
+
function reportCoverageSafely(deps, coverage, suite, skippedTools = [], outputContractSkips = [], validBranchSkips = []) {
|
|
2940
3642
|
try {
|
|
2941
|
-
writeCoverageReport(deps, coverage(), suite, skippedTools);
|
|
3643
|
+
writeCoverageReport(deps, coverage(), suite, skippedTools, outputContractSkips, validBranchSkips);
|
|
2942
3644
|
} catch {
|
|
2943
3645
|
deps.writeStderr("경고 [GENERATE_COVERAGE_UNAVAILABLE]: 명세는 저장했지만 커버리지를 계산하지 못했습니다.\n해결: 저장된 명세는 그대로 `mcpeak test` 로 쓸 수 있습니다. 커버리지만 다시 보려면 다른 --out 경로로 generate 를 실행하세요.\n");
|
|
2944
3646
|
}
|
|
@@ -2952,6 +3654,9 @@ function reportCoverageSafely(deps, coverage, suite, skippedTools = []) {
|
|
|
2952
3654
|
* **provider 가 죽어도 툴을 건너뛰지 않는다.** provider 실패는 사용자 서버의 문제가 아니라
|
|
2953
3655
|
* 우리 쪽 사정이고, 그것 때문에 케이스를 잃는 손해가 더 크다. baseline 값으로 진행하고
|
|
2954
3656
|
* 그 사실을 화면에 적는다.
|
|
3657
|
+
*
|
|
3658
|
+
* **`--no-dry-run` 이면 돌지 않는다.** 채택을 정하는 것이 실제 서버 실행인데 그 경로에는 실행이
|
|
3659
|
+
* 없다. 이 분기가 빠져 있어 시험 실행을 끄고도 툴이 두 번 호출됐다(#397).
|
|
2955
3660
|
*/
|
|
2956
3661
|
async function runPreFill(input, tools, baseline, deps, client) {
|
|
2957
3662
|
const skip = {
|
|
@@ -2973,6 +3678,10 @@ async function runPreFill(input, tools, baseline, deps, client) {
|
|
|
2973
3678
|
baseline: baseline.suite
|
|
2974
3679
|
});
|
|
2975
3680
|
if (request === null) return skip;
|
|
3681
|
+
if (!input.dryRun) {
|
|
3682
|
+
io.write("▸ 시험 실행이 꺼져 있어(--no-dry-run) AI 사전보완을 건너뜁니다. 제안 값의 채택은 실제 서버\n 실행이 정하는데 그 실행이 없습니다. baseline 값으로 진행합니다.\n AI 사전보완이 필요하면 --no-dry-run 없이 실행하세요.\n");
|
|
3683
|
+
return skip;
|
|
3684
|
+
}
|
|
2976
3685
|
let view;
|
|
2977
3686
|
try {
|
|
2978
3687
|
view = preview({
|
|
@@ -3001,13 +3710,22 @@ async function runPreFill(input, tools, baseline, deps, client) {
|
|
|
3001
3710
|
const applied = await applyPreFill({
|
|
3002
3711
|
client,
|
|
3003
3712
|
baseline: baseline.suite,
|
|
3004
|
-
preFill: dispatched.result
|
|
3713
|
+
preFill: dispatched.result,
|
|
3714
|
+
reset: () => (deps.attemptReset ?? attemptReset)(input.resetCmd).then((result) => result.grade)
|
|
3005
3715
|
});
|
|
3716
|
+
if (applied.skippedReason !== void 0) io.write(`▸ ${applied.skippedReason}\n`);
|
|
3006
3717
|
io.write(renderPreFillSummary({
|
|
3007
3718
|
toolCount: tools.length,
|
|
3008
3719
|
proposedToolCount: request.tools.length,
|
|
3009
3720
|
adopted: applied.adopted,
|
|
3010
3721
|
notAdopted: applied.notAdopted,
|
|
3722
|
+
held: applied.held,
|
|
3723
|
+
heldCases: applied.cases.filter((item) => item.needsClassification).map((item) => ({
|
|
3724
|
+
caseId: item.caseId,
|
|
3725
|
+
proposedFields: item.proposedFields ?? [],
|
|
3726
|
+
serverMessage: item.serverMessage ?? ""
|
|
3727
|
+
})),
|
|
3728
|
+
excluded: applied.excluded,
|
|
3011
3729
|
discarded: dispatched.result.discarded
|
|
3012
3730
|
}));
|
|
3013
3731
|
return {
|
|
@@ -3039,6 +3757,10 @@ async function runGenerateCommand(argv, deps) {
|
|
|
3039
3757
|
deps.writeStderr("오류 [GENERATE_INTERACTIVE_REQUIRED]: AI 검토에는 TTY가 필요합니다.\n해결: `--baseline-only`를 지정하거나 대화형 터미널에서 실행하세요.\n");
|
|
3040
3758
|
return 1;
|
|
3041
3759
|
}
|
|
3760
|
+
const fixturesRead = await loadFixtures(input, deps);
|
|
3761
|
+
if (fixturesRead === null) return 1;
|
|
3762
|
+
const fixtureFile = fixturesRead.file;
|
|
3763
|
+
const fixturePath = input.fixturesPath ?? DEFAULT_FIXTURES_PATH;
|
|
3042
3764
|
let connection;
|
|
3043
3765
|
/**
|
|
3044
3766
|
* 대화형 검토는 아래 try 밖에서 돌린다. 검토가 던지는 오류를 여기 catch 가 삼키면
|
|
@@ -3059,7 +3781,8 @@ async function runGenerateCommand(argv, deps) {
|
|
|
3059
3781
|
}
|
|
3060
3782
|
const baseline = deps.createBaselineSuite(tools, {
|
|
3061
3783
|
suiteId: input.suiteId,
|
|
3062
|
-
suiteName: input.name
|
|
3784
|
+
suiteName: input.name,
|
|
3785
|
+
...fixtureFile === void 0 ? {} : { fixtures: fixtureFile }
|
|
3063
3786
|
});
|
|
3064
3787
|
let sessionSuite = baseline.suite;
|
|
3065
3788
|
let preFilledCaseIds = [];
|
|
@@ -3084,7 +3807,14 @@ async function runGenerateCommand(argv, deps) {
|
|
|
3084
3807
|
active,
|
|
3085
3808
|
session,
|
|
3086
3809
|
tools,
|
|
3087
|
-
skippedTools: baseline.skippedTools
|
|
3810
|
+
skippedTools: baseline.skippedTools,
|
|
3811
|
+
outputContractSkips: baseline.outputContractSkips,
|
|
3812
|
+
validBranchSkips: baseline.validBranchSkips,
|
|
3813
|
+
fixtures: {
|
|
3814
|
+
path: fixturePath,
|
|
3815
|
+
fieldOrigins: baseline.fieldOrigins,
|
|
3816
|
+
...fixtureFile?.notes === void 0 ? {} : { notes: fixtureFile.notes }
|
|
3817
|
+
}
|
|
3088
3818
|
};
|
|
3089
3819
|
} else {
|
|
3090
3820
|
const final = deps.finalizeAuthoringDraft({
|
|
@@ -3098,7 +3828,7 @@ async function runGenerateCommand(argv, deps) {
|
|
|
3098
3828
|
const finalSuite = deps.getAuthoringExecutionSuite(final.snapshot);
|
|
3099
3829
|
await saveSuite(input, finalSuite, final.snapshot.fingerprint, deps);
|
|
3100
3830
|
deps.writeStdout(`baseline suite를 저장했습니다: ${input.outPath}\n`);
|
|
3101
|
-
reportCoverageSafely(deps, () => baseline.coverage, finalSuite, baseline.skippedTools);
|
|
3831
|
+
reportCoverageSafely(deps, () => baseline.coverage, finalSuite, baseline.skippedTools, baseline.outputContractSkips, baseline.validBranchSkips);
|
|
3102
3832
|
return 0;
|
|
3103
3833
|
}
|
|
3104
3834
|
} catch (error) {
|
|
@@ -3129,7 +3859,7 @@ async function runGenerateCommand(argv, deps) {
|
|
|
3129
3859
|
return 1;
|
|
3130
3860
|
}
|
|
3131
3861
|
try {
|
|
3132
|
-
return await runInteractiveReview(input, review.tools, review.session, deps, review.active, review.skippedTools);
|
|
3862
|
+
return await runInteractiveReview(input, review.tools, review.session, deps, review.active, review.skippedTools, review.outputContractSkips, review.validBranchSkips, review.fixtures);
|
|
3133
3863
|
} finally {
|
|
3134
3864
|
await review.active.close().catch(() => void 0);
|
|
3135
3865
|
}
|
|
@@ -3228,6 +3958,18 @@ function caseApprovalStatuses(suite) {
|
|
|
3228
3958
|
return new Map((suite.approval?.cases ?? []).map((entry) => [entry.id, entry.status]));
|
|
3229
3959
|
}
|
|
3230
3960
|
/**
|
|
3961
|
+
* 승인 시점에 실제 서버 실행 기록이 남았는가. `approval.cases` 의 존재가 기준이다.
|
|
3962
|
+
*
|
|
3963
|
+
* `generate` 는 시험 실행을 마치고 사람이 분류를 끝냈을 때만 이 키를 쓴다(`renderSuite` 의
|
|
3964
|
+
* 주석과 `reviewDryRun`). 그래서 `--baseline-only`·`--no-dry-run` 으로 저장한 명세는 지문이
|
|
3965
|
+
* 있어도 이 키가 없다. 지문 일치와 실행 기록은 별개다(#385).
|
|
3966
|
+
*
|
|
3967
|
+
* 빈 배열은 저장되지 않지만 손으로 쓴 파일에는 있을 수 있으므로 길이로 본다.
|
|
3968
|
+
*/
|
|
3969
|
+
function specRunHistory(suite) {
|
|
3970
|
+
return (suite.approval?.cases?.length ?? 0) > 0 ? "present" : "absent";
|
|
3971
|
+
}
|
|
3972
|
+
/**
|
|
3231
3973
|
* `serverDefect` 로 표시된 케이스가 다시 실패했을 때 붙이는 한 줄. 설계 문서 §9.
|
|
3232
3974
|
* 들여쓰기는 보고서의 케이스 블록과 맞춘다. 개행으로 끝나고 호출자가 앞에 빈 줄을 붙인다.
|
|
3233
3975
|
*
|
|
@@ -3252,6 +3994,18 @@ function renderSpecApproval(result, allPassed) {
|
|
|
3252
3994
|
return `명세: 현재 명세와 저장된 approval.fingerprint가 불일치함
|
|
3253
3995
|
→ 승인 ${short(result.approvedFingerprint ?? "")} 현재 ${short(result.fingerprint)}\n` + outcome + " → 지문만으로는 변경 내용을 알 수 없습니다. 버전 관리에서 명세를 비교하세요.\n → 의도한 변경이면 같은 설정으로 mcpeak generate 를 다시 실행하고 --force 로 재승인하세요.\n";
|
|
3254
3996
|
}
|
|
3997
|
+
/**
|
|
3998
|
+
* 도구 하나의 스키마 직렬화 상한. 넘으면 스키마를 빼고 `schemasOmitted` 로 표시한다.
|
|
3999
|
+
*
|
|
4000
|
+
* 관측된 실제 MCP 도구 스키마는 대부분 1 KiB 아래다. 8 KiB 를 넘는 것은 $defs 가 수십 개
|
|
4001
|
+
* 달린 생성 스키마이고, 그런 것은 통째로 실어도 AI 가 원인을 좁히는 데 못 쓰면서 요청
|
|
4002
|
+
* 예산만 먹는다. `MAX_REQUEST_BYTES`(256 KiB)에 걸려 요청 전체가 거절되는 것보다 그 도구만
|
|
4003
|
+
* 줄이는 편이 낫다(#393).
|
|
4004
|
+
*
|
|
4005
|
+
* `maxCases` 기본값이 12 이고 실패 케이스가 부른 도구만 싣는다. 도구 하나가 8 KiB 를 넘지
|
|
4006
|
+
* 않으면 12개라도 96 KiB 이고, 나머지(진단·입력·stderr)를 더해도 256 KiB 아래다.
|
|
4007
|
+
*/
|
|
4008
|
+
const MAX_TOOL_SCHEMA_BYTES = 8192;
|
|
3255
4009
|
/** 번들에 적는 CLI 식별자. `mcpeak --version` 이 찍는 것과 같은 출처를 쓴다. */
|
|
3256
4010
|
const REPAIR_BUNDLE_GENERATED_BY = `mcpeak ${version}`;
|
|
3257
4011
|
/**
|
|
@@ -3292,6 +4046,10 @@ function buildRepairBundle(options) {
|
|
|
3292
4046
|
caseId: item.spec.id,
|
|
3293
4047
|
caseName: item.spec.name,
|
|
3294
4048
|
status: item.status,
|
|
4049
|
+
assertions: item.assertions.map((assertion) => ({
|
|
4050
|
+
type: assertion.spec.type,
|
|
4051
|
+
status: assertion.status
|
|
4052
|
+
})),
|
|
3295
4053
|
diagnostics: diagnosticsOf(item)
|
|
3296
4054
|
};
|
|
3297
4055
|
if (item.spec.operation.type === "callTool") {
|
|
@@ -3306,20 +4064,43 @@ function buildRepairBundle(options) {
|
|
|
3306
4064
|
suiteId: options.report.suite.id,
|
|
3307
4065
|
suiteName: options.report.suite.name,
|
|
3308
4066
|
approval: options.specApproval.state,
|
|
4067
|
+
runHistory: specRunHistory(options.suite),
|
|
3309
4068
|
fingerprint: options.specApproval.fingerprint
|
|
3310
4069
|
};
|
|
3311
4070
|
if (options.specApproval.approvedFingerprint !== void 0) spec.approvedFingerprint = options.specApproval.approvedFingerprint;
|
|
4071
|
+
const called = new Set(failures.map((failure) => failure.tool).filter(Boolean));
|
|
4072
|
+
let toolSchemasOmitted = 0;
|
|
4073
|
+
const tools = (options.tools ?? []).filter((tool) => called.has(tool.name)).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0).map((tool) => {
|
|
4074
|
+
const schemas = { inputSchema: tool.inputSchema ?? {} };
|
|
4075
|
+
if (tool.outputSchema !== void 0) schemas.outputSchema = tool.outputSchema;
|
|
4076
|
+
const omitted = Buffer.byteLength(JSON.stringify(schemas), "utf8") > MAX_TOOL_SCHEMA_BYTES;
|
|
4077
|
+
if (omitted) toolSchemasOmitted++;
|
|
4078
|
+
const entry = {
|
|
4079
|
+
name: tool.name,
|
|
4080
|
+
inputSchema: omitted ? {} : schemas.inputSchema
|
|
4081
|
+
};
|
|
4082
|
+
if (!omitted && schemas.outputSchema !== void 0) entry.outputSchema = schemas.outputSchema;
|
|
4083
|
+
if (tool.description !== void 0) entry.description = tool.description;
|
|
4084
|
+
if (omitted) entry.schemasOmitted = true;
|
|
4085
|
+
return entry;
|
|
4086
|
+
});
|
|
3312
4087
|
const bundle = {
|
|
3313
|
-
bundleVersion:
|
|
4088
|
+
bundleVersion: 3,
|
|
3314
4089
|
generatedBy: options.cliVersion ?? REPAIR_BUNDLE_GENERATED_BY,
|
|
3315
4090
|
spec,
|
|
3316
|
-
failures
|
|
4091
|
+
failures,
|
|
4092
|
+
tools,
|
|
4093
|
+
target: { transport: options.target.transport }
|
|
3317
4094
|
};
|
|
4095
|
+
if (toolSchemasOmitted > 0) bundle.truncated = { toolSchemas: toolSchemasOmitted };
|
|
3318
4096
|
/**
|
|
3319
4097
|
* 내용이 있을 때만 담는다. 판정은 화면이 쓰는 `hasDiagnosticContent` 와 **같은 함수**다.
|
|
3320
4098
|
* 규칙이 갈라지면 화면에는 안 뜨는 것이 번들에는 들어간다. 설계서 §4.2.
|
|
3321
4099
|
*/
|
|
3322
|
-
if (options.processDiagnostics !== void 0 && hasDiagnosticContent(options.processDiagnostics)) bundle.process =
|
|
4100
|
+
if (options.processDiagnostics !== void 0 && hasDiagnosticContent(options.processDiagnostics)) bundle.process = {
|
|
4101
|
+
...options.processDiagnostics,
|
|
4102
|
+
scope: "suite"
|
|
4103
|
+
};
|
|
3323
4104
|
return bundle;
|
|
3324
4105
|
}
|
|
3325
4106
|
/**
|
|
@@ -3340,8 +4121,8 @@ function describeRepairBundleInvalid(reason) {
|
|
|
3340
4121
|
switch (reason) {
|
|
3341
4122
|
case "notJson": return "번들 파일이 JSON 이 아닙니다. `--repair-bundle` 이 만든 파일이 맞는지, 편집 중에 깨지지 않았는지 확인하세요.";
|
|
3342
4123
|
case "notObject": return "번들 최상위가 JSON 객체가 아닙니다. 배열이나 문자열이 담긴 다른 파일을 가리키고 있지 않은지 경로를 확인하세요.";
|
|
3343
|
-
case "versionMismatch": return `번들 형식 버전이 이 CLI 가 아는
|
|
3344
|
-
case "missingField": return "번들에 필요한 항목이 없거나 값이 형식과 다릅니다. `spec` 의 `suiteId`·`suiteName`·`approval`, 각 실패의 `caseId`·`caseName`·`status`·`diagnostics`, 각 진단의 `code`·`message` 가 있어야 합니다. `mcpeak test --repair-bundle` 로 다시 만드세요.";
|
|
4124
|
+
case "versionMismatch": return `번들 형식 버전이 이 CLI 가 아는 3 이 아닙니다. 최신 \`mcpeak test --repair-bundle\` 로 다시 만드세요.`;
|
|
4125
|
+
case "missingField": return "번들에 필요한 항목이 없거나 값이 형식과 다릅니다. `spec` 의 `suiteId`·`suiteName`·`approval`·`runHistory`, 각 실패의 `caseId`·`caseName`·`status`·`assertions`·`diagnostics`, 각 진단의 `code`·`message`, 각 단언의 `type`·`status`, 도구 목록 `tools` 와 각 도구의 `name`·`inputSchema`, 대상 `target` 의 `transport` 가 있어야 합니다. `process` 가 있으면 그 안에 `scope` 도 있어야 합니다. `mcpeak test --repair-bundle` 로 다시 만드세요.";
|
|
3345
4126
|
case "emptyFailures": return "번들에 실패한 케이스가 없습니다. 진단할 근거가 없으므로 provider 를 부르지 않습니다. 실패가 있는 실행에서 번들을 다시 만드세요.";
|
|
3346
4127
|
}
|
|
3347
4128
|
}
|
|
@@ -3358,6 +4139,7 @@ const APPROVAL_STATES = [
|
|
|
3358
4139
|
"mismatched",
|
|
3359
4140
|
"absent"
|
|
3360
4141
|
];
|
|
4142
|
+
const RUN_HISTORIES = ["present", "absent"];
|
|
3361
4143
|
const FAILURE_STATUSES = [
|
|
3362
4144
|
"failed",
|
|
3363
4145
|
"timedOut",
|
|
@@ -3365,6 +4147,13 @@ const FAILURE_STATUSES = [
|
|
|
3365
4147
|
"notRun"
|
|
3366
4148
|
];
|
|
3367
4149
|
const APPROVED_AS = ["passed", "serverDefect"];
|
|
4150
|
+
/** runner 의 `AssertionResult.status` 와 같은 네 값이다. 케이스 상태와 다른 유니온이다. */
|
|
4151
|
+
const ASSERTION_STATUSES = [
|
|
4152
|
+
"passed",
|
|
4153
|
+
"failed",
|
|
4154
|
+
"skipped",
|
|
4155
|
+
"notRun"
|
|
4156
|
+
];
|
|
3368
4157
|
const isOneOf = (value, allowed) => typeof value === "string" && allowed.includes(value);
|
|
3369
4158
|
/**
|
|
3370
4159
|
* 소비하는 필드가 실제로 있고 형식이 맞는지 본다.
|
|
@@ -3381,6 +4170,12 @@ function failureShapeValid(failure) {
|
|
|
3381
4170
|
if (failure.tool !== void 0 && typeof failure.tool !== "string") return false;
|
|
3382
4171
|
if (failure.approvedAs !== void 0 && !isOneOf(failure.approvedAs, APPROVED_AS)) return false;
|
|
3383
4172
|
if (failure.input !== void 0 && !plainObject(failure.input)) return false;
|
|
4173
|
+
if (!Array.isArray(failure.assertions)) return false;
|
|
4174
|
+
for (const assertion of failure.assertions) {
|
|
4175
|
+
if (!plainObject(assertion)) return false;
|
|
4176
|
+
if (typeof assertion.type !== "string") return false;
|
|
4177
|
+
if (!isOneOf(assertion.status, ASSERTION_STATUSES)) return false;
|
|
4178
|
+
}
|
|
3384
4179
|
if (!Array.isArray(failure.diagnostics)) return false;
|
|
3385
4180
|
for (const diagnostic of failure.diagnostics) {
|
|
3386
4181
|
if (!plainObject(diagnostic)) return false;
|
|
@@ -3390,11 +4185,20 @@ function failureShapeValid(failure) {
|
|
|
3390
4185
|
}
|
|
3391
4186
|
return true;
|
|
3392
4187
|
}
|
|
4188
|
+
function toolShapeValid(tool) {
|
|
4189
|
+
if (!plainObject(tool)) return false;
|
|
4190
|
+
if (typeof tool.name !== "string" || tool.name === "") return false;
|
|
4191
|
+
if (!plainObject(tool.inputSchema)) return false;
|
|
4192
|
+
if (tool.outputSchema !== void 0 && !plainObject(tool.outputSchema)) return false;
|
|
4193
|
+
if (tool.description !== void 0 && typeof tool.description !== "string") return false;
|
|
4194
|
+
return true;
|
|
4195
|
+
}
|
|
3393
4196
|
function specShapeValid(spec) {
|
|
3394
4197
|
if (!plainObject(spec)) return false;
|
|
3395
4198
|
if (typeof spec.suiteId !== "string") return false;
|
|
3396
4199
|
if (typeof spec.suiteName !== "string") return false;
|
|
3397
4200
|
if (!isOneOf(spec.approval, APPROVAL_STATES)) return false;
|
|
4201
|
+
if (!isOneOf(spec.runHistory, RUN_HISTORIES)) return false;
|
|
3398
4202
|
if (typeof spec.fingerprint !== "string") return false;
|
|
3399
4203
|
return true;
|
|
3400
4204
|
}
|
|
@@ -3412,7 +4216,7 @@ function readRepairBundle(text) {
|
|
|
3412
4216
|
status: "invalid",
|
|
3413
4217
|
reason: "notObject"
|
|
3414
4218
|
};
|
|
3415
|
-
if (parsed.bundleVersion !==
|
|
4219
|
+
if (parsed.bundleVersion !== 3) return {
|
|
3416
4220
|
status: "invalid",
|
|
3417
4221
|
reason: "versionMismatch"
|
|
3418
4222
|
};
|
|
@@ -3428,6 +4232,28 @@ function readRepairBundle(text) {
|
|
|
3428
4232
|
status: "invalid",
|
|
3429
4233
|
reason: "missingField"
|
|
3430
4234
|
};
|
|
4235
|
+
if (!Array.isArray(parsed.tools)) return {
|
|
4236
|
+
status: "invalid",
|
|
4237
|
+
reason: "missingField"
|
|
4238
|
+
};
|
|
4239
|
+
for (const tool of parsed.tools) if (!toolShapeValid(tool)) return {
|
|
4240
|
+
status: "invalid",
|
|
4241
|
+
reason: "missingField"
|
|
4242
|
+
};
|
|
4243
|
+
if (!plainObject(parsed.target) || typeof parsed.target.transport !== "string") return {
|
|
4244
|
+
status: "invalid",
|
|
4245
|
+
reason: "missingField"
|
|
4246
|
+
};
|
|
4247
|
+
if (parsed.process !== void 0) {
|
|
4248
|
+
if (!plainObject(parsed.process)) return {
|
|
4249
|
+
status: "invalid",
|
|
4250
|
+
reason: "missingField"
|
|
4251
|
+
};
|
|
4252
|
+
if (parsed.process.scope !== "suite") return {
|
|
4253
|
+
status: "invalid",
|
|
4254
|
+
reason: "missingField"
|
|
4255
|
+
};
|
|
4256
|
+
}
|
|
3431
4257
|
if (parsed.failures.length === 0) return {
|
|
3432
4258
|
status: "invalid",
|
|
3433
4259
|
reason: "emptyFailures"
|
|
@@ -3506,6 +4332,16 @@ function parseRepairCommand(argv) {
|
|
|
3506
4332
|
* `repair` 명령. 번들을 읽고, 전송 내용을 확인받고, provider 에게 진단을 물어 화면에 찍는다.
|
|
3507
4333
|
* 파일도 명세도 고치지 않는다. 종료 코드는 운영 실패에만 1 이다(설계서 §7.1).
|
|
3508
4334
|
*/
|
|
4335
|
+
/**
|
|
4336
|
+
* 진단 요청이 크기 상한을 넘었을 때의 문장.
|
|
4337
|
+
*
|
|
4338
|
+
* `repair-render.ts` 가 아니라 여기 두는 이유는 그 파일이 화면 조립을 맡고 이 문장은 이
|
|
4339
|
+
* 명령의 실패 경로에만 쓰이기 때문이다. 다른 실패 문장들처럼 `deps.writeStderr` 로 나간다.
|
|
4340
|
+
*
|
|
4341
|
+
* 무엇이 왜 큰지와 사용자가 할 수 있는 일을 함께 적는다. 상한 계산이 틀렸을 때 스택
|
|
4342
|
+
* 트레이스만 뜨면 사용자는 할 수 있는 일이 없다(#393).
|
|
4343
|
+
*/
|
|
4344
|
+
const REPAIR_REQUEST_TOO_LARGE_LINE = "오류 [REPAIR_REQUEST_TOO_LARGE]: 진단 요청이 크기 상한을 넘었습니다.\n 번들의 도구 선언이 너무 큽니다. --max-cases 를 줄여 실패 케이스 수를 낮추면\n 함께 실리는 도구도 줄어듭니다.\n";
|
|
3509
4345
|
async function runRepairCommand(argv, deps) {
|
|
3510
4346
|
let input;
|
|
3511
4347
|
try {
|
|
@@ -3533,27 +4369,49 @@ async function runRepairCommand(argv, deps) {
|
|
|
3533
4369
|
return 1;
|
|
3534
4370
|
}
|
|
3535
4371
|
const provider = deps.diagnosis.providers[input.providerId](input.model);
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
4372
|
+
/**
|
|
4373
|
+
* 요청 크기 상한을 넘으면 `prepareDiagnosisRequest` 가 `RangeError` 를 던진다.
|
|
4374
|
+
* 도구 선언을 싣기 시작하면서 실제로 닿을 수 있는 자리가 됐다(#393).
|
|
4375
|
+
*
|
|
4376
|
+
* 던진 것을 그대로 두면 스택 트레이스가 화면에 뜨고 사용자가 할 수 있는 일이 없다.
|
|
4377
|
+
* 번들을 다시 만들 수도 없다. 그 안에 이미 도구가 들어 있기 때문이다.
|
|
4378
|
+
*/
|
|
4379
|
+
let preview;
|
|
4380
|
+
try {
|
|
4381
|
+
preview = deps.diagnosis.prepare({
|
|
4382
|
+
specTrust: {
|
|
4383
|
+
fingerprint: bundle.spec.approval,
|
|
4384
|
+
runHistory: bundle.spec.runHistory
|
|
4385
|
+
},
|
|
4386
|
+
suite: {
|
|
4387
|
+
id: bundle.spec.suiteId,
|
|
4388
|
+
name: bundle.spec.suiteName
|
|
4389
|
+
},
|
|
4390
|
+
failures: bundle.failures.map((failure) => ({
|
|
4391
|
+
caseId: failure.caseId,
|
|
4392
|
+
caseName: failure.caseName,
|
|
4393
|
+
...failure.tool === void 0 ? {} : { tool: failure.tool },
|
|
4394
|
+
...failure.input === void 0 ? {} : { input: failure.input },
|
|
4395
|
+
...failure.approvedAs === void 0 ? {} : { approvedAs: failure.approvedAs },
|
|
4396
|
+
diagnostics: failure.diagnostics.map((diagnostic) => ({ ...diagnostic }))
|
|
4397
|
+
})),
|
|
4398
|
+
...bundle.process === void 0 ? {} : { processDiagnostics: bundle.process },
|
|
4399
|
+
tools: bundle.tools.map((tool) => ({
|
|
4400
|
+
name: tool.name,
|
|
4401
|
+
inputSchema: tool.inputSchema,
|
|
4402
|
+
...tool.description === void 0 ? {} : { description: tool.description },
|
|
4403
|
+
...tool.outputSchema === void 0 ? {} : { outputSchema: tool.outputSchema }
|
|
4404
|
+
})),
|
|
4405
|
+
providerId: input.providerId,
|
|
4406
|
+
model: input.model,
|
|
4407
|
+
maxCases: input.maxCases,
|
|
4408
|
+
includeStderr: input.includeStderr
|
|
4409
|
+
});
|
|
4410
|
+
} catch (error) {
|
|
4411
|
+
if (!(error instanceof RangeError)) throw error;
|
|
4412
|
+
deps.writeStderr(REPAIR_REQUEST_TOO_LARGE_LINE);
|
|
4413
|
+
return 1;
|
|
4414
|
+
}
|
|
3557
4415
|
const confirmView = {
|
|
3558
4416
|
providerId: input.providerId,
|
|
3559
4417
|
model: input.model,
|
|
@@ -3562,8 +4420,11 @@ async function runRepairCommand(argv, deps) {
|
|
|
3562
4420
|
omittedFailures: preview.omitted.failures,
|
|
3563
4421
|
maxCases: input.maxCases,
|
|
3564
4422
|
approval: bundle.spec.approval,
|
|
4423
|
+
runHistory: bundle.spec.runHistory,
|
|
3565
4424
|
includeStderr: input.includeStderr,
|
|
3566
4425
|
...preview.request.processDiagnostics === void 0 ? {} : { stderr: preview.request.processDiagnostics.stderr },
|
|
4426
|
+
sentTools: bundle.tools.length,
|
|
4427
|
+
omittedToolSchemas: bundle.tools.filter((tool) => tool.schemasOmitted === true).length,
|
|
3567
4428
|
requestBytes: preview.byteLength
|
|
3568
4429
|
};
|
|
3569
4430
|
if (!input.yes) {
|
|
@@ -3737,6 +4598,7 @@ async function startExternalWiring(options) {
|
|
|
3737
4598
|
mode: "record",
|
|
3738
4599
|
sessionId: SESSION_ID,
|
|
3739
4600
|
store,
|
|
4601
|
+
...options.origin === void 0 ? {} : { origin: options.origin },
|
|
3740
4602
|
...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
|
|
3741
4603
|
}) : await startExternalCoordinator({
|
|
3742
4604
|
mode: "replay",
|
|
@@ -3873,6 +4735,7 @@ function parseTestCommand(argv) {
|
|
|
3873
4735
|
let recordSessionPath;
|
|
3874
4736
|
const args = [];
|
|
3875
4737
|
const headerEnv = createHeaderEnvCollector();
|
|
4738
|
+
const envForward = createEnvForwardCollector();
|
|
3876
4739
|
for (let index = 1; index < argv.length; index += 1) {
|
|
3877
4740
|
const token = argv[index] ?? "";
|
|
3878
4741
|
/**
|
|
@@ -3925,6 +4788,15 @@ function parseTestCommand(argv) {
|
|
|
3925
4788
|
const rejected = headerEnv.add(parsed.value.header, parsed.value.envName);
|
|
3926
4789
|
if (rejected !== void 0) fail(rejected);
|
|
3927
4790
|
}
|
|
4791
|
+
} else if (token === "--env" || token.startsWith("--env=")) {
|
|
4792
|
+
const read = readOptionValue(argv, token, "--env", index);
|
|
4793
|
+
index = read.index;
|
|
4794
|
+
const parsed = parseEnvForwardOption(read.value);
|
|
4795
|
+
if (!parsed.ok) fail(parsed.message);
|
|
4796
|
+
else {
|
|
4797
|
+
const rejected = envForward.add(parsed.value);
|
|
4798
|
+
if (rejected !== void 0) fail(rejected);
|
|
4799
|
+
}
|
|
3928
4800
|
} else if (token === "--arg" || token.startsWith("--arg=")) {
|
|
3929
4801
|
let value;
|
|
3930
4802
|
if (token === "--arg") {
|
|
@@ -4024,6 +4896,7 @@ function parseTestCommand(argv) {
|
|
|
4024
4896
|
hint: TEST_USAGE_HINT
|
|
4025
4897
|
});
|
|
4026
4898
|
if (url !== void 0 && args.length > 0) fail("`--arg` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--arg` 는 우리가 띄우는 프로세스에 넘길 인자입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
|
|
4899
|
+
if (url !== void 0 && envForward.snapshot().length > 0) fail("`--env` 는 `--url` 과 함께 쓸 수 없습니다.\n→ `--env` 는 우리가 띄우는 프로세스의 환경에 넣을 변수입니다. 원격 서버에는 띄울 프로세스가 없습니다.");
|
|
4027
4900
|
if (command !== void 0 && !headerEnv.isEmpty()) fail("`--header-env` 는 `--url` 과 함께만 쓸 수 있습니다.\n→ 헤더는 HTTP 요청에 실립니다. `--command` 로 띄운 서버와는 stdio 로 이야기합니다.");
|
|
4028
4901
|
if (url !== void 0 && stderrLines !== void 0) fail("`--stderr-lines` 는 `--url` 과 함께 쓸 수 없습니다.\n→ 이 옵션은 우리가 띄운 프로세스의 stderr 를 보여줍니다. 원격 서버에는 그 프로세스가 없습니다.\n→ 원격 서버 실패는 엔드포인트와 HTTP 상태로 진단합니다. 그 블록은 항상 나옵니다.");
|
|
4029
4902
|
if (url !== void 0 && (sessionPath !== void 0 || recordSessionPath !== void 0)) fail("External 세션 옵션은 `--url` 과 함께 쓸 수 없습니다.\n→ External 배선은 우리가 띄운 자식 프로세스의 환경변수로 들어갑니다.\n→ 원격 서버는 우리가 띄우지 않으므로 그 주입 지점이 없습니다.");
|
|
@@ -4034,7 +4907,8 @@ function parseTestCommand(argv) {
|
|
|
4034
4907
|
target: url === void 0 ? Object.freeze({
|
|
4035
4908
|
transport: "stdio",
|
|
4036
4909
|
command,
|
|
4037
|
-
args: Object.freeze(args)
|
|
4910
|
+
args: Object.freeze(args),
|
|
4911
|
+
envNames: envForward.snapshot()
|
|
4038
4912
|
}) : Object.freeze({
|
|
4039
4913
|
transport: "http",
|
|
4040
4914
|
url,
|
|
@@ -4881,7 +5755,9 @@ async function runCliCore(argv, dependencies, childEnv) {
|
|
|
4881
5755
|
report: finalReport,
|
|
4882
5756
|
suite: validated.value,
|
|
4883
5757
|
specApproval,
|
|
4884
|
-
processDiagnostics: settled.value
|
|
5758
|
+
processDiagnostics: settled.value,
|
|
5759
|
+
tools,
|
|
5760
|
+
target: { transport: input.target.transport }
|
|
4885
5761
|
});
|
|
4886
5762
|
if (bundle === void 0) dependencies.writeStdout(`\n${REPAIR_BUNDLE_EMPTY_LINE}`);
|
|
4887
5763
|
else try {
|
|
@@ -4961,7 +5837,7 @@ function externalOpenFailure(mode, path, error) {
|
|
|
4961
5837
|
if (code === "REPLAY_SOURCE_INVALID") return {
|
|
4962
5838
|
code: "EXTERNAL_SESSION_FAILED",
|
|
4963
5839
|
message: `녹화가 완료되지 않은 세션입니다: ${shownPath}`,
|
|
4964
|
-
hint: "
|
|
5840
|
+
hint: "녹화가 도중에 끊겼거나, 지원하지 않는 응답(비-JSON·redirect)을 만나 호출을 끝내지 못한 세션입니다. `--record-session` 으로 다시 녹화하세요."
|
|
4965
5841
|
};
|
|
4966
5842
|
if (code === "SCHEMA_VERSION_UNSUPPORTED") return {
|
|
4967
5843
|
code: "EXTERNAL_SESSION_FAILED",
|
|
@@ -4990,10 +5866,15 @@ function externalOpenFailure(mode, path, error) {
|
|
|
4990
5866
|
function externalCloseFailure(error) {
|
|
4991
5867
|
const code = error?.code;
|
|
4992
5868
|
const detail = error instanceof Error ? error.message : String(error);
|
|
5869
|
+
if (code === "INCOMPLETE_SESSION") return {
|
|
5870
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
5871
|
+
message: "녹화를 완료하지 못했습니다. 끝나지 않은 외부 호출이 남았습니다.",
|
|
5872
|
+
hint: detail
|
|
5873
|
+
};
|
|
4993
5874
|
return {
|
|
4994
5875
|
code: "EXTERNAL_SESSION_FAILED",
|
|
4995
5876
|
message: "External 세션을 닫지 못했습니다.",
|
|
4996
|
-
hint:
|
|
5877
|
+
hint: escapeTerminalText(detail)
|
|
4997
5878
|
};
|
|
4998
5879
|
}
|
|
4999
5880
|
/** 같은 단서의 재생 판. 남지 않는 것이 아니라 **실제 네트워크로 나가는** 것이 요점이다. */
|
|
@@ -5047,7 +5928,8 @@ function externalSessionOutcome(summary, sessionPath) {
|
|
|
5047
5928
|
const shownPath = escapeTerminalText(sessionPath);
|
|
5048
5929
|
if (summary.mode === "record") {
|
|
5049
5930
|
if (summary.interactionCount === 0) return void 0;
|
|
5050
|
-
if (summary.status !== "completed") return `\n→
|
|
5931
|
+
if (summary.status !== "completed") return `\n→ 녹화가 완료되지 않은 채 닫혔습니다: ${shownPath}\n 외부 호출 ${summary.interactionCount}건을 잡았지만 재생 원본으로 쓸 수 없습니다.\n→ 테스트 판정과는 무관합니다. 녹화 자체가 도중에 중단됐습니다.
|
|
5932
|
+
→ 위에 나온 원인을 고친 뒤 다시 녹화하세요.
|
|
5051
5933
|
`;
|
|
5052
5934
|
return `\n→ 외부 호출 ${summary.interactionCount}건을 녹화했습니다: ${shownPath}\n 이 수는 어댑터가 잡은 호출만 셉니다. 범위 밖 호출은 세션에 남지 않습니다.
|
|
5053
5935
|
`;
|
|
@@ -5094,9 +5976,16 @@ function formatUtc(recordedAt, date, time) {
|
|
|
5094
5976
|
return `${date} ${time} UTC`;
|
|
5095
5977
|
}
|
|
5096
5978
|
function externalSessionNotice(summary) {
|
|
5979
|
+
const others = summary.otherProcessCalls ?? 0;
|
|
5097
5980
|
if (summary.mode === "record") {
|
|
5098
|
-
if (summary.interactionCount > 0) return
|
|
5099
|
-
|
|
5981
|
+
if (summary.interactionCount === 0 && others > 0) return `
|
|
5982
|
+
알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.
|
|
5983
|
+
→ 다른 프로세스가 먼저 이 세션의 기록을 시작해, 이 실행의 호출 ${others}건을 녹화하지 않았습니다.\n→ 서버가 외부 API를 자식 프로세스에서 부르면 먼저 시작한 쪽만 녹화됩니다.
|
|
5984
|
+
`;
|
|
5985
|
+
if (summary.interactionCount === 0) return "\n알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.\n→ 서버가 외부 API를 호출했다면 지원 범위를 벗어났는지 확인하세요.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
5986
|
+
if (others > 0) return `\n알림: 이 실행의 외부 호출 ${others}건이 다른 프로세스에서 나와 녹화되지 않았습니다.\n→ 녹화본은 이 서버가 한 일의 일부만 담고 있습니다. 재생은 나머지를 막지 못합니다.
|
|
5987
|
+
`;
|
|
5988
|
+
return;
|
|
5100
5989
|
}
|
|
5101
5990
|
if (summary.interactionCount === 0) return "\n알림: 이 세션에는 녹화된 외부 호출이 0건입니다. 재생할 것이 없었습니다.\n→ 녹화 실행이 외부 호출을 하나도 잡지 못했다는 뜻입니다. 이 세션은 아무 호출도 막지 못합니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
5102
5991
|
if (summary.consumedCount === 0) return "\n알림: 이 실행에서 녹화된 외부 호출이 하나도 재생되지 않았습니다.\n→ 지원 범위 밖의 호출은 실제 네트워크로 나갔을 수 있습니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
@@ -5148,6 +6037,7 @@ async function runCli(argv, dependencies) {
|
|
|
5148
6037
|
wiring = await startExternalWiring({
|
|
5149
6038
|
mode: mode.mode,
|
|
5150
6039
|
sessionPath: mode.path,
|
|
6040
|
+
...mode.origin === void 0 ? {} : { origin: mode.origin },
|
|
5151
6041
|
existingNodeOptions: process.env.NODE_OPTIONS
|
|
5152
6042
|
});
|
|
5153
6043
|
} catch (error) {
|
|
@@ -5161,7 +6051,7 @@ async function runCli(argv, dependencies) {
|
|
|
5161
6051
|
throw error;
|
|
5162
6052
|
}
|
|
5163
6053
|
try {
|
|
5164
|
-
const summary = await wiring.finish(
|
|
6054
|
+
const summary = await wiring.finish("completed");
|
|
5165
6055
|
const outcome = externalSessionOutcome(summary, mode.path);
|
|
5166
6056
|
if (outcome !== void 0) dependencies.writeStderr(outcome);
|
|
5167
6057
|
if (summary.mode === "replay") {
|
|
@@ -5202,10 +6092,18 @@ function externalModeOf(argv) {
|
|
|
5202
6092
|
} catch {
|
|
5203
6093
|
return;
|
|
5204
6094
|
}
|
|
5205
|
-
if (input.recordSessionPath !== void 0)
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
6095
|
+
if (input.recordSessionPath !== void 0) {
|
|
6096
|
+
const origin = input.target.transport === "stdio" ? {
|
|
6097
|
+
command: input.target.command,
|
|
6098
|
+
args: input.target.args,
|
|
6099
|
+
suitePath: input.suitePath
|
|
6100
|
+
} : void 0;
|
|
6101
|
+
return {
|
|
6102
|
+
mode: "record",
|
|
6103
|
+
path: input.recordSessionPath,
|
|
6104
|
+
...origin === void 0 ? {} : { origin }
|
|
6105
|
+
};
|
|
6106
|
+
}
|
|
5209
6107
|
if (input.sessionPath !== void 0) return {
|
|
5210
6108
|
mode: "replay",
|
|
5211
6109
|
path: input.sessionPath
|
|
@@ -5218,6 +6116,12 @@ Object.defineProperty(exports, "GLOBAL_HELP", {
|
|
|
5218
6116
|
return GLOBAL_HELP;
|
|
5219
6117
|
}
|
|
5220
6118
|
});
|
|
6119
|
+
Object.defineProperty(exports, "REVIEW_BACK_VALUE", {
|
|
6120
|
+
enumerable: true,
|
|
6121
|
+
get: function() {
|
|
6122
|
+
return REVIEW_BACK_VALUE;
|
|
6123
|
+
}
|
|
6124
|
+
});
|
|
5221
6125
|
Object.defineProperty(exports, "commandDiscovery", {
|
|
5222
6126
|
enumerable: true,
|
|
5223
6127
|
get: function() {
|