// 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\)과 다르다/); });