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:
Yun Chan 2026-10-06 16:55:38 +09:00
parent ee3bef2ce5
commit 9bcb45cbd3
122 changed files with 5836 additions and 831 deletions

View file

@ -0,0 +1,64 @@
// scenarios.json 의 정적 검증. 시나리오 정의만 더하고 실행은 하지 않은 항목(S19~S21)이 깨진 채 남지 않게 한다.
//
// node --test build/eval/interview/test/scenarios.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";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(HERE, "..");
const SCENARIOS = JSON.parse(fs.readFileSync(path.join(ROOT, "scenarios.json"), "utf8"));
const byId = Object.fromEntries(SCENARIOS.map((s) => [s.id, s]));
function filesUnder(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? filesUnder(path.join(dir, e.name)) : [path.join(dir, e.name)]));
}
test("시나리오 ID 는 겹치지 않고 S1 부터 이어진다", () => {
assert.deepEqual(SCENARIOS.map((s) => s.id), SCENARIOS.map((_, i) => `S${i + 1}`));
});
test("필수 필드와 픽스처 디렉터리가 있다", () => {
for (const s of SCENARIOS) {
assert.ok(["ask", "no-interview", "no-trigger", "direction-checkpoint"].includes(s.expect), `${s.id}: expect ${s.expect}`);
assert.equal(typeof s.prompt, "string", `${s.id}: prompt`);
if (s.fixture) assert.ok(fs.statSync(path.join(ROOT, "fixtures", s.fixture)).isDirectory(), `${s.id}: fixtures/${s.fixture}`);
if (s.cardMode !== undefined) {
assert.equal(s.cardMode, "interpretation", `${s.id}: cardMode`);
assert.equal(s.expect, "direction-checkpoint", `${s.id}: cardMode 는 direction-checkpoint 에만 쓴다`);
}
if (s.maxSkillReadTokens !== undefined) assert.ok(Number.isInteger(s.maxSkillReadTokens) && s.maxSkillReadTokens > 0, `${s.id}: maxSkillReadTokens`);
}
});
test("새 시나리오(S19~S21)의 픽스처는 시나리오의 전제를 실제로 만든다", () => {
const fx = (s) => path.join(ROOT, "fixtures", byId[s].fixture);
const names = (s) => filesUnder(fx(s)).map((p) => path.relative(fx(s), p).split(path.sep).join("/")).sort();
// S19(J-01): design.md 가 없고, 현행 시각 언어가 코드에 있다(토큰 파일 + 같은 스타일을 쓰는 화면 둘).
assert.deepEqual(names("S19"), ["features.html", "index.html", "styles/site.css", "styles/tokens.css"]);
for (const page of ["index.html", "features.html"]) assert.match(fs.readFileSync(path.join(fx("S19"), page), "utf8"), /styles\/site\.css/);
assert.match(fs.readFileSync(path.join(fx("S19"), "styles/tokens.css"), "utf8"), /--color-accent:[\s\S]*--font-display:/);
// S20(J-12): design.md 가 있고 hover 용 토큰이 없다 — 고치려면 파생 상태 토큰을 더해야 한다.
assert.deepEqual(names("S20"), ["design.md", "index.html"]);
for (const f of names("S20")) assert.doesNotMatch(fs.readFileSync(path.join(fx("S20"), f), "utf8"), /--[a-z-]*hover/);
assert.match(fs.readFileSync(path.join(fx("S20"), "design.md"), "utf8"), /색은 토큰으로만 쓴다/);
assert.equal(byId.S20.prompt, byId.S6.prompt);
// S21(J-08): 프롬프트가 가리키는 시안 파일이 있다.
const mock = /refs\/[\w.-]+\.svg/.exec(byId.S21.prompt)?.[0];
assert.ok(mock && names("S21").includes(mock), `S21 프롬프트의 시안 파일이 픽스처에 없다: ${mock}`);
});
test("픽스처는 작고(시나리오당 8KB 이하) LF 줄끝이다", () => {
for (const s of ["S19", "S20", "S21"]) {
const files = filesUnder(path.join(ROOT, "fixtures", byId[s].fixture));
let total = 0;
for (const f of files) {
const buf = fs.readFileSync(f);
total += buf.length;
assert.equal(buf.indexOf(0x0d), -1, `${f}: CR 줄끝`);
}
assert.ok(total <= 8 * 1024, `${s}: 픽스처 ${total}바이트`);
}
});