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
122
build/eval/interview/test/route.test.mjs
Normal file
122
build/eval/interview/test/route.test.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// 경로 판정(V-36) 단위 테스트. 모델을 부르지 않는다. 표기 예문은 실제 실행 기록
|
||||
// (outputs/eval/interview/runs/redteam-2026-10-02·-r2, 2026-10-02)에서 옮긴 것이다.
|
||||
//
|
||||
// node --test build/eval/interview/test/route.test.mjs
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { detectRoute, detectRoutes, judgeRoute, ROUTES } from "../lib/route.mjs";
|
||||
import { normalizeClaude } from "../lib/judge.mjs";
|
||||
import { staticJudgements } from "../lib/static.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCENARIOS = JSON.parse(fs.readFileSync(path.join(HERE, "..", "scenarios.json"), "utf8"));
|
||||
const byId = Object.fromEntries(SCENARIOS.map((s) => [s.id, s]));
|
||||
const textEvents = (...texts) => texts.map((text) => ({ kind: "text", text }));
|
||||
|
||||
// [원문, 기대 경로]
|
||||
const STATED = [
|
||||
["국소 — 새 화면 없음, 토큰 재정의 없음, 섹션 1개.", "국소"], // SKILL.md 0-2 의 예문
|
||||
["경로는 **연장**(새 화면, design.md 있음 — 톤·토큰은 이미 확정됨)이고, 고유명사 슬롯이 비어 있어 인터뷰가 필요합니다.", "연장"],
|
||||
["경로 판정: 새 화면(A) + design.md 없음(C 아니오) → **전체 경로**. 필수 슬롯은 모두 확정됐으니 1단계로 진행합니다.", "전체"],
|
||||
["리뷰 경로(국소 수정 없음, 평가만) — 0단계만 거치고 코드 기반 5′ 감사로 진행합니다.", "리뷰"],
|
||||
["- 국소 경로(섹션 1개, 새 토큰 체계 아님)로 판단해 인터뷰 없이 바로 진행했습니다.", "국소"],
|
||||
["국소 수정으로 진행합니다. 현재 기본색과 hover 색이 모두 `#d97757`이라 색 변화가 없습니다.", "국소"],
|
||||
["국소 수정입니다. 같은 녹색 계열의 `#32493b`로 hover 색을 한 단계 어둡게 하겠습니다.", "국소"],
|
||||
["새 사이트를 만드는 전체 경로로 진행합니다. 가정은 한국어 고객 대상의 작은 꽃집입니다.", "전체"],
|
||||
["전체 제작 경로로 진행합니다.", "전체"],
|
||||
["기존 `design.md`가 있어 디자인을 이어가는 ‘연장’ 경로입니다.", "연장"],
|
||||
["범위: 평가만(리뷰 경로) · 실제 브라우저 렌더 도구가 이 환경에 없어 코드 기반 정적 분석으로 진행했습니다.", "리뷰"],
|
||||
["방향을 바꾸는 요청이라 전체(리디자인) 경로로 갑니다. 기존 design.md 는 보존 자산 목록의 입력으로 씁니다.", "전체(리디자인)"],
|
||||
["Local route — no new screen, no token redefinition, one section.", "국소"],
|
||||
["Route: extension. The existing tokens stay as they are.", "연장"],
|
||||
];
|
||||
|
||||
for (const [text, route] of STATED) {
|
||||
test(`경로 표기를 읽는다: ${route} ← "${text.slice(0, 40)}…"`, () => {
|
||||
assert.equal(detectRoute(text)?.route, route);
|
||||
});
|
||||
}
|
||||
|
||||
// 경로 표기가 아닌 말. 경로 이름이 들어 있어도 잡으면 안 된다.
|
||||
const NOT_STATED = [
|
||||
"다음 단계로 사진을 채우고 싶다면, 그건 리뷰가 아니라 국소/연장 구현 경로로 넘어가는 일이라 별도로 알려주면 된다.", // 나열
|
||||
"톤·업종·토큰 체계는 이미 확정돼 있고 국소 색상 값 조정이라 인터뷰 없이 바로 진행한다.", // 풀어 쓴 말
|
||||
"페이지 전체를 리뷰하면 될까요, 아니면 시각 디자인만 중점적으로 볼까요?",
|
||||
"“상담 신청”은 연락처 자리표시자로 이동합니다. 실제 신청 경로가 필요합니다.",
|
||||
"새 사이트이므로 전체 디자인 과정을 진행하고 있습니다.",
|
||||
"The full page was reviewed at 1440 and 390.",
|
||||
];
|
||||
|
||||
for (const text of NOT_STATED) {
|
||||
test(`경로 표기가 아니다: "${text.slice(0, 40)}…"`, () => {
|
||||
assert.equal(detectRoute(text), null, JSON.stringify(detectRoutes(text)));
|
||||
});
|
||||
}
|
||||
|
||||
test("ROUTES 는 SKILL.md 0-2 표의 경로 이름이다", () => {
|
||||
assert.deepEqual(ROUTES, ["전체", "전체(리디자인)", "연장", "국소", "리뷰", "피드백"]);
|
||||
});
|
||||
|
||||
test("경로를 올렸으면 마지막에 말한 경로가 최종이다", () => {
|
||||
const r = detectRoute("국소 경로로 시작했습니다.\n\n손대는 섹션이 3개로 늘어 연장 경로로 올립니다.");
|
||||
assert.equal(r.route, "연장");
|
||||
assert.deepEqual(r.routes, ["국소", "연장"]);
|
||||
});
|
||||
|
||||
test("강한 표기가 있으면 약한 표기(‘국소 수정으로’)는 쓰지 않는다", () => {
|
||||
const r = detectRoute("연장 경로입니다. 가격 표는 새 화면이고, 푸터 문구는 국소 수정으로 진행하면 됩니다.");
|
||||
assert.equal(r.route, "연장");
|
||||
assert.deepEqual(r.routes, ["연장"]);
|
||||
});
|
||||
|
||||
test("judgeRoute: 기대와 같으면 PASS, 다르면 FAIL, 표기가 없으면 REVIEW, 기대가 없으면 null", () => {
|
||||
assert.equal(judgeRoute(byId.S6, textEvents("국소 수정으로 진행합니다.")).verdict, "PASS");
|
||||
const fail = judgeRoute(byId.S6, textEvents("토큰을 새로 만들어야 해서 연장 경로로 올립니다."));
|
||||
assert.equal(fail.verdict, "FAIL");
|
||||
assert.match(fail.note, /경로 연장 — 기대는 국소/);
|
||||
const review = judgeRoute(byId.S6, textEvents("hover 색을 한 단계 진하게 바꿨습니다."));
|
||||
assert.equal(review.verdict, "REVIEW");
|
||||
assert.equal(review.route, null);
|
||||
assert.equal(judgeRoute(byId.S1, textEvents("전체 경로로 진행합니다.")), null);
|
||||
});
|
||||
|
||||
test("judgeRoute: 전체 ↔ 전체(리디자인)은 REVIEW, expectedRoute 배열은 어느 하나와 같으면 PASS", () => {
|
||||
const r = judgeRoute({ expectedRoute: "전체(리디자인)" }, textEvents("전체 경로로 진행합니다."));
|
||||
assert.equal(r.verdict, "REVIEW");
|
||||
assert.equal(judgeRoute({ expectedRoute: ["연장", "국소"] }, textEvents("연장 경로입니다.")).verdict, "PASS");
|
||||
});
|
||||
|
||||
test("과한 경로(J-01): design.md 없는 기존 제품에 화면 추가를 전체로 판정하면 S19 는 FAIL", () => {
|
||||
assert.equal(byId.S19.expectedRoute, "연장");
|
||||
assert.equal(judgeRoute(byId.S19, textEvents("design.md 가 없어 전체 경로로 진행합니다. 먼저 톤을 여쭙겠습니다.")).verdict, "FAIL");
|
||||
assert.equal(judgeRoute(byId.S19, textEvents("현행 색·서체가 코드에 있어 연장 경로입니다. 플랜과 가격을 알려 주세요.")).verdict, "PASS");
|
||||
});
|
||||
|
||||
test("과잉 승격(J-12): 파생 상태 토큰 하나로 연장에 올리면 S20 은 FAIL, 국소에 머물면 PASS", () => {
|
||||
assert.equal(byId.S20.expectedRoute, "국소");
|
||||
const up = judgeRoute(byId.S20, textEvents("국소 경로로 시작했지만 `--color-accent-hover` 토큰을 새로 만들어야 해서 연장 경로로 올립니다."));
|
||||
assert.equal(up.verdict, "FAIL");
|
||||
assert.match(up.note, /국소 → 연장/);
|
||||
const stay = judgeRoute(byId.S20, textEvents("국소 — 새 화면 없음, 섹션 1개. hover 색은 기존 강조색에서 파생한 상태 토큰이라 국소에 머뭅니다."));
|
||||
assert.equal(stay.verdict, "PASS");
|
||||
});
|
||||
|
||||
test("staticJudgements: 스킬이 호출되지 않은 실행은 경로를 판정하지 않는다", () => {
|
||||
const noSkill = [{ kind: "text", text: "국소 수정으로 진행합니다." }];
|
||||
assert.equal(staticJudgements(byId.S6, noSkill).route, null);
|
||||
const raw = fs.readFileSync(path.join(HERE, "transcripts", "claude-s21-interpretation-pass.ndjson"), "utf8");
|
||||
const st = staticJudgements(byId.S21, normalizeClaude(raw));
|
||||
assert.equal(st.route.verdict, "PASS");
|
||||
assert.equal(st.route.route, "전체");
|
||||
});
|
||||
|
||||
test("expectedRoute 는 아는 경로 이름만 쓴다", () => {
|
||||
for (const s of SCENARIOS) {
|
||||
if (s.expectedRoute === undefined) continue;
|
||||
for (const r of [].concat(s.expectedRoute)) assert.ok(ROUTES.includes(r), `${s.id}: 모르는 경로 '${r}'`);
|
||||
}
|
||||
assert.ok(SCENARIOS.filter((s) => s.expectedRoute).length >= 9);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue