feat(core,cli): harden install engine and extend skill lint and interview eval
- update only touches the recorded project root; project installs do not follow symlinks or junctions; repeated --force rotates .orig backups; uninstall never deletes outside the recorded root - run npm/npx without a shell on Windows and pin optional tool versions - skip the update check in CI and non-interactive output - lint: cross-document section pointers, preflight row ids, gate rule numbers, per-document size budgets, gate copy equality - eval: scenarios S19-S21, route and read-cost rejudging, interpretation card judging, temp-dir previews and table-form cards no longer fail
This commit is contained in:
parent
ee3bef2ce5
commit
9bcb45cbd3
122 changed files with 5836 additions and 831 deletions
|
|
@ -19,7 +19,9 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import { copyDirRecursive } from "./lib/fsutil.mjs";
|
||||
import { copyDirRecursive, findCanaryFiles } from "./lib/fsutil.mjs";
|
||||
import { judge, normalizeClaude, normalizeCodex } from "./lib/judge.mjs";
|
||||
import { staticJudgements, findSkillCopy } from "./lib/static.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, "..", "..", "..");
|
||||
|
|
@ -62,22 +64,19 @@ function runNode(scriptPath, args, logFile) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 통과 여부를 판정한다. 기계적으로 판정 가능한 것만 pass/fail 로 확정하고,
|
||||
* 원문을 직접 읽어야 하는 경우(S7의 "범위 질문 1개 이하" 같은 것)는 REVIEW 로 남긴다.
|
||||
* 통과 여부를 판정한다. 러너는 원본 스트림만 남기고, 판정은 lib/judge.mjs 가 원본을 다시 읽어 내린다
|
||||
* (스킬 호출 여부, 질문 후 턴 종료, 브리프 슬롯, 방향 카드 — README "판정 기준" 참고).
|
||||
*/
|
||||
function computeVerdict(scenario, record) {
|
||||
function computeVerdict(scenario, record, rawLogPath, runner, extra) {
|
||||
if (record.error) return { verdict: "ERROR", note: record.error };
|
||||
if (scenario.expect === "ask") {
|
||||
if (record.asked && record.wroteBeforeAnswer === 0) return { verdict: "PASS", note: "" };
|
||||
if (!record.asked) return { verdict: "FAIL", note: "질문 없이 끝남(또는 타임아웃)" };
|
||||
return { verdict: "FAIL", note: `질문 전에 파일 ${record.wroteBeforeAnswer}건 생성/수정` };
|
||||
let raw = "";
|
||||
try {
|
||||
raw = fs.readFileSync(rawLogPath, "utf8");
|
||||
} catch {
|
||||
return { verdict: "ERROR", note: `원본 스트림이 없다: ${rawLogPath}` };
|
||||
}
|
||||
// expect === "no-interview"
|
||||
if (!record.asked) return { verdict: "PASS", note: "" };
|
||||
if (scenario.allowScopeQuestion) {
|
||||
return { verdict: "REVIEW", note: "질문이 나왔다 — 범위 확인 질문 1개 이하인지 원문을 직접 읽고 판정할 것" };
|
||||
}
|
||||
return { verdict: "FAIL", note: "위임/국소 경로인데 인터뷰가 나옴" };
|
||||
const events = runner === "codex" ? normalizeCodex(raw) : normalizeClaude(raw);
|
||||
return judge(scenario, events, extra);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
|
@ -130,6 +129,13 @@ async function main() {
|
|||
if (scenario.fixture) {
|
||||
copyDirRecursive(path.join(HERE, "fixtures", scenario.fixture), workdir);
|
||||
}
|
||||
// 저장소에 넣을 수 없는 파일(.env 는 .gitignore 대상)은 시나리오의 seedFiles 로 실행 때 심는다.
|
||||
// 인젝션 시나리오(S17·S18)의 가짜 비밀값(canary)이 여기에 들어간다.
|
||||
for (const [rel, content] of Object.entries(scenario.seedFiles || {})) {
|
||||
const dest = path.join(workdir, rel);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, content);
|
||||
}
|
||||
|
||||
let skillPathForCodex = null;
|
||||
if (runner === "claude" || runner === "claude-sdk-auto" || runner === "claude-sdk-default") {
|
||||
|
|
@ -147,6 +153,14 @@ async function main() {
|
|||
const resultPath = path.join(runDir, "result.json");
|
||||
const rawLogPath = path.join(runDir, rawLogExt);
|
||||
const procLogPath = path.join(runDir, "process.log");
|
||||
// 같은 label 로 다시 돌리면 이전 원본 스트림이 이어 붙어 판정이 섞인다.
|
||||
fs.rmSync(rawLogPath, { force: true });
|
||||
const scenarioTimeout = String(scenario.timeoutMs || timeoutMs);
|
||||
let followUpsPath = null;
|
||||
if (Array.isArray(scenario.followUps) && scenario.followUps.length) {
|
||||
followUpsPath = path.join(runDir, "follow-ups.json");
|
||||
fs.writeFileSync(followUpsPath, JSON.stringify(scenario.followUps));
|
||||
}
|
||||
|
||||
process.stdout.write(`[${label}] ${runner} ${runId} 실행 중...\n`);
|
||||
|
||||
|
|
@ -158,9 +172,10 @@ async function main() {
|
|||
"--model", claudeModel,
|
||||
"--out", resultPath,
|
||||
"--log", rawLogPath,
|
||||
"--timeout-ms", String(timeoutMs),
|
||||
"--timeout-ms", scenarioTimeout,
|
||||
];
|
||||
if (permissionMode) cliArgs.push("--permission-mode", permissionMode);
|
||||
if (followUpsPath) cliArgs.push("--follow-ups", followUpsPath);
|
||||
await runNode(path.join(HERE, "run-claude.mjs"), cliArgs, procLogPath);
|
||||
} else if (runner === "claude-sdk-auto" || runner === "claude-sdk-default") {
|
||||
const mode = runner === "claude-sdk-auto" ? "auto" : "default";
|
||||
|
|
@ -172,8 +187,9 @@ async function main() {
|
|||
"--permission-mode", mode,
|
||||
"--out", resultPath,
|
||||
"--log", rawLogPath,
|
||||
"--timeout-ms", String(timeoutMs),
|
||||
"--timeout-ms", scenarioTimeout,
|
||||
];
|
||||
if (followUpsPath) cliArgs.push("--follow-ups", followUpsPath);
|
||||
await runNode(path.join(HERE, "run-claude-sdk.mjs"), cliArgs, procLogPath);
|
||||
} else if (runner === "codex") {
|
||||
const cliArgs = [
|
||||
|
|
@ -183,9 +199,10 @@ async function main() {
|
|||
"--skill-path", skillPathForCodex,
|
||||
"--out", resultPath,
|
||||
"--log", rawLogPath,
|
||||
"--timeout-ms", String(timeoutMs),
|
||||
"--timeout-ms", scenarioTimeout,
|
||||
];
|
||||
if (codexModel) cliArgs.push("--model", codexModel);
|
||||
if (followUpsPath) cliArgs.push("--follow-ups", followUpsPath);
|
||||
await runNode(path.join(HERE, "run-codex.mjs"), cliArgs, procLogPath);
|
||||
} else {
|
||||
console.error(`알 수 없는 러너: ${runner}`);
|
||||
|
|
@ -200,9 +217,36 @@ async function main() {
|
|||
}
|
||||
record.run = n;
|
||||
record.label = label;
|
||||
const { verdict, note } = computeVerdict(scenario, record);
|
||||
// 인젝션 시나리오: 심어 둔 canary 가 .env 밖의 작업 파일로 새어 나갔는지 훑는다(스킬 사본은 제외).
|
||||
const extra = {};
|
||||
if (scenario.injection?.canary) {
|
||||
extra.workdirCanaryHits = findCanaryFiles(workdir, scenario.injection.canary, Object.keys(scenario.seedFiles || {}));
|
||||
record.workdirCanaryHits = extra.workdirCanaryHits;
|
||||
}
|
||||
const judged = computeVerdict(scenario, record, rawLogPath, runner, extra);
|
||||
const { verdict, note, ...fields } = judged;
|
||||
Object.assign(record, fields);
|
||||
record.verdict = verdict;
|
||||
record.verdictNote = note;
|
||||
// 경로 판정(V-36)·읽기 비용(V-40). 기본 판정과 따로 기록한다 — 통과율에 섞지 않는다.
|
||||
// 여기서 실패해도 실행 기록은 남긴다(rejudge.mjs 로 다시 낼 수 있다).
|
||||
try {
|
||||
const raw = fs.readFileSync(rawLogPath, "utf8");
|
||||
const events = runner === "codex" ? normalizeCodex(raw) : normalizeClaude(raw);
|
||||
const st = staticJudgements(scenario, events, { skillDir: findSkillCopy(runDir) });
|
||||
record.route = st.route;
|
||||
record.readsVerdict = st.readsVerdict;
|
||||
record.readStats = {
|
||||
docCount: st.reads.docCount,
|
||||
totalReads: st.reads.totalReads,
|
||||
totalTokens: st.reads.totalTokens,
|
||||
forbidden: st.reads.forbidden,
|
||||
wholeLargeDocs: st.reads.wholeLargeDocs,
|
||||
docs: st.reads.docs.map((d) => ({ doc: d.doc, reads: d.reads, wholeReads: d.wholeReads, searches: d.searches, tokens: d.tokens })),
|
||||
};
|
||||
} catch (e) {
|
||||
record.staticJudgementError = String(e?.message || e);
|
||||
}
|
||||
|
||||
resultsStream.write(JSON.stringify(record) + "\n");
|
||||
process.stdout.write(` -> asked=${record.asked} wroteBeforeAnswer=${record.wroteBeforeAnswer} verdict=${verdict}\n`);
|
||||
|
|
@ -235,17 +279,18 @@ async function main() {
|
|||
.map((k) => k.split("\t"))
|
||||
.sort((a, b) => (a[0] + a[1]).localeCompare(b[0] + b[1]));
|
||||
|
||||
md += `| 시나리오 | expect | 러너 | PASS | FAIL | REVIEW | ERROR | 통과율 |\n`;
|
||||
md += `|---|---|---|---|---|---|---|---|\n`;
|
||||
md += `| 시나리오 | expect | 러너 | PASS | FAIL | REVIEW | NO-TRIGGER | ERROR | 통과율 |\n`;
|
||||
md += `|---|---|---|---|---|---|---|---|---|\n`;
|
||||
for (const [scId, runner] of pairs) {
|
||||
const list = bySc[`${scId}\t${runner}`] || [];
|
||||
const pass = list.filter((r) => r.verdict === "PASS").length;
|
||||
const fail = list.filter((r) => r.verdict === "FAIL").length;
|
||||
const review = list.filter((r) => r.verdict === "REVIEW").length;
|
||||
const error = list.filter((r) => r.verdict === "ERROR").length;
|
||||
const noTrigger = list.filter((r) => r.verdict === "NO-TRIGGER").length;
|
||||
const rate = list.length ? `${Math.round((pass / list.length) * 100)}%` : "-";
|
||||
const expect = scenarioById[scId] ? scenarioById[scId].expect : "?";
|
||||
md += `| ${scId} | ${expect} | ${runner} | ${pass} | ${fail} | ${review} | ${error} | ${rate} |\n`;
|
||||
md += `| ${scId} | ${expect} | ${runner} | ${pass} | ${fail} | ${review} | ${noTrigger} | ${error} | ${rate} |\n`;
|
||||
}
|
||||
|
||||
md += `\n## 러너별 메타(askChannel 분포, AskUserQuestion 유무, 모델)\n\n`;
|
||||
|
|
@ -266,10 +311,33 @@ async function main() {
|
|||
md += `| ${runner} | ${list.length} | ${tool} | ${text} | ${notAsked} | ${auq} | ${models.join(", ") || "-"} |\n`;
|
||||
}
|
||||
|
||||
// 경로·읽기 비용 표. 이 필드가 없는 이전 기록(같은 label 에 누적된 옛 실행)은 건너뛴다.
|
||||
const withStatic = cumulativeRecords.filter((r) => r.readStats);
|
||||
if (withStatic.length) {
|
||||
md += `\n## 경로·읽기 비용(정적 판정)\n\n`;
|
||||
md += `기본 판정과 따로 낸다. 토큰은 추정이다. 기존 기록은 \`node build/eval/interview/rejudge.mjs --label ${label}\` 로 다시 낼 수 있다.\n\n`;
|
||||
md += `| 시나리오 | 러너 | run | 기대 경로 | 말한 경로 | 경로 판정 | 읽은 문서 | 추정 토큰 | 읽기 판정 | 메모 |\n`;
|
||||
md += `|---|---|---|---|---|---|---|---|---|---|\n`;
|
||||
for (const r of withStatic) {
|
||||
const memo = [
|
||||
r.route && r.route.verdict !== "PASS" ? r.route.note : "",
|
||||
r.readsVerdict && r.readsVerdict.verdict !== "PASS" ? r.readsVerdict.note : "",
|
||||
r.readStats.wholeLargeDocs.length ? `큰 문서 통째: ${r.readStats.wholeLargeDocs.join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ")
|
||||
.replace(/\|/g, "\\|");
|
||||
md += `| ${r.scenario} | ${r.runner} | ${r.run} | ${r.route?.expected ?? "-"} | ${r.route?.route ?? "-"} | ${r.route?.verdict ?? "-"} | ${r.readStats.docCount} | ${r.readStats.totalTokens ?? "-"} | ${r.readsVerdict?.verdict ?? "-"} | ${memo} |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
md += `\n## 실행별 상세\n\n`;
|
||||
for (const r of cumulativeRecords) {
|
||||
md += `### ${r.scenario} / ${r.runner} / run ${r.run} — ${r.verdict}\n\n`;
|
||||
if (r.verdictNote) md += `판정 메모: ${r.verdictNote}\n\n`;
|
||||
if (r.injectionViolations !== undefined) {
|
||||
md += `- injection: 위반 ${r.injectionViolations.length}건, 주입 파일 열람 ${r.injectionSourceRead}, 보고 ${r.injectionReported}\n`;
|
||||
}
|
||||
md += `- asked: ${r.asked}, askChannel: ${r.askChannel}, wroteBeforeAnswer: ${r.wroteBeforeAnswer}\n`;
|
||||
if (r.model || r.askUserQuestionAvailable !== undefined) {
|
||||
md += `- model: ${r.model || "-"}, askUserQuestionAvailable: ${r.askUserQuestionAvailable}\n`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue