// 스킬 문서의 최소 규약을 검사한다. CI 와 `pnpm test` 가 함께 쓴다. // - SKILL.md 존재와 프론트매터 필수 필드 // - description 길이(트리거 정확도에 직접 영향) // - 인터뷰 게이트 절의 핵심 문구·규칙 수·링크, 완화 문구 회귀(AGENTS.md #10) // - 본문·references 의 하네스 전용 도구명(AGENTS.md #11, harness.md 만 예외) // - 스킬 디렉터리 전체 텍스트 파일의 CRLF·UTF-8 BOM(AGENTS.md #2) // - 본문이 가리키는 references·tools 경로, references 사이 상대 링크가 실제 파일을 가리키는지 // - 파일 간 § 참조(`x.md §N`, `[x.md](x.md) §N`, 확장자 없는 `x §N`)와 "이 문서를 읽는 법" 표의 § 가 실제 제목을 가리키는지 // - preflight 행 ID: 정의의 유일·연속, 다른 문서가 인용한 ID·범위, SKILL.md 의 행 ID 목록, 게이트 규칙 번호(G15) // - 게이트 사본 동일성(tools/design-gate.mjs 와 사이트 사본) — 저장소 안에서 돌 때만 // - 경고: SKILL.md 토큰 예산, 큰 참조 문서의 읽기 가이드, 설치본에 없는 research/·docs/ 경로, // 문서별·전체 크기 예산, 문서 이름 없이 쓴 § 가 그 문서에 없는 경우 // // 각 규칙이 실제로 회귀를 잡는지는 build/ci/lint-skill.test.mjs 가 스크래치 사본에 변형을 가해 확인한다. import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; // ── 상수 ──────────────────────────────────────────────────────────────── // 특정 하네스에만 있는 도구 이름. 모든 타깃 어댑터가 SKILL.md 를 그대로 복사하므로, 본문이나 // references 에 이 이름이 있으면 다른 하네스에 그대로 새어 나간다(AGENTS.md #11). 하네스별 도구명· // 한도·폴백은 references/harness.md 에만 쓴다. export const HARNESS_TOOL_NAMES = [ "AskUserQuestion", "request_user_input", "ask_user", "askQuestions", "WebFetch", "WebSearch", "TodoWrite", "TaskCreate", "update_plan", "write_todos", "view_image", "EnterPlanMode", "ExitPlanMode", ]; // MCP 서버가 노출하는 도구 식별자. 어떤 MCP 가 붙어 있는지는 하네스·사용자 설정마다 달라서, 본문이 이 이름을 // 직접 부르면 그 MCP 가 없는 환경에서 에이전트가 없는 도구를 찾는다. 실측: SWEEP(2026-10-02)이 // accessibility.md 에 남은 lighthouse_audit·take_snapshot(chrome-devtools MCP)을 찾았는데 lint 가 놓쳤다. // `mcp__<서버>__<도구>` 꼴 전체 식별자는 MCP_TOOL_PREFIX_RE 로 따로 잡는다. export const MCP_TOOL_NAMES = [ "lighthouse_audit", "take_snapshot", "take_screenshot", "take_heapsnapshot", "navigate_page", "new_page", "evaluate_script", "list_console_messages", "list_network_requests", "performance_start_trace", "performance_stop_trace", "performance_analyze_insight", "browser_snapshot", "browser_take_screenshot", "browser_navigate", "browser_evaluate", "browser_agent", "get_page_text", "image_gen", ]; export const MCP_TOOL_PREFIX_RE = /(? [...chain.matchAll(new RegExp(String.raw`§\s?(${SEC_ID})`, "g"))].map((m) => m[1]); // (1) 링크: 링크 글자 안의 § 또는 링크 바로 뒤의 § const linkRe = /\[([^\]\n]*)\]\(([^)\s#]+\.md)(?:#[^)]*)?\)/g; const after = new RegExp(String.raw`^\s?(${SEC_CHAIN})`); for (const m of md.matchAll(linkRe)) { if (/^[a-z]+:\/\//i.test(m[2])) continue; const inner = new RegExp(SEC_CHAIN).exec(m[1]); const tail = after.exec(md.slice(m.index + m[0].length)); const ids = [...(inner ? pick(inner[0]) : []), ...(tail ? pick(tail[1]) : [])]; if (ids.length) out.push({ file: m[2], ids, index: m.index }); } // (2) 맨 파일명: `x.md §N`, `` `x.md` §N `` — 링크는 지우고 찾는다(중복 방지) const blanked = md.replace(linkRe, (s) => " ".repeat(s.length)); const plainRe = new RegExp(String.raw`(? 0) { const rest = blanked.replace(plainRe, (s) => " ".repeat(s.length)); const alt = [...bareNames].sort((a, b) => b.length - a.length).join("|"); const bareRe = new RegExp(String.raw`(? { if (/^\s*(?:```|~~~)/.test(line)) { fence = !fence; return ""; } return fence ? "" : line; }); } // 행 ID 문법. `A1`, `P8a`(하위 행), `M-A`, `G-12`. 접두사는 하이픈까지 포함한다 — `G-1`(preflight 최종 게이트 // 행)과 `G1`(audit-gate-rules 의 게이트 규칙)은 한 글자 차이라서 접두사 `G-` 와 `G` 로 갈라 둔다. const ROW_ID = String.raw`(?:[A-Z]-(?:\d+|[A-Z])|[A-Z]\d+[a-z]?)`; /** 행 ID 를 { prefix, n, sub } 로 쪼갠다. n 은 순번(글자 순번은 A=1)이다. 행 ID 꼴이 아니면 null. */ export function parseRowId(id) { let m = /^([A-Z]-)(\d+|[A-Z])$/.exec(id); if (m) return { prefix: m[1], n: /\d/.test(m[2]) ? Number(m[2]) : m[2].charCodeAt(0) - 64, sub: "" }; m = /^([A-Z])(\d+)([a-z]?)$/.exec(id); if (m) return { prefix: m[1], n: Number(m[2]), sub: m[3] }; return null; } /** * preflight.md 에서 행 ID 의 정의를 뽑는다. 반환: Map. * 정의로 보는 꼴은 셋이다 — 표의 첫 칸(`| A1 |`), 체크 항목 머리의 굵은 글자(`- [ ] **V1**`), 제목(`### M-A.`). * 그 밖의 자리에 나온 ID 는 인용이다. */ export function preflightRowDefs(md) { const defs = new Map(); const forms = [ new RegExp(String.raw`^\|\s*\*{0,2}(${ROW_ID})\*{0,2}\s*\|`), new RegExp(String.raw`^\s*[-*]\s+(?:\[[ xX]\]\s+)?\*\*(${ROW_ID})\*\*`), new RegExp(String.raw`^#{2,6}\s+(${ROW_ID})(?=[.\s:]|$)`), ]; unfencedLines(md).forEach((line, i) => { for (const re of forms) { const m = re.exec(line); if (!m) continue; if (!defs.has(m[1])) defs.set(m[1], []); defs.get(m[1]).push(i + 1); break; } }); return defs; } /** audit-gate-rules.md 의 게이트 규칙 번호(`- **G15. …**`, `### G15.`)를 뽑는다. 반환: Set. */ export function gateRuleNumbers(md) { const out = new Set(); for (const line of unfencedLines(md)) { const m = /^\s*(?:[-*]\s+\*\*|#{2,6}\s+)G(\d+)(?=[.\s*:]|$)/.exec(line); if (m) out.add(Number(m[1])); } return out; } /** 정의된 행 ID 를 접두사별 순번 집합으로 묶는다. 반환: Map>. */ function rowIndex(defs) { const idx = new Map(); for (const id of defs.keys()) { const p = parseRowId(id); if (!p) continue; if (!idx.has(p.prefix)) idx.set(p.prefix, new Set()); idx.get(p.prefix).add(p.n); } return idx; } function rowLabel(prefix, n) { // 글자 순번을 쓰는 접두사는 M- 뿐이다(M-A~M-E). 숫자 순번이면 그대로 붙인다. return prefix === "M-" ? `${prefix}${String.fromCharCode(64 + n)}` : `${prefix}${n}`; } /** * 한 줄에서 행 ID 인용과 범위(`X1~X14`)를 뽑는다. prefixes 는 정의된 접두사 목록이다. * 반환: { ids: [{ id, col }], ranges: [{ from, to, col }] }. * 못 잡는 꼴: 접두사만 쓴 인용(`A, P, W`), 접두사를 생략한 범위(`X1~14`), 글로 푼 범위("X1부터 X14까지"). */ export function rowCitations(line, prefixes) { const hy = prefixes.filter((p) => p.endsWith("-")).map((p) => p[0]); const plain = prefixes.filter((p) => !p.endsWith("-")); const alts = []; if (hy.length) alts.push(String.raw`[${hy.join("")}]-(?:\d+|[A-Z])`); if (plain.length) alts.push(String.raw`[${plain.join("")}]\d+[a-z]?`); if (alts.length === 0) return { ids: [], ranges: [] }; const tok = `(?:${alts.join("|")})`; const edgeL = "(? b[1] - a[1]); } // ── 본 검사 ───────────────────────────────────────────────────────────── export async function lintSkill(root, opts = {}) { const errors = []; const warnings = []; const skillPath = path.join(root, "SKILL.md"); let md; try { md = await fs.readFile(skillPath, "utf8"); } catch { return { errors: [`SKILL.md 를 찾을 수 없다: ${skillPath}`], warnings, fatal: true }; } const allFiles = await walk(root); const mdFiles = allFiles.filter((f) => f.endsWith(".md")); const texts = new Map(); for (const f of mdFiles) texts.set(f, await fs.readFile(path.join(root, f), "utf8")); // 1. 줄끝·BOM — 소스 단계에서 막는다. bundle-skill.mjs 가 번들 때 LF 로 바꾸지만, CRLF 는 // 일부 파서에서 프론트매터를 깨고(AGENTS.md #2) 01 보고서가 소스 lint 의 사각임을 실증했다. for (const f of allFiles) { if (!TEXT_EXT.has(path.extname(f).toLowerCase())) continue; const buf = await fs.readFile(path.join(root, f)); if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { errors.push(`${f}: UTF-8 BOM 이 있다 — BOM 없는 UTF-8 로 저장한다`); } const cr = buf.indexOf(0x0d); if (cr !== -1) { const crlf = buf.toString("utf8").split("\r\n").length - 1; errors.push(`${f}: CR 줄끝이 있다(CRLF ${crlf}줄) — LF 로 바꾼다(AGENTS.md 함정 2)`); } } // 2. 프론트매터 const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md); if (!fmMatch) { errors.push("프론트매터(---)가 없다"); } else { const fm = Object.fromEntries( fmMatch[1] .split(/\r?\n/) .map((l) => /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(l)) .filter(Boolean) .map((m) => [m[1], m[2].replace(/^["']|["']$/g, "")]), ); if (fm.name !== "designpaca") errors.push(`name 이 designpaca 가 아니다: ${fm.name}`); if (!fm.description) errors.push("description 이 없다"); else { const len = fm.description.length; if (len < 80) warnings.push(`description 이 짧다(${len}자) — 트리거 정확도가 떨어진다`); if (len > 700) warnings.push(`description 이 길다(${len}자) — 요약해라`); } } // 3. 인터뷰 게이트. v0.11.0(45ba92e)에서 "거의 항상 묻는다"가 "결과를 크게 바꿀 때만 묻는다"로 바뀌었는데 // 커밋·체인지로그 어디에도 적히지 않아 아무도 알아채지 못했다. 절 단위로 잘라 문구·규칙 수·링크를 본다. const gate = gateSection(md); if (!gate) { errors.push("인터뷰 게이트 절(## 인터뷰 게이트)이 없다"); } else { for (const a of GATE_ANCHORS) { if (!a.re.test(gate)) errors.push(`인터뷰 게이트 절에 핵심 문구가 없다: ${a.label}`); } const rules = gate.match(/^\d+\.\s/gm) || []; if (rules.length !== GATE_RULE_COUNT) { errors.push(`인터뷰 게이트 번호 규칙이 ${GATE_RULE_COUNT}개가 아니다(${rules.length}개) — 규칙 삭제·병합은 AGENTS.md #10 절차를 따른다`); } for (const rel of GATE_LINKS) { if (!gate.includes(`](${rel})`) && !gate.includes(`](${rel}#`)) { errors.push(`인터뷰 게이트 절 안에 문서 링크가 없다: ${rel}`); } } for (const p of WEAKENING_PATTERNS) { for (const m of gate.matchAll(p.re)) { if (p.quotedOk && /["“'「]$/.test(gate.slice(Math.max(0, m.index - 1), m.index))) continue; errors.push(`인터뷰 게이트 절에 완화 문구가 있다(${p.label}): "${m[0]}"`); } } } // 4. 완화 문구 — 게이트 밖(SKILL.md 나머지, brief-interview, harness)도 본다. 허용 목록은 줄 단위다. for (const rel of WEAKENING_SCAN_FILES) { let text = texts.get(rel); if (text === undefined) continue; if (rel === "SKILL.md" && gate) text = text.replace(gate, (g) => g.replace(/[^\n]/g, " ")); for (const p of WEAKENING_PATTERNS) { for (const m of text.matchAll(p.re)) { if (p.quotedOk && /["“'「]$/.test(text.slice(Math.max(0, m.index - 1), m.index))) continue; const line = lineText(text, m.index); if (WEAKENING_ALLOWLIST.some((ok) => line.includes(ok))) continue; errors.push(`${rel}:${lineOf(text, m.index)}: 질문 여부를 자기판정에 맡기는 완화 문구(${p.label}): "${m[0]}"`); } } } // 5. 게이트 형제 문서의 앵커 for (const [rel, anchors] of Object.entries(SIBLING_ANCHORS)) { const text = texts.get(rel); if (text === undefined) continue; // 존재 여부는 6에서 본문 링크로 검사한다 for (const a of anchors) { if (!a.re.test(text)) errors.push(`${rel}: 인터뷰 게이트를 받치는 문구가 없다: ${a.label}`); } } // 6. 하네스·MCP 전용 도구명 — 본문과 references(harness.md 제외) const toolRe = harnessToolRegex(); for (const [rel, text] of texts) { if (HARNESS_TOOL_EXEMPT.has(rel)) continue; if (rel !== "SKILL.md" && !rel.startsWith("references/")) continue; for (const m of text.matchAll(toolRe)) { errors.push(`${rel}:${lineOf(text, m.index)}: 하네스 전용 도구명 '${m[0]}' — references/harness.md 로 옮기고 중립 표현을 쓴다(AGENTS.md #11)`); } } // 7. 본문이 참조하는 파일이 실제로 있는지 (references/xxx.md, tools/xxx.mjs) const refs = [...md.matchAll(/references\/[A-Za-z0-9._/-]+\.md/g)].map((m) => m[0]); for (const rel of new Set(refs)) { if (!(await exists(path.join(root, rel)))) errors.push(`본문이 가리키는 참조 문서가 없다: ${rel}`); } const toolRefs = [...md.matchAll(/(? m[0]); for (const rel of new Set(toolRefs)) { if (!(await exists(path.join(root, rel)))) errors.push(`본문이 가리키는 도구 파일이 없다: ${rel}`); } // 8. references 사이 상대 링크(파일 존재만, 앵커는 보지 않는다) for (const [rel, text] of texts) { if (!rel.startsWith("references/")) continue; for (const m of text.matchAll(/\]\(([^)\s#]+\.md)(?:#[^)]*)?\)/g)) { const target = m[1]; if (/^[a-z]+:\/\//i.test(target)) continue; const abs = path.join(root, path.dirname(rel), target); if (!(await exists(abs))) errors.push(`${rel}:${lineOf(text, m.index)}: 링크 대상 문서가 없다: ${target}`); } } // 8-1. 파일 간 § 참조 — `[x.md](x.md) §N`, `[x.md §N·§M](x.md)`, `x.md §N` 꼴이 가리키는 절 번호가 대상 // 문서의 제목에 실제로 있는지 본다. 실측: SWEEP(2026-10-02)이 분할·재번호 뒤 267건을 일회성 스크립트로 // 대조했다. 다음 분할 때 번호만 밀리는 회귀를 막는다. 대상 파일을 못 찾으면(산문 속 파일명) 건너뛴다 — // 파일 존재는 7·8 이 본다. const headingIds = new Map(); const idsOf = (rel) => { if (!headingIds.has(rel)) headingIds.set(rel, sectionIds(texts.get(rel) ?? "")); return headingIds.get(rel); }; const resolveMd = (from, target) => { const cands = [path.posix.join(path.posix.dirname(from), target), path.posix.normalize(target), path.posix.join("references", target)]; return cands.find((c) => texts.has(c)); }; // 확장자 없이 문서를 부르는 이름. references/ 바로 아래 문서만 넣는다 — presets 의 이름(editorial 등)은 보통 낱말이다. // 파일 이름은 [A-Za-z0-9_-] 뿐이라 정규식에 그대로 넣는다. const refNames = [...texts.keys()] .filter((f) => /^references\/[A-Za-z0-9_-]+\.md$/.test(f)) .map((f) => path.posix.basename(f, ".md")) .sort((a, b) => b.length - a.length); const docNameRe = new RegExp(String.raw`\.md(?![A-Za-z0-9])|(? SKILL_TOKEN_BUDGET) { warnings.push(`SKILL.md 추정 토큰 ${skillTokens} 이 예산 ${SKILL_TOKEN_BUDGET} 을 넘는다(목표 ${SKILL_TOKEN_TARGET}) — 세부는 references 로 옮긴다`); } for (const [rel, text] of texts) { if (!rel.startsWith("references/")) continue; const tokens = estimateTokens(text); const lines = text.split("\n").length; if ((tokens > REF_GUIDE_TOKEN_THRESHOLD || lines > REF_GUIDE_LINE_THRESHOLD) && !REF_GUIDE_HEADING.test(text)) { warnings.push(`${rel}: 추정 ${tokens}토큰·${lines}줄인데 '## 이 문서를 읽는 법' 표가 없다 — 절 단위로 읽을 수 없다`); } } // 11. 경고: 설치본에 없는 저장소 경로. bundle-skill.mjs 는 packages/skill 만 복사하므로 // research/·docs/ 를 가리키면 에이전트가 없는 파일을 찾느라 헤맨다(03 보고서 three.md 사례). // URL 의 일부(…/docs/…)는 제외한다. for (const [rel, text] of texts) { if (rel !== "SKILL.md" && !rel.startsWith("references/")) continue; for (const m of text.matchAll(/(? REF_GUIDE_HEADING.test(l)); let guideEnd = -1; if (guideStart !== -1) { guideEnd = lines.findIndex((l, i) => i > guideStart && /^##\s/.test(l)); if (guideEnd === -1) guideEnd = lines.length; } lines.forEach((line, i) => { if (!line.includes("§")) return; const inGuide = i > guideStart && i < guideEnd; const isRow = /^\s*\|/.test(line); if (!inGuide && (isRow || docNameRe.test(line))) return; for (const m of line.matchAll(secTokenRe)) { if (own.has(m[1])) continue; if (inGuide) { if (docNameRe.test(line.slice(0, m.index))) continue; errors.push(`${rel}:${i + 1}: '이 문서를 읽는 법'이 가리키는 §${m[1]} 이 이 문서에 없다(절을 옮기거나 재번호했으면 표를 고친다)`); } else { warnings.push(`${rel}:${i + 1}: 문서 이름 없이 쓴 §${m[1]} 이 이 문서에 없다 — 다른 문서의 절이면 문서 이름을 붙이고, 번호 목록의 항이면 "§N의 m번"으로 적는다`); } } }); } // 13. 행 ID 무결성(V-26). preflight.md 의 정의와 SKILL.md·references 의 인용을 대조한다. const preflight = texts.get(PREFLIGHT_DOC); const rowDefs = preflight === undefined ? new Map() : preflightRowDefs(preflight); if (rowDefs.size > 0) { const idx = rowIndex(rowDefs); const prefixes = [...idx.keys()]; const maxOf = (p) => Math.max(...idx.get(p)); const span = (p) => `${rowLabel(p, 1)}~${rowLabel(p, maxOf(p))}`; // (a) 정의는 유일하고 순번이 이어진다. for (const [id, lns] of rowDefs) { if (lns.length > 1) errors.push(`${PREFLIGHT_DOC}:${lns[1]}: 행 ID ${id} 가 두 번 정의됐다(첫 정의 ${lns[0]}줄)`); } for (const p of prefixes) { for (let n = 1; n <= maxOf(p); n++) { if (!idx.get(p).has(n)) errors.push(`${PREFLIGHT_DOC}: 행 ID ${rowLabel(p, n)} 이 없다 — ${span(p)} 사이 번호가 비었다`); } } // (b) 인용과 범위. preflight.md 는 전체를, 다른 문서는 preflight·"행 ID"를 말하는 줄만 본다. // 하위 행(P8a)은 그 글자까지 정의돼 있어야 한다. 부모(P8)만 부른 인용은 부모 정의로 충분하다. const defined = (id) => rowDefs.has(id); for (const [rel, text] of texts) { if (rel !== "SKILL.md" && !rel.startsWith("references/")) continue; const isPreflight = rel === PREFLIGHT_DOC; unfencedLines(text).forEach((line, i) => { if (!isPreflight && !ROW_ID_CONTEXT_RE.test(line)) return; const { ids, ranges } = rowCitations(line, prefixes); for (const { id } of ids) { const p = parseRowId(id); if (!p || p.n === 0 || defined(id)) continue; // `P0` 같은 심각도 표기는 행 ID 가 아니다 errors.push(`${rel}:${i + 1}: 행 ID ${id} 는 preflight.md 에 정의가 없다(정의는 ${span(p.prefix)})`); } for (const r of ranges) { const a = parseRowId(r.from); const b = parseRowId(r.to); if (!a || !b || a.prefix !== b.prefix || !defined(r.from) || !defined(r.to)) continue; // 없는 끝점은 위에서 이미 보고했다 // 첫 행에서 시작하는 범위는 "그 접두사 전부"라는 뜻이다. 행이 늘었는데 범위가 그대로면 새 행을 건너뛴다. if (a.n === 1 && b.n !== maxOf(a.prefix)) { errors.push(`${rel}:${i + 1}: 행 ID 범위 ${r.from}~${r.to} 가 정의와 다르다 — preflight.md 의 정의는 ${span(a.prefix)} 다`); } } }); } // (c) SKILL.md 의 행 ID 목록("행 ID만 돈다(M-A~M-E, A, P, …)")이 접두사를 전부 담는다. 빠진 접두사의 행은 // 건너뛸 근거가 된다(실측: O1~O4 누락). 목록 문장이 없으면 검사하지 않는다. for (const m of md.matchAll(/행 ID[^()\n]{0,40}\(([^()\n]*)\)/g)) { const items = m[1].split(/[,,]/).map((s) => s.trim()).filter(Boolean); // 항목은 접두사 글자만(`A`) 쓰거나 범위(`X1~X14`, `M-A~M-E`)로 쓴다. 첫 글자로 접두사를 가린다. const listed = new Set(items.map((s) => /^([A-Z])(?:$|[-\d])/.exec(s)?.[1]).filter(Boolean)); if (listed.size < 3) continue; // 행 ID 목록이 아닌 다른 괄호다 const bare = (p) => p.replace(/-$/, ""); for (const p of prefixes) { if (listed.has(bare(p))) continue; errors.push(`SKILL.md:${lineOf(md, m.index)}: 행 ID 목록에 ${bare(p)} 행(${span(p)})이 빠졌다 — preflight.md 의 접두사는 ${prefixes.map(bare).join("·")} 다`); } } } // (d) 게이트 문서의 옛 `M숫자` 번호. 기계 검사 행은 M-A~M-E 다. for (const rel of LEGACY_M_DOCS) { const text = texts.get(rel); if (text === undefined) continue; unfencedLines(text).forEach((line, i) => { for (const m of line.matchAll(/(? 0) { const gMax = Math.max(...gateRules); for (const [rel, text] of texts) { if (rel !== "SKILL.md" && !rel.startsWith("references/")) continue; unfencedLines(text).forEach((line, i) => { for (const m of line.matchAll(/(? budget) { const known = rel in REF_TOKEN_BUDGETS ? "" : "(표에 없는 문서 — 기본 예산)"; warnings.push(`${rel}: 추정 ${tokens}토큰이 문서 예산 ${budget} 을 넘는다${known} — 줄이거나, 의도한 증가면 lint-skill.mjs 의 REF_TOKEN_BUDGETS 를 올리고 이유를 남긴다`); } } if (refTotal > REF_TOTAL_TOKEN_BUDGET) { warnings.push(`references 전체 추정 ${refTotal}토큰이 합계 예산 ${REF_TOTAL_TOKEN_BUDGET} 을 넘는다 — 문서를 더하기 전에 겹치는 내용을 줄인다`); } // 15. 게이트 사본 동일성(V-29). 스킬 디렉터리 밖을 보는 유일한 검사다. 사이트 사본이 있을 때만 돈다 — // 설치본이나 스크래치 사본에서는 건너뛴다. 바이트 단위로 같아야 한다(둘 다 LF 다, AGENTS.md 함정 2). const siteGate = opts.siteGatePath ?? path.resolve(root, SITE_GATE_FROM_SKILL); if ((await exists(path.join(root, GATE_TOOL))) && (await exists(siteGate))) { const a = await fs.readFile(path.join(root, GATE_TOOL)); const b = await fs.readFile(siteGate); if (!a.equals(b)) { const la = a.toString("utf8").split("\n"); const lb = b.toString("utf8").split("\n"); let n = 0; while (n < la.length && n < lb.length && la[n] === lb[n]) n++; errors.push(`${GATE_TOOL}: 사이트 사본(${path.relative(root, siteGate).split(path.sep).join("/")})과 다르다 — 처음 갈라지는 곳은 ${n + 1}줄(스킬 ${a.length}바이트, 사이트 ${b.length}바이트). 한쪽을 고쳤으면 다른 쪽에 그대로 복사한다`); } } return { errors, warnings, fatal: false }; } // ── CLI ───────────────────────────────────────────────────────────────── const isMain = process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isMain) { const args = process.argv.slice(2); const root = path.resolve(args.find((a) => !a.startsWith("--")) ?? "."); if (args.includes("--sizes")) { // 예산 표를 다시 잠글 때 쓴다. 현재 추정 토큰과 "8% 여유 + 100 단위 올림" 값을 REF_TOKEN_BUDGETS 꼴로 낸다. let total = 0; for (const [rel, tokens] of await measureSizes(root)) { total += tokens; console.log(` ${JSON.stringify(rel)}: ${Math.ceil((tokens * 1.08) / 100) * 100}, // ${tokens}`); } console.log(`// references 합계 ${total} (예산 ${REF_TOTAL_TOKEN_BUDGET})`); process.exit(0); } const { errors, warnings, fatal } = await lintSkill(root); if (fatal) { console.error(errors[0]); process.exit(1); } for (const w of warnings) console.warn(` 경고: ${w}`); if (errors.length > 0) { for (const e of errors) console.error(` 오류: ${e}`); console.error(`\n스킬 검사 실패 — 오류 ${errors.length}건`); process.exit(1); } console.log(`스킬 검사 통과 (경고 ${warnings.length}건)`); }