- 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
114 lines
7.1 KiB
JavaScript
114 lines
7.1 KiB
JavaScript
// 경로 판정(V-36). SKILL.md 0-2 는 "경로와 근거를 한 줄로 말한다"를 출력 계약으로 둔다
|
||
// (예: "국소 — 새 화면 없음, 토큰 재정의 없음, 섹션 1개."). 응답 텍스트에서 그 한 줄을 뽑아 시나리오의
|
||
// 기대 경로(`expectedRoute`)와 대조한다. 모델을 부르지 않는 정적 재판정이다.
|
||
//
|
||
// 실측한 표기(outputs/eval/interview/runs/redteam-2026-10-02·-r2, 2026-10-02):
|
||
// "경로는 **연장**(새 화면, design.md 있음 …)" Claude S4
|
||
// "경로 판정: 새 화면(A) + design.md 없음 → **전체 경로**" Claude S11
|
||
// "리뷰 경로(국소 수정 없음, 평가만) — 0단계만 거치고 …" Claude S17 ← "국소 수정"은 경로 표기가 아니다
|
||
// "국소 경로(섹션 1개, 새 토큰 체계 아님)로 판단해 …" Claude S6
|
||
// "국소 수정으로 진행합니다." / "국소 수정입니다." Codex S6·S13
|
||
// "새 사이트를 만드는 전체 경로로 진행합니다." Codex S5
|
||
// "기존 `design.md`가 있어 … ‘연장’ 경로입니다." Codex S4
|
||
// "… 그건 리뷰가 아니라 국소/연장 구현 경로로 넘어가는 일" Claude S7 ← 둘을 나란히 든 언급은 표기가 아니다
|
||
//
|
||
// 못 잡는 것: 경로 이름 없이 풀어 쓴 말("버튼 하나만 고칩니다"), 질문 도구의 선택지 안에만 적은 경로,
|
||
// 표·코드 블록 안의 경로 표(스킬 본문을 인용한 경우는 걸러 내지 못한다 — 판정 메모의 근거 줄을 읽고 확인한다).
|
||
|
||
export const ROUTES = ["전체", "전체(리디자인)", "연장", "국소", "리뷰", "피드백"];
|
||
|
||
const KO = "전체\\s*\\(\\s*리디자인\\s*\\)|리디자인|전체|연장|국소|리뷰|피드백";
|
||
const EN = "full\\s*\\(\\s*redesign\\s*\\)|redesign|full|extension|extend|local|review|feedback";
|
||
const EN_MAP = { full: "전체", redesign: "전체(리디자인)", extension: "연장", extend: "연장", local: "국소", review: "리뷰", feedback: "피드백" };
|
||
// 경로 이름을 감싸는 꾸밈(굵게·따옴표·백틱)
|
||
const DECO = "[*_`'\"‘’“”「」]*";
|
||
|
||
function canon(raw) {
|
||
const s = String(raw).replace(/\s+/g, "").toLowerCase();
|
||
if (/리디자인|redesign/.test(s)) return "전체(리디자인)";
|
||
if (EN_MAP[s]) return EN_MAP[s];
|
||
return ROUTES.includes(s) ? s : null;
|
||
}
|
||
|
||
// 강한 표기: "X 경로", "경로는 X", "경로: X", "X — 근거"(줄 머리), 영어 "X route|path", "Route: X".
|
||
// 약한 표기: "X 수정으로 진행", "X(으)로 진행/판단/처리" — 강한 표기가 하나도 없을 때만 쓴다.
|
||
const PATTERNS = [
|
||
{ strength: 2, re: new RegExp(`(?<![가-힣A-Za-z/·,])${DECO}(${KO})${DECO}\\s*(?:제작|구현|수정|작업)?\\s*경로(?!\\s*(?:가|는|도)?\\s*아니)`, "g") },
|
||
{ strength: 2, re: new RegExp(`경로(?:\\s*판정)?\\s*(?:는|은|:|:)[^\\n]{0,80}?${DECO}(${KO})${DECO}(?![가-힣]*\\s*(?:이|가)?\\s*아니)`, "g") },
|
||
{ strength: 2, re: new RegExp(`^[ \\t>*-]*${DECO}(${KO})${DECO}\\s*[—–:]\\s`, "gm") },
|
||
{ strength: 2, re: new RegExp(`\\b(${EN})\\s+(?:route|path)\\b`, "gi") },
|
||
{ strength: 2, re: new RegExp(`\\b(?:route|path)\\s*(?:is|:)\\s*${DECO}(${EN})\\b`, "gi") },
|
||
{ strength: 1, re: new RegExp(`(?<![가-힣A-Za-z/·,])(${KO})\\s*수정\\s*(?:으로|입니다|이라|이므로|이어서)`, "g") },
|
||
{ strength: 1, re: new RegExp(`(?<![가-힣A-Za-z/·,])${DECO}(${KO})${DECO}\\s*(?:으로|로)\\s*(?:진행|판단|판정|처리|올)`, "g") },
|
||
];
|
||
|
||
function lineAt(text, index) {
|
||
const start = text.lastIndexOf("\n", index - 1) + 1;
|
||
const end = text.indexOf("\n", index);
|
||
return text.slice(start, end === -1 ? text.length : end).trim();
|
||
}
|
||
|
||
/**
|
||
* 텍스트에서 경로 표기를 모두 뽑는다. 반환: [{ route, strength, index, line }] (나온 순서).
|
||
* "국소/연장 구현 경로"처럼 경로 이름을 나란히 든 언급은 표기로 보지 않는다.
|
||
*/
|
||
export function detectRoutes(text) {
|
||
const s = String(text || "");
|
||
const found = [];
|
||
for (const p of PATTERNS) {
|
||
for (const m of s.matchAll(p.re)) {
|
||
const route = canon(m[1]);
|
||
if (!route) continue;
|
||
const at = m.index + m[0].indexOf(m[1]);
|
||
// 바로 뒤에 다른 경로 이름이 `/`·`·` 로 이어지면 나열이다.
|
||
if (new RegExp(`^\\s*[/·,]\\s*(?:${KO})`).test(s.slice(at + m[1].length))) continue;
|
||
found.push({ route, strength: p.strength, index: at, line: lineAt(s, at) });
|
||
}
|
||
}
|
||
found.sort((a, b) => a.index - b.index || b.strength - a.strength);
|
||
// 같은 자리를 여러 패턴이 잡은 것은 하나로 합친다.
|
||
return found.filter((f, i) => i === 0 || f.index !== found[i - 1].index);
|
||
}
|
||
|
||
/**
|
||
* 실행이 말한 최종 경로. 강한 표기가 있으면 그중 마지막(경로를 올렸으면 올린 뒤가 최종이다), 없으면 약한 표기의 마지막.
|
||
* 반환: { route, line, routes: 서로 다른 경로를 나온 순서로 } | null
|
||
*/
|
||
export function detectRoute(text) {
|
||
const all = detectRoutes(text);
|
||
if (all.length === 0) return null;
|
||
const strong = all.filter((f) => f.strength === 2);
|
||
const pool = strong.length ? strong : all;
|
||
const last = pool[pool.length - 1];
|
||
const routes = [];
|
||
for (const f of pool) if (routes[routes.length - 1] !== f.route) routes.push(f.route);
|
||
return { route: last.route, line: last.line, routes };
|
||
}
|
||
|
||
/**
|
||
* 시나리오의 기대 경로와 대조한다. `expectedRoute` 는 문자열이거나 허용 경로 배열이다. 없으면 null(판정 대상 아님).
|
||
* PASS 말한 최종 경로가 기대와 같다
|
||
* FAIL 다른 경로를 말했다
|
||
* REVIEW 경로를 말하지 않았다(출력 계약 미이행 — 원문을 읽고 확정), 또는 전체 ↔ 전체(리디자인) 차이
|
||
* 기본 판정(judge)과 따로 낸다. 스킬이 호출되지 않은 실행은 호출 쪽에서 거른다.
|
||
* @param {{expectedRoute?: string|string[]}} scenario
|
||
* @param {Array} events normalizeClaude/normalizeCodex 결과(모든 턴의 텍스트를 본다)
|
||
*/
|
||
export function judgeRoute(scenario, events) {
|
||
if (!scenario?.expectedRoute) return null;
|
||
const expected = [].concat(scenario.expectedRoute);
|
||
const text = events
|
||
.filter((e) => e.kind === "text")
|
||
.map((e) => e.text)
|
||
.join("\n\n");
|
||
const got = detectRoute(text);
|
||
const base = { expected: expected.join("|"), route: got?.route ?? null, routes: got?.routes ?? [], evidence: got?.line?.slice(0, 200) ?? null };
|
||
if (!got) return { ...base, verdict: "REVIEW", note: "경로와 근거 한 줄이 없다(SKILL.md 0-2 출력 계약) — 원문을 읽고 확정할 것" };
|
||
const moved = got.routes.length > 1 ? ` (말한 순서: ${got.routes.join(" → ")})` : "";
|
||
if (expected.includes(got.route)) return { ...base, verdict: "PASS", note: `경로 ${got.route}${moved}` };
|
||
const family = (r) => r.replace(/\(리디자인\)$/, "");
|
||
if (expected.some((e) => family(e) === family(got.route))) {
|
||
return { ...base, verdict: "REVIEW", note: `경로 ${got.route} — 기대는 ${base.expected}(전체와 전체(리디자인)의 차이)${moved}` };
|
||
}
|
||
return { ...base, verdict: "FAIL", note: `경로 ${got.route} — 기대는 ${base.expected}${moved}` };
|
||
}
|