designpaca/build/ci/lint-skill.test.mjs
Yun Chan 9bcb45cbd3 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
2026-10-06 16:55:38 +09:00

680 lines
36 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lint-skill.mjs 변형 테스트. 실제 packages/skill 을 임시 디렉터리에 복사하고, 01 레드팀 보고서가 실증한
// 우회(규칙 4 삭제, "경우에만" 변형, 도구명 누출, CRLF)를 하나씩 가해 lint 가 실제로 실패하는지 확인한다.
//
// node --test build/ci/lint-skill.test.mjs
//
// 기준 사본은 "진행 중인 작업 때문에 지금 저장소에 남아 있을 수 있는 위반"(도구명, CRLF·BOM, 아직 만들지
// 않은 링크 대상)을 먼저 정리한 뒤 통과해야 한다. 정리 대상에 인터뷰 게이트 문구는 없다 — 저장소 SKILL.md 의
// 게이트가 약해지면 기준 테스트부터 실패한다.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
lintSkill,
harnessToolRegex,
gateSection,
sectionIds,
sectionRefs,
parseRowId,
preflightRowDefs,
rowCitations,
gateRuleNumbers,
unfencedLines,
estimateTokens,
REF_TOKEN_BUDGETS,
REF_TOKEN_BUDGET_DEFAULT,
REF_TOTAL_TOKEN_BUDGET,
} from "./lint-skill.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "..", "..");
const SKILL_SRC = path.join(REPO, "packages", "skill");
const LINT = path.join(HERE, "lint-skill.mjs");
let tmpRoot;
let baseDir;
let caseNo = 0;
async function copyDir(src, dest) {
await fs.mkdir(dest, { recursive: true });
for (const e of await fs.readdir(src, { withFileTypes: true })) {
if (e.name === "node_modules") continue;
const s = path.join(src, e.name);
const d = path.join(dest, e.name);
if (e.isDirectory()) await copyDir(s, d);
else if (e.isFile()) await fs.copyFile(s, d);
}
}
async function listFiles(dir, base = dir) {
const out = [];
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await listFiles(full, base)));
else out.push(path.relative(base, full).split(path.sep).join("/"));
}
return out;
}
/** 진행 중인 다른 작업 때문에 생길 수 있는 위반을 정리해 "통과해야 하는 기준 사본"을 만든다. */
async function sanitize(dir) {
const toolRe = harnessToolRegex();
for (const rel of await listFiles(dir)) {
if (!/\.(md|ya?ml|json|mjs|js)$/.test(rel)) continue;
const p = path.join(dir, rel);
let text = await fs.readFile(p, "utf8");
text = text.replace(/^/, "").replace(/\r\n?/g, "\n");
if (rel.endsWith(".md") && rel !== "references/harness.md") text = text.replace(toolRe, "텍스트 페처");
await fs.writeFile(p, text);
}
// 아직 만들어지지 않은 링크 대상(다른 패키지가 작성 중인 신규 문서)은 빈 문서로 채운다.
for (let i = 0; i < 3; i++) {
const { errors } = await lintSkill(dir);
const missing = errors
.map((e) => /^(references\/[^:]+):\d+: 링크 대상 문서가 없다: (.+)$/.exec(e))
.filter(Boolean);
if (missing.length === 0) break;
for (const [, from, target] of missing) {
const abs = path.join(dir, path.dirname(from), target);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, "# stub\n");
}
}
// 문서 정합성 검사(행 ID·게이트 규칙 번호·읽는 법 표·확장자 없는 § 참조)가 지금 저장소 문서에서 내는 오류는
// 그 줄을 비워서 정리한다. 문서를 고치는 작업이 진행 중일 때 변형 테스트 전체가 함께 무너지는 것을 막는다.
// 저장소 문서의 실제 결함은 `node build/ci/lint-skill.mjs packages/skill` 이 오류로 낸다(CI 가 테스트보다 먼저 돌린다).
// 정리한 줄은 sanitizedDocLines 에 남겨 아래 테스트가 진단으로 출력한다. 줄 번호가 없는 오류(번호가 빈 행 ID)와
// `x.md §N` 꼴의 기존 § 참조 오류는 정리하지 않는다 — 기준 테스트가 그대로 실패한다.
for (let i = 0; i < 3; i++) {
const { errors } = await lintSkill(dir);
const hits = errors.map((e) => DOC_INTEGRITY_ERROR_RE.exec(e)).filter(Boolean);
if (hits.length === 0) break;
const byFile = new Map();
for (const m of hits) {
if (!byFile.has(m[1])) byFile.set(m[1], new Set());
byFile.get(m[1]).add(Number(m[2]));
sanitizedDocLines.push(m[0]);
}
for (const [rel, lineNos] of byFile) {
const p = path.join(dir, rel);
const lines = (await fs.readFile(p, "utf8")).split("\n");
for (const n of lineNos) lines[n - 1] = "";
await fs.writeFile(p, lines.join("\n"));
}
}
}
// sanitize 가 줄을 비워 정리하는 오류. 전부 이 파일 아래쪽 "문서 정합성" 테스트가 변형으로 따로 검증한다.
const DOC_INTEGRITY_ERROR_RE =
/^(SKILL\.md|references\/[^:]+):(\d+): (?:행 ID |'M\d+' 은 정의가 없는 번호|게이트 규칙 |'이 문서를 읽는 법'이 가리키는 |'[A-Za-z0-9_-]+ §)[^\n]*/;
const sanitizedDocLines = [];
async function freshCopy() {
const dir = path.join(tmpRoot, `case-${++caseNo}`);
await copyDir(baseDir, dir);
return dir;
}
async function edit(dir, rel, fn) {
const p = path.join(dir, rel);
const before = await fs.readFile(p, "utf8");
const afterText = fn(before);
assert.notEqual(afterText, before, `변형이 적용되지 않았다: ${rel}`);
await fs.writeFile(p, afterText);
}
/** 게이트 절 안에서만 치환한다. */
function inGate(md, fn) {
const gate = gateSection(md);
assert.ok(gate, "게이트 절이 없다");
return md.replace(gate, fn(gate));
}
async function expectError(dir, pattern) {
const { errors } = await lintSkill(dir);
assert.ok(
errors.some((e) => pattern.test(e)),
`기대한 오류(${pattern})가 없다. 실제 오류:\n${errors.join("\n") || "(없음)"}`,
);
}
before(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "dp-lint-"));
baseDir = path.join(tmpRoot, "base");
await copyDir(SKILL_SRC, baseDir);
await sanitize(baseDir);
});
after(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
test("기준 사본은 통과한다(정리 후 오류 0건, CLI exit 0)", async () => {
const { errors } = await lintSkill(baseDir);
assert.deepEqual(errors, []);
const r = spawnSync(process.execPath, [LINT, baseDir], { encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
});
// ── 인터뷰 게이트 ──────────────────────────────────────────────────────
test("규칙 4 '물었으면 턴을 끝낸다' 삭제 → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) => inGate(md, (g) => g.replace(/\*\*물었으면 턴을 끝낸다\.\*\*\s*/, "")));
await expectError(dir, /물었으면 턴을 끝낸다/);
const r = spawnSync(process.execPath, [LINT, dir], { encoding: "utf8" });
assert.equal(r.status, 1, "CLI 가 exit 1 로 실패해야 한다");
});
test("규칙 4를 '합리적 가정으로 계속 진행해도 된다'로 바꿈(01 실증 우회) → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) =>
inGate(md, (g) => g.replace(/^4\. .*$/m, "4. **물은 뒤에도 합리적 가정으로 계속 진행해도 된다.**")),
);
await expectError(dir, /완화 문구.*합리적·적절한 가정/);
await expectError(dir, /물었으면 턴을 끝낸다/);
});
test("'답을 받기 전에는 … 구현을 시작하지 않는다' 삭제 → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) => inGate(md, (g) => g.replace(/답을 받기 전에는[^\n]*/, "")));
await expectError(dir, /답을 받기 전에는/);
});
for (const [name, phrase] of [
["경우에만 묻는다(01 실증)", "결과를 크게 바꿀 경우에만 묻는다."],
["때만 묻는다(v0.11.0 원형)", "결과를 크게 바꿀 때만 묻는다."],
["때에만 질문한다", "정말 필요한 때에만 질문한다."],
["경우에만 사용자에게 확인을 받는다", "모호한 경우에만 사용자에게 확인을 받는다."],
["필요할 때 묻는다", "필요할 때 묻는다."],
["only ask when", "Only ask when the answer changes the result."],
]) {
test(`게이트 규칙 2 완화 변형 '${name}' → 오류`, async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) => inGate(md, (g) => g.replace(/^2\. .*$/m, `2. **${phrase}**`)));
await expectError(dir, /완화 문구|생략하지 않는다/);
const { errors } = await lintSkill(dir);
assert.ok(errors.some((e) => /완화 문구/.test(e)), `완화 문구 정규식이 '${phrase}'를 놓쳤다:\n${errors.join("\n")}`);
});
}
test("게이트 밖 SKILL.md 본문의 '경우에만 묻는다' → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) => md.replace(/^## 핵심 규칙$/m, "## 핵심 규칙\n\n브리프가 애매한 경우에만 묻는다.\n"));
await expectError(dir, /^SKILL\.md:\d+: 질문 여부를 자기판정에 맡기는 완화 문구/);
});
test("brief-interview.md 의 완화 문구 → 오류, 기존 객관 조건(허용 목록)은 통과", async () => {
const dir = await freshCopy();
await edit(dir, "references/brief-interview.md", (t) => t + "\n결과에 영향이 클 때만 묻는다.\n");
const { errors } = await lintSkill(dir);
const hits = errors.filter((e) => e.startsWith("references/brief-interview.md") && /완화 문구/.test(e));
assert.equal(hits.length, 1, `새로 넣은 한 줄만 잡혀야 한다:\n${hits.join("\n")}`);
});
test("게이트 번호 규칙 하나 삭제(6 → 5개) → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) => inGate(md, (g) => g.replace(/^6\. .*\n/m, "")));
await expectError(dir, /번호 규칙이 6개가 아니다/);
});
test("게이트 절 안의 harness.md 링크 삭제(지도 표에는 남음) → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) =>
inGate(md, (g) => g.replace(/\[harness\.md\]\(references\/harness\.md\)/g, "harness 문서")),
);
const md = await fs.readFile(path.join(dir, "SKILL.md"), "utf8");
assert.ok(md.includes("references/harness.md"), "지도 표에는 링크가 남아 있어야 이 변형이 의미가 있다");
await expectError(dir, /게이트 절 안에 문서 링크가 없다: references\/harness\.md/);
});
test("brief-interview.md 의 '생략하지 않는다' 삭제 → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "references/brief-interview.md", (t) => t.replaceAll("생략하지 않는다", "생략할 수 있다"));
await expectError(dir, /brief-interview\.md: 인터뷰 게이트를 받치는 문구가 없다/);
});
test("harness.md 의 '기계적으로 판정한다' 삭제 → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "references/harness.md", (t) => t.replaceAll("기계적으로 판정한다", "상황에 맞게 고른다"));
await expectError(dir, /harness\.md: 인터뷰 게이트를 받치는 문구가 없다: 기계적으로 판정한다/);
});
// ── 하네스 전용 도구명 ────────────────────────────────────────────────
test("SKILL.md 본문에 AskUserQuestion → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) => md.replace(/^## 핵심 규칙$/m, "## 핵심 규칙\n\n질문은 AskUserQuestion 으로 한다.\n"));
await expectError(dir, /^SKILL\.md:\d+: 하네스 전용 도구명 'AskUserQuestion'/);
});
for (const [rel, name] of [
["references/layout.md", "WebFetch"],
["references/brief-interview.md", "request_user_input"],
["references/presets/README.md", "TodoWrite"],
["references/critique.md", "WebSearch"],
]) {
test(`${rel} 에 ${name} → 오류`, async () => {
const dir = await freshCopy();
await edit(dir, rel, (t) => `${t}\n${name} 를 쓴다.\n`);
await expectError(dir, new RegExp(`^${rel.replace(/[./]/g, "\\$&")}:\\d+: 하네스 전용 도구명 '${name}'`));
});
}
test("harness.md 의 도구명은 예외", async () => {
const dir = await freshCopy();
await edit(dir, "references/harness.md", (t) => `${t}\nWebFetch 와 TodoWrite 를 표에 적는다.\n`);
const { errors } = await lintSkill(dir);
assert.deepEqual(errors, []);
});
for (const [rel, name] of [
["references/accessibility.md", "lighthouse_audit"], // SWEEP 실측: chrome-devtools MCP 도구명이 남아 있었다
["references/accessibility.md", "take_snapshot"],
["references/preflight.md", "take_screenshot"],
["references/presets/README.md", "browser_take_screenshot"],
["SKILL.md", "mcp__chrome-devtools__navigate_page"],
["references/images.md", "mcp__plugin_playwright_playwright__browser_snapshot"],
]) {
test(`${rel} 에 MCP 도구명 ${name} → 오류`, async () => {
const dir = await freshCopy();
await edit(dir, rel, (t) => `${t}\n접근성은 ${name} 로 잰다.\n`);
await expectError(dir, new RegExp(`^${rel.replace(/[./]/g, "\\$&")}:\\d+: 하네스 전용 도구명 '${name}'`));
});
}
test("harness.md 의 MCP 도구명은 예외, 비슷한 일반 낱말은 오류가 아니다", async () => {
const dir = await freshCopy();
await edit(dir, "references/harness.md", (t) => `${t}\nmcp__chrome-devtools__lighthouse_audit 와 take_snapshot 을 표에 적는다.\n`);
await edit(dir, "references/color.md", (t) => `${t}\nsnapshot 을 찍고 page 를 새로 연다(take snapshot, new page).\n`);
const { errors } = await lintSkill(dir);
assert.deepEqual(errors, []);
});
// ── 줄끝·BOM ──────────────────────────────────────────────────────────
for (const rel of ["SKILL.md", "references/tokens.md", "agents/openai.yaml"]) {
test(`${rel} 를 CRLF 로 → 오류`, async () => {
const dir = await freshCopy();
await edit(dir, rel, (t) => t.replace(/\n/g, "\r\n"));
await expectError(dir, new RegExp(`^${rel.replace(/[./]/g, "\\$&")}: CR 줄끝이 있다`));
});
}
test("CRLF 한 줄만 섞여도 → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "references/color.md", (t) => t.replace("\n", "\r\n"));
await expectError(dir, /color\.md: CR 줄끝이 있다\(CRLF 1줄\)/);
});
test("SKILL.md 에 UTF-8 BOM → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (t) => "" + t);
await expectError(dir, /SKILL\.md: UTF-8 BOM/);
});
// ── 경로 ──────────────────────────────────────────────────────────────
test("본문이 없는 tools/*.mjs 를 가리킴 → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) => md.replace(/^## 핵심 규칙$/m, "## 핵심 규칙\n\n`tools/no-such-gate.mjs` 를 돌린다.\n"));
await expectError(dir, /도구 파일이 없다: tools\/no-such-gate\.mjs/);
});
test("references 사이 깨진 상대 링크 → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "references/color.md", (t) => `${t}\n[없는 문서](no-such-doc.md#x)\n`);
await expectError(dir, /color\.md:\d+: 링크 대상 문서가 없다: no-such-doc\.md/);
});
// ── 경고(실패는 아님) ─────────────────────────────────────────────────
test("SKILL.md 토큰 예산 초과 → 경고만(exit 0)", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (md) => md + "\n" + "가".repeat(6000) + "\n");
const { errors, warnings } = await lintSkill(dir);
assert.deepEqual(errors, []);
assert.ok(warnings.some((w) => /SKILL\.md 추정 토큰 \d+ 이 예산/.test(w)), warnings.join("\n"));
});
test("기준 사본의 SKILL.md 는 예산 경고가 없다", async () => {
const { warnings } = await lintSkill(baseDir);
assert.ok(!warnings.some((w) => /SKILL\.md 추정 토큰/.test(w)), warnings.join("\n"));
});
test("references 의 research/ 경로 → 경고, URL 속 /docs/ 는 무시", async () => {
const dir = await freshCopy();
await edit(
dir,
"references/color.md",
(t) => `${t}\n코드는 research/three/02-patterns.md 에 있다. ../../research/x.md 도. 출처 https://developer.mozilla.org/en-US/docs/Web/CSS\n`,
);
const { errors, warnings } = await lintSkill(dir);
assert.deepEqual(errors, []);
const hits = warnings.filter((w) => w.startsWith("references/color.md") && /설치본에 없는 저장소 경로/.test(w));
assert.equal(hits.length, 2, hits.join("\n"));
assert.ok(!hits.some((w) => w.includes("docs/Web")), "URL 속 docs/ 를 잡으면 안 된다");
});
test("큰 참조 문서에 읽기 가이드가 없으면 경고", async () => {
const dir = await freshCopy();
await fs.writeFile(path.join(dir, "references", "big-doc.md"), "# big\n" + "본문 줄\n".repeat(400));
const { warnings } = await lintSkill(dir);
assert.ok(warnings.some((w) => w.startsWith("references/big-doc.md: 추정")), warnings.join("\n"));
});
// ── 파일 간 § 참조 ────────────────────────────────────────────────────
test("sectionIds 는 제목 번호 형식(6., 6-5., 6-b., §3., 1.1)을 읽고 코드 펜스·'3단계'는 무시한다", () => {
const ids = sectionIds(
["# t", "## 6. 가", "### 6-5. 나", "### 6-b. 다", "## §3. 라", "## 1.1 마", "## 3단계", "```", "## 9. 펜스 안", "```"].join("\n"),
);
assert.deepEqual([...ids].sort(), ["1.1", "3", "6", "6-5", "6-b"]);
});
test("sectionRefs 는 링크 뒤·링크 글자 안·맨 파일명·연쇄(·, ~)를 읽고 문서 안 맨 § 와 URL 은 무시한다", () => {
const refs = sectionRefs(
[
"[a.md](a.md) §2-1 을 본다.",
"[b.md §1·§2](b.md) 와 `c.md` §6-b, d.md §0~§5.",
"이 문서 §7 은 같은 문서다. [e.md](https://x.y/e.md) §3 은 URL 이다.",
].join("\n"),
);
const flat = refs.map((r) => `${r.file}:${r.ids.join(",")}`).sort();
assert.deepEqual(flat, ["a.md:2-1", "b.md:1,2", "c.md:6-b", "d.md:0,5"]);
});
test("기준 사본의 파일 간 § 참조는 전부 유효하다(SWEEP 대조 267건 수준)", async () => {
const { errors } = await lintSkill(baseDir);
assert.ok(!errors.some((e) => /그 번호의 제목이 없다/.test(e)), errors.join("\n"));
let n = 0;
for (const rel of await listFiles(path.join(baseDir, "references"))) {
if (!rel.endsWith(".md")) continue;
for (const r of sectionRefs(await fs.readFile(path.join(baseDir, "references", rel), "utf8"))) n += r.ids.length;
}
assert.ok(n >= 250, `§ 참조 추출이 너무 적다(${n}건) — 정규식이 형식을 놓친다`);
});
for (const [name, rel, line, re] of [
["링크 뒤 §", "references/color.md", "[preflight.md](preflight.md) §99 를 본다.", /color\.md:\d+: 'preflight\.md §99'/],
["링크 글자 안 연쇄의 둘째", "references/color.md", "[harness.md §1·§98](harness.md)", /color\.md:\d+: 'harness\.md §98'/],
["맨 파일명", "references/color.md", "세부는 `typography.md` §6-99 에 있다.", /color\.md:\d+: 'typography\.md §6-99'/],
["presets 하위에서 상위로", "references/presets/README.md", "[../harness.md](../harness.md) §97", /presets\/README\.md:\d+: '\.\.\/harness\.md §97'/],
["SKILL.md 본문", "SKILL.md", "세부는 [critique.md](references/critique.md) §96.", /^SKILL\.md:\d+: 'references\/critique\.md §96'/],
]) {
test(`없는 절을 가리키는 § 참조(${name}) → 오류`, async () => {
const dir = await freshCopy();
await edit(dir, rel, (t) => `${t}\n${line}\n`);
await expectError(dir, re);
});
}
test("있는 절을 가리키는 § 참조(6-b, §0~§5 범위)는 통과", async () => {
const dir = await freshCopy();
await edit(dir, "references/color.md", (t) => `${t}\n[harness.md](harness.md) §6-b, harness.md §0~§5.\n`);
const { errors } = await lintSkill(dir);
assert.deepEqual(errors, []);
});
test("대상 문서를 재번호하면(harness.md §10 → §11) 그 절을 가리키던 참조가 오류", async () => {
const dir = await freshCopy();
await edit(dir, "references/harness.md", (t) => t.replace(/^## 10\./m, "## 11."));
const { errors } = await lintSkill(dir);
const hits = errors.filter((e) => /'harness\.md §10'/.test(e));
assert.ok(hits.length >= 2, `harness.md §10 참조(galleries·reference-method 등)가 잡혀야 한다:\n${errors.join("\n")}`);
});
test("presets/ 하위 문서의 깨진 상대 링크 → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "references/presets/README.md", (t) => `${t}\n[없음](../no-such-atlas.md)\n`);
await expectError(dir, /presets\/README\.md:\d+: 링크 대상 문서가 없다: \.\.\/no-such-atlas\.md/);
});
// ── 문서 정합성: 확장자 없는 § 참조, 행 ID, 읽는 법 표, 크기 예산, 게이트 사본 ──────────────
// 2026-10-06 정합성 감사(C-19·C-20·C-26)와 검증 공백 보고(V-26·V-27·V-29·V-30·V-31)의 실측 결함을 변형으로 재현한다.
// 저장소 문서가 수정 중이어도 흔들리지 않게, 기존 문장을 바꾸지 않고 문서 끝에 줄을 덧붙이는 변형을 주로 쓴다.
async function lintErrors(dir, opts) {
return (await lintSkill(dir, opts)).errors;
}
async function baseRowDefs() {
return preflightRowDefs(await fs.readFile(path.join(baseDir, "references", "preflight.md"), "utf8"));
}
function maxRow(defs, prefix) {
return Math.max(...[...defs.keys()].map(parseRowId).filter((p) => p && p.prefix === prefix).map((p) => p.n));
}
test("sanitize 가 정리한 저장소 문서의 정합성 오류를 진단으로 남긴다(실패는 아님)", (t) => {
for (const line of sanitizedDocLines) t.diagnostic(`저장소 문서 결함: ${line}`);
t.diagnostic(`정리한 줄 ${sanitizedDocLines.length}건 — 0 이 아니면 node build/ci/lint-skill.mjs packages/skill 이 실패한다`);
});
test("sectionRefs 는 bareNames 를 주면 확장자 없는 문서 이름도 읽는다", () => {
const md = "판정은 critique §2-1 과 harness §6-b·§10 을 따른다. [critique §3](critique.md) 은 링크다. motion.md §5 는 맨 파일명이다. critique의 §9 는 못 읽는다.";
assert.deepEqual(sectionRefs(md).map((r) => `${r.file}:${r.ids.join(",")}`).sort(), ["critique.md:3", "motion.md:5"]);
const withBare = sectionRefs(md, ["critique", "harness", "motion"]).map((r) => `${r.label ?? r.file}:${r.ids.join(",")}`).sort();
assert.deepEqual(withBare, ["critique.md:3", "critique:2-1", "harness:6-b,10", "motion.md:5"]);
});
test("확장자 없는 § 참조가 없는 절을 가리키면 오류, 있는 절이면 통과(V-27)", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (t) => `${t}\n세부는 critique §96 을 본다. harness §6-b 는 있는 절이다.\n`);
const errors = await lintErrors(dir);
assert.ok(errors.some((e) => /^SKILL\.md:\d+: 'critique §96'/.test(e)), errors.join("\n"));
assert.ok(!errors.some((e) => /'harness §6-b'/.test(e)), errors.join("\n"));
});
test("실측 결함 재현: 'preflight §0-E'(없는 절, C-19) → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "references/audit-gate-rules.md", (t) => `${t}\n왼쪽 인셋은 preflight §0-E 를 본다.\n`);
await expectError(dir, /audit-gate-rules\.md:\d+: 'preflight §0-E'/);
});
test("parseRowId·preflightRowDefs·rowCitations·gateRuleNumbers 의 문법", () => {
assert.deepEqual(parseRowId("M-E"), { prefix: "M-", n: 5, sub: "" });
assert.deepEqual(parseRowId("G-12"), { prefix: "G-", n: 12, sub: "" });
assert.deepEqual(parseRowId("P8a"), { prefix: "P", n: 8, sub: "a" });
assert.equal(parseRowId("G15x9"), null);
const defs = preflightRowDefs(
[
"### M-A. 이펙트 끄기",
"| ID | 항목 |",
"|---|---|",
"| A1 | 키보드 |",
"| A2 | 포커스(A1 과 함께 본다) |",
"### P8. 폰트",
"- [ ] **P8a** 웹폰트",
"- [ ] **V1** `[방향]` 평가",
"- [ ] **G-1** §0 M-A 통과",
"본문에 나온 X9 는 인용이다.",
"```",
"| Z1 | 펜스 안 |",
"```",
].join("\n"),
);
assert.deepEqual([...defs.keys()], ["M-A", "A1", "A2", "P8", "P8a", "V1", "G-1"]);
assert.deepEqual(defs.get("A2"), [5]);
const c = rowCitations("판정은 M-A·A1·X6~X14, G-3 과 G15(게이트 규칙), #A1B2C3, W3C, `P8h` 다.", ["M-", "A", "P", "X", "G-"]);
assert.deepEqual(c.ids.map((x) => x.id), ["M-A", "A1", "X6", "X14", "G-3", "P8h"]);
assert.deepEqual(c.ranges.map((r) => `${r.from}~${r.to}`), ["X6~X14"]);
assert.deepEqual([...gateRuleNumbers("- **G1. 선택자** 설명\n- **G2. 클로저** 설명\n본문의 G9 는 인용이다.\n### G12. 제목 꼴\n")], [1, 2, 12]);
assert.deepEqual(unfencedLines("a\n```\nb\n```\nc"), ["a", "", "", "", "c"]);
});
test("기준 사본의 preflight 행 ID 는 접두사 10종 이상이다(추출기가 정의 꼴을 놓치지 않는다)", async () => {
const defs = await baseRowDefs();
const prefixes = new Set([...defs.keys()].map((id) => parseRowId(id).prefix));
assert.ok(prefixes.size >= 10, `행 ID 추출이 너무 적다(접두사 ${[...prefixes].join(" ")}) — 정의 꼴이 바뀌었으면 preflightRowDefs 를 고친다`);
for (const p of ["M-", "A", "X", "V", "G-"]) assert.ok(prefixes.has(p), `접두사 ${p} 가 없다`);
});
test("실측 결함 재현: SKILL.md 행 ID 목록에서 접두사가 빠짐(O1~O4 누락, C-20) → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "SKILL.md", (t) => `${t}\n해당하는 행 ID만 돈다(M-A~M-E, A, P, W, C, S, T, G-1~G-12).\n`);
const errors = await lintErrors(dir);
assert.ok(errors.some((e) => /^SKILL\.md:\d+: 행 ID 목록에 O 행\(O1~O\d+\)이 빠졌다/.test(e)), errors.join("\n"));
assert.ok(errors.some((e) => /^SKILL\.md:\d+: 행 ID 목록에 X 행/.test(e)), errors.join("\n"));
assert.ok(!errors.some((e) => /행 ID 목록에 (?:M|A|G) 행/.test(e)), errors.join("\n"));
});
test("정의 없는 행 ID 인용 → 오류. preflight 를 말하지 않는 줄과 P0 는 보지 않는다", async () => {
const dir = await freshCopy();
await edit(
dir,
"references/color.md",
(t) => `${t}\n납품 판정은 preflight.md X99·A1 이 한다(심각도 P0).\n\n심각도 P9, 용지 A99, 모델 S99 는 다른 번호다.\n`,
);
const hits = (await lintErrors(dir)).filter((e) => e.startsWith("references/color.md") && /행 ID/.test(e));
assert.equal(hits.length, 1, hits.join("\n"));
assert.match(hits[0], /행 ID X99 는 preflight\.md 에 정의가 없다\(정의는 X1~X\d+\)/);
});
test("정의 없는 하위 행(P8z)·최종 게이트 행(G-99) 인용 → 오류", async () => {
const dir = await freshCopy();
await edit(dir, "references/typography.md", (t) => `${t}\n납품 판정은 preflight.md P8z 와 G-99 가 한다.\n`);
const errors = await lintErrors(dir);
assert.ok(errors.some((e) => /typography\.md:\d+: 행 ID P8z /.test(e)), errors.join("\n"));
assert.ok(errors.some((e) => /typography\.md:\d+: 행 ID G-99 /.test(e)), errors.join("\n"));
});
test("실측 결함 재현: 1번에서 시작하는 범위가 정의보다 짧음(V1~V6, C-20) → 오류. 부분 범위는 통과", async () => {
const dir = await freshCopy();
const vMax = maxRow(await baseRowDefs(), "V");
await edit(dir, "references/antipatterns.md", (t) => `${t}\n납품 판정은 preflight.md V1~V${vMax - 1} 이 한다. 접근성은 preflight.md A2~A5 를 본다.\n`);
const hits = (await lintErrors(dir)).filter((e) => e.startsWith("references/antipatterns.md") && /행 ID 범위/.test(e));
assert.equal(hits.length, 1, hits.join("\n"));
assert.match(hits[0], new RegExp(`V1~V${vMax - 1} 가 정의와 다르다 — preflight\\.md 의 정의는 V1~V${vMax} 다`));
});
test("preflight 에 행을 더하고 범위를 안 고치면 읽는 법 표의 범위가 오류", async () => {
const dir = await freshCopy();
const xMax = maxRow(await baseRowDefs(), "X");
await edit(dir, "references/preflight.md", (t) => `${t}\n| X${xMax + 1} | 새 조건 | 새 판정 | 정본 |\n`);
const errors = await lintErrors(dir);
const re = new RegExp(`행 ID 범위 X1~X${xMax} 가 정의와 다르다`);
assert.ok(errors.some((e) => e.startsWith("references/preflight.md") && re.test(e)), errors.join("\n"));
});
test("행 ID 중복 정의·번호 건너뜀 → 오류", async () => {
const dir = await freshCopy();
const aMax = maxRow(await baseRowDefs(), "A");
await edit(dir, "references/preflight.md", (t) => `${t}\n| A1 | 중복 | | |\n| A${aMax + 2} | 건너뜀 | | |\n`);
const errors = await lintErrors(dir);
assert.ok(errors.some((e) => /preflight\.md:\d+: 행 ID A1 가 두 번 정의됐다/.test(e)), errors.join("\n"));
assert.ok(errors.some((e) => new RegExp(`행 ID A${aMax + 1} 이 없다`).test(e)), errors.join("\n"));
});
test("실측 결함 재현: 게이트 문서의 옛 번호 M5·M6(C-19) → 오류. Material 판 이름 M3 는 통과", async () => {
const dir = await freshCopy();
await edit(dir, "references/audit-gate-rules.md", (t) => `${t}\n뷰 전환 단언(M5)과 M6 인셋 스위트. HIG 44pt·M3 48dp 관례.\n`);
const hits = (await lintErrors(dir)).filter((e) => /정의가 없는 번호/.test(e));
assert.deepEqual(hits.map((e) => /'(M\d+)'/.exec(e)[1]).sort(), ["M5", "M6"]);
});
test("정의 없는 게이트 규칙 번호(G99)·짧은 전체 범위(G1~G(max-1)) → 오류. G-n 과 섞지 않는다", async () => {
const dir = await freshCopy();
const rules = gateRuleNumbers(await fs.readFile(path.join(baseDir, "references", "audit-gate-rules.md"), "utf8"));
const gMax = Math.max(...rules);
assert.ok(gMax >= 30, `게이트 규칙 추출이 너무 적다(${rules.size}개)`);
await edit(
dir,
"references/color.md",
(t) => `${t}\n게이트 규칙 G99 와 G1~G${gMax - 1}, 그리고 G${gMax} 를 본다. 최종 게이트 G-3 은 다른 번호 체계다.\n`,
);
const hits = (await lintErrors(dir)).filter((e) => e.startsWith("references/color.md") && /게이트 규칙/.test(e));
assert.equal(hits.length, 2, hits.join("\n"));
assert.ok(hits.some((e) => /게이트 규칙 G99 은 audit-gate-rules\.md 에 정의가 없다/.test(e)), hits.join("\n"));
assert.ok(hits.some((e) => new RegExp(`범위 G1~G${gMax - 1} 가 정의와 다르다`).test(e)), hits.join("\n"));
});
test("게이트 규칙을 하나 지우면(재번호) 그 번호를 부르던 인용이 오류", async () => {
const dir = await freshCopy();
await edit(dir, "references/audit-gate-rules.md", (t) => t.replace(/^- \*\*G15\. /m, "- **구 규칙. "));
await expectError(dir, /게이트 규칙 G15 은 audit-gate-rules\.md 에 정의가 없다/);
});
test("읽는 법 표가 없는 절을 가리키면 오류. 다른 문서 이름 뒤의 § 는 그 문서 것으로 본다(V-31)", async () => {
const dir = await freshCopy();
await edit(dir, "references/color.md", (t) =>
t.replace(/(## 이 문서를 읽는 법\n\n\|[^\n]*\n\|[-| :]+\n)/, (head) => `${head}| 없는 절을 가리킨다 | §98 |\n| 다른 문서 | [harness.md](harness.md) §3 … §97 |\n`),
);
const hits = (await lintErrors(dir)).filter((e) => e.startsWith("references/color.md") && /읽는 법/.test(e));
assert.equal(hits.length, 1, hits.join("\n"));
assert.match(hits[0], /'이 문서를 읽는 법'이 가리키는 §98 이 이 문서에 없다/);
});
test("읽는 법 표가 가리키던 절을 재번호하면 오류", async () => {
const dir = await freshCopy();
// color.md 의 읽는 법 표는 §4 를 가리킨다. 제목 번호만 바꾼다.
await edit(dir, "references/color.md", (t) => t.replace(/^## §4\. /m, "## §40. "));
await expectError(dir, /color\.md:\d+: '이 문서를 읽는 법'이 가리키는 §4 이 이 문서에 없다/);
});
test("실측 결함 재현: 산문의 맨 § 가 그 문서에 없음('(§3-3)', C-26) → 경고만. 표의 행·다른 문서 이름이 있는 줄은 무시", async () => {
const dir = await freshCopy();
const before = await lintSkill(dir);
await edit(
dir,
"references/harness.md",
(t) => `${t}\n미확인은 불가로 다룬다(§93-3).\n\n| 표의 행 | §94 |\n\n판정은 critique §2-1 과 §95 를 따른다.\n`,
);
const afterRun = await lintSkill(dir);
assert.deepEqual(afterRun.errors, before.errors);
const added = afterRun.warnings.filter((w) => !before.warnings.includes(w) && /문서 이름 없이 쓴 §/.test(w));
assert.equal(added.length, 1, added.join("\n"));
assert.match(added[0], /^references\/harness\.md:\d+: 문서 이름 없이 쓴 §93-3 /);
});
test("문서 크기 예산: 표의 문서가 예산을 넘으면 경고, 새 문서는 기본 예산, 합계 초과도 경고(V-30)", async () => {
const dir = await freshCopy();
const rel = "references/color.md";
assert.ok(rel in REF_TOKEN_BUDGETS, "color.md 가 예산 표에 없다");
const cur = estimateTokens(await fs.readFile(path.join(dir, rel), "utf8"));
await edit(dir, rel, (t) => `${t}\n${"가".repeat(Math.max(0, REF_TOKEN_BUDGETS[rel] - cur) + 50)}\n`);
await fs.writeFile(path.join(dir, "references", "new-doc.md"), `# new\n\n## 이 문서를 읽는 법\n\n${"나".repeat(REF_TOKEN_BUDGET_DEFAULT + 50)}\n`);
let total = 0;
for (const f of await listFiles(path.join(dir, "references"))) {
if (f.endsWith(".md")) total += estimateTokens(await fs.readFile(path.join(dir, "references", f), "utf8"));
}
if (total <= REF_TOTAL_TOKEN_BUDGET) {
await fs.writeFile(path.join(dir, "references", "filler.md"), `# filler\n\n## 이 문서를 읽는 법\n\n${"다".repeat(REF_TOTAL_TOKEN_BUDGET - total + 100)}\n`);
}
const base = await lintSkill(baseDir);
const { errors, warnings } = await lintSkill(dir);
assert.deepEqual(errors, base.errors, "크기 예산은 오류를 내지 않는다");
assert.ok(warnings.some((w) => new RegExp(`^references/color\\.md: 추정 \\d+토큰이 문서 예산 ${REF_TOKEN_BUDGETS[rel]} 을 넘는다 —`).test(w)), warnings.join("\n"));
assert.ok(warnings.some((w) => /^references\/new-doc\.md: 추정 \d+토큰이 문서 예산 \d+ 을 넘는다\(표에 없는 문서/.test(w)), warnings.join("\n"));
assert.ok(warnings.some((w) => /^references 전체 추정 \d+토큰이 합계 예산/.test(w)), warnings.join("\n"));
});
test("게이트 사본 동일성: 같으면 통과, 한 바이트라도 다르면 오류, 사이트 사본이 없으면 건너뛴다(V-29)", async () => {
const dir = await freshCopy();
const skillGate = path.join(dir, "tools", "design-gate.mjs");
const site = path.join(tmpRoot, `site-gate-${caseNo}.mjs`);
const isGate = (e) => e.startsWith("tools/design-gate.mjs: 사이트 사본");
await fs.copyFile(skillGate, site);
assert.ok(!(await lintErrors(dir, { siteGatePath: site })).some(isGate));
await fs.appendFile(site, "// drift\n");
const errors = await lintErrors(dir, { siteGatePath: site });
assert.ok(errors.some((e) => isGate(e) && /처음 갈라지는 곳은 \d+줄/.test(e)), errors.join("\n"));
// 스크래치 사본의 기본 위치(../../apps/site/…)에는 사이트 사본이 없다 — 설치본에서 돌 때와 같다.
assert.ok(!(await lintErrors(dir)).some(isGate));
assert.ok(!(await lintErrors(dir, { siteGatePath: path.join(tmpRoot, "no-such-gate.mjs") })).some(isGate));
});
test("저장소 배치(<루트>/packages/skill + apps/site/tools)에서는 게이트 사본 대조가 기본으로 돈다", async () => {
const repo = path.join(tmpRoot, `repo-${++caseNo}`);
const skill = path.join(repo, "packages", "skill");
await copyDir(baseDir, skill);
await fs.mkdir(path.join(repo, "apps", "site", "tools"), { recursive: true });
await fs.copyFile(path.join(skill, "tools", "design-gate.mjs"), path.join(repo, "apps", "site", "tools", "design-gate.mjs"));
assert.ok(!(await lintErrors(skill)).some((e) => e.startsWith("tools/design-gate.mjs")));
await fs.appendFile(path.join(skill, "tools", "design-gate.mjs"), "// skill-only change\n");
await expectError(skill, /^tools\/design-gate\.mjs: 사이트 사본\(\.\.\/\.\.\/apps\/site\/tools\/design-gate\.mjs\)과 다르다/);
});