- 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
680 lines
36 KiB
JavaScript
680 lines
36 KiB
JavaScript
// 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\)과 다르다/);
|
||
});
|