designpaca/build/ci/lint-skill.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

814 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 스킬 문서의 최소 규약을 검사한다. 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 = /(?<![A-Za-z0-9_])mcp__[A-Za-z0-9_-]+/g;
export const HARNESS_TOOL_EXEMPT = new Set(["references/harness.md"]);
/** 하네스·MCP 전용 도구명을 잡는 정규식. 테스트의 기준 사본 정리도 같은 정규식을 쓴다. */
export function harnessToolRegex() {
const names = [...HARNESS_TOOL_NAMES, ...MCP_TOOL_NAMES];
return new RegExp(`(?<![A-Za-z0-9_])(?:mcp__[A-Za-z0-9_-]+|${names.join("|")})(?![A-Za-z0-9_])`, "g");
}
// 인터뷰 게이트 절(## 인터뷰 게이트 ~ 다음 ## ) 안에 반드시 있어야 할 문구. 하나라도 빠지면 오류다.
// 실측 사고: 01 레드팀 보고서에서 규칙 4("물었으면 턴을 끝낸다")를 지워도 lint 가 통과했다.
export const GATE_ANCHORS = [
{ label: "명시적으로 의존한다", re: /명시적으로 의존한다/ },
{ label: "물었으면 턴을 끝낸다(규칙 4)", re: /물었으면 턴을 끝낸다/ },
{
label: "답을 받기 전에는 조사·파일 생성·구현을 시작하지 않는다(규칙 4)",
re: /답을 받기 전에는[^\n]*구현[^\n]*(?:시작하지 않는다|하지 않는다)/,
},
{ label: "스스로 판정해 생략하지 않는다(규칙 2)", re: /스스로 판정해서? 생략하지 않는다/ },
{ label: "기계적으로 판정한다(규칙 3)", re: /기계적으로 판정한다/ },
{ label: "예외는 사용자의 명시적 위임뿐이다(규칙 6)", re: /명시적 위임뿐이다/ },
];
export const GATE_RULE_COUNT = 6;
export const GATE_LINKS = ["references/harness.md", "references/brief-interview.md"];
// 인터뷰 게이트를 받쳐 주는 형제 문서의 앵커. 게이트가 "여기서 바뀌지 않는다"고 선언한 문구다.
export const SIBLING_ANCHORS = {
"references/brief-interview.md": [
{ label: "스스로 판정해서 생략하지 않는다", re: /생략하지 않는다/ },
{ label: "턴 종료 규칙 절 제목", re: /^##\s.*턴 종료/m },
],
"references/harness.md": [
{ label: "기계적으로 판정한다", re: /기계적으로 판정한다/ },
{ label: "턴을 끝낸다", re: /턴을 끝낸다/ },
],
};
// 질문 여부를 모델의 자기평가에 맡기는 완화 문구. v0.11.0(45ba92e)의 "결과를 크게 바꿀 때만 묻는다"와
// 01 보고서가 실증한 우회형("경우에만 묻는다", "합리적 가정으로 계속 진행해도 된다")을 함께 잡는다.
export const WEAKENING_PATTERNS = [
{ label: "'~때만/~경우에만 묻는다·질문한다' 조건", re: /(?:때|경우)에?만\s*(?:[^\s.,。·]+\s+){0,3}?(?:묻|질문|되묻|확인을?\s*받)/g },
{ label: "'필요할 때(만) 묻는다' 조건", re: /필요(?:할|하다고\s*판단될|하다고\s*판단할)\s*때(?:에)?(?:만)?\s*(?:[^\s.,。]+\s+){0,2}?(?:묻|질문)/g },
{ label: "'크게 바꿀 때/경우' 자기판정 조건", re: /크게\s*바꿀\s*(?:때|경우)/g },
{ label: "'only ask/question' 조건", re: /\b(?:only\s+(?:ask|question)|ask\s+only\s+(?:when|if))/gi },
{ label: "'합리적·적절한 가정으로 계속/진행' 허용", re: /(?:합리적|적절한)\s*가정으로\s*(?:계속|진행)/g, quotedOk: true },
{ label: "'묻지 않고 계속/진행' 허용", re: /(?:묻지|질문하지)\s*않고\s*(?:계속|진행)/g, quotedOk: true },
];
// 완화 정규식에 걸리지만 객관적 조건이라 합법인 문장. 게이트 절 안에는 적용하지 않는다.
// 새로 추가할 때는 "조건이 모델의 판단이 아니라 관측 가능한 사실인가"를 확인하고 이유를 남긴다.
export const WEAKENING_ALLOWLIST = [
"읽지 못할 때만 묻는다", // 언어 슬롯: 로케일 명령 실패라는 관측 사실
"불명확할 때만 묻는다", // 언어 판정 순서의 근거가 모두 없다는 관측 사실
"불명확할 때만 질문한다",
"새 화면**일 때만 묻는다", // 연장 경로: 화면 신설 여부는 사실
"새 화면일 때만 묻는다",
"`design.md`에 없을 때만 묻는다", // 파일 내용으로 판정
"design.md에 없을 때만 묻는다",
"추가하거나 바꿀 때만** 묻는다", // 국소 경로 고유명사(01 F7): 이번 작업이 값을 추가·변경하는가는 diff 로 판정
"추가하거나 바꿀 때만 묻는다",
];
// 완화 문구를 찾는 문서. 게이트 절은 별도로 허용 목록 없이 검사한다.
export const WEAKENING_SCAN_FILES = ["SKILL.md", "references/brief-interview.md", "references/harness.md"];
// SKILL.md 는 트리거될 때마다 통째로 로드된다. 03 보고서 추정식(한글 음절 × 1.0 + 그 밖의 문자 / 3.5)으로
// 2026-10-02 기준 약 15.7k 토큰이다. 회귀(더 커짐)만 경고하도록 현재보다 약간 위에 둔다. 03 의 목표치는 10k 다.
export const SKILL_TOKEN_BUDGET = 16_500;
export const SKILL_TOKEN_TARGET = 10_000;
// 이 크기를 넘는 참조 문서는 머리에 "## 이 문서를 읽는 법" 표가 있어야 절 단위로 읽을 수 있다.
export const REF_GUIDE_TOKEN_THRESHOLD = 6_000;
export const REF_GUIDE_LINE_THRESHOLD = 300;
export const REF_GUIDE_HEADING = /^##\s*이 문서를 읽는 법/m;
// ── 크기 예산(V-30) ─────────────────────────────────────────────────────
// 목적은 조용한 비대화를 막는 것이다. 값은 추정 토큰(estimateTokens)이고, 잠근 날의 실측에 약 8%의 여유를
// 둔 뒤 100 단위로 올림했다. 넘으면 경고만 낸다 — 문서를 의도해서 키웠으면 이 표의 값을 같이 올리고 이유를
// 커밋에 적는다. 현재 값은 `node build/ci/lint-skill.mjs packages/skill --sizes` 로 다시 뽑는다.
// 표에 없는 문서(새 문서)는 REF_TOKEN_BUDGET_DEFAULT 를 쓴다.
export const REF_TOKEN_BUDGETS = {
"references/typography.md": 19600, // 18124
"references/layout.md": 19100, // 17593
"references/motion.md": 18400, // 16985
"references/brief-interview.md": 17900, // 16500
"references/antipatterns.md": 16600, // 15330
"references/accessibility.md": 16400, // 15113
"references/evidence-ledger.md": 16000, // 14726
"references/preflight.md": 14300, // 13171
"references/three.md": 14200, // 13124
"references/critique.md": 14000, // 12924
"references/harness.md": 13100, // 12068
"references/audit-gate.md": 12600, // 11621
"references/product-copy.md": 12500, // 11564
"references/component-systems.md": 11500, // 10579
"references/change-review.md": 10800, // 9964
"references/tokens.md": 10600, // 9772
"references/color.md": 10400, // 9558
"references/interaction-feel.md": 10400, // 9548
"references/svg-filters.md": 10100, // 9274
"references/trustworthy-showcases.md": 9700, // 8979
"references/mobile-app-ux.md": 8900, // 8177
"references/images.md": 8800, // 8080
"references/art-direction.md": 8600, // 7892
"references/audit-gate-rules.md": 8300, // 7674
"references/reference-method.md": 7700, // 7104
"references/style-atlas.md": 6900, // 6348
"references/experimental-canvas.md": 6900, // 6345
"references/print-email.md": 6900, // 6319
"references/motion-parallax.md": 5500, // 5034
"references/icons.md": 5500, // 5006
"references/svg-glass.md": 5100, // 4707
"references/galleries.md": 5100, // 4706
"references/feedback-loop.md": 5000, // 4609
"references/elevation.md": 4600, // 4250
"references/style-playbook.md": 4600, // 4231
"references/genre-conventions.md": 4600, // 4197
"references/design-foundations.md": 4000, // 3682
"references/remix.md": 4000, // 3660
"references/presets/README.md": 3900, // 3570
"references/visual-perception-research.md": 3000, // 2724
"references/visual-design-lineage.md": 2800, // 2577
"references/presets/quiet-commerce.md": 2600, // 2320
"references/presets/anti-grid.md": 2500, // 2268
"references/presets/editorial.md": 2200, // 1955
"references/presets/dark-instrument.md": 2000, // 1790
"references/presets/swiss-minimal.md": 1900, // 1723
};
export const REF_TOKEN_BUDGET_DEFAULT = 8_000;
// references 전체 합계(presets 포함). 문서별 예산을 조금씩 올려 전체가 불어나는 것을 따로 막는다.
export const REF_TOTAL_TOKEN_BUDGET = 385_000;
// ── 행 ID(V-26) ─────────────────────────────────────────────────────────
// preflight.md 가 "납품 판정은 이 문서의 행 ID로만 한다"고 선언하므로 행 ID 는 문서 간 계약이다.
// 실측 결함(2026-10-06 정합성 감사 C-19·C-20): SKILL.md 의 행 ID 목록에 O1~O4 가 빠졌고, antipatterns.md 가
// V1~V6 으로 적었고(정의는 V7 까지), audit-gate-rules.md 에 정의 없는 M5·M6 이 남아 있었다.
export const PREFLIGHT_DOC = "references/preflight.md";
export const GATE_RULES_DOC = "references/audit-gate-rules.md";
// 행 ID 를 인용한 줄로 보는 조건. preflight.md 밖에서는 이 낱말이 있는 줄만 본다 — `P1`(심각도)·`A4`(용지)·
// `S3` 처럼 같은 꼴의 다른 낱말이 흔해서, 문서 전체를 훑으면 오탐이 난다.
export const ROW_ID_CONTEXT_RE = /preflight|행 ID/;
// 기계 검사 행은 M-A~M-E 다. 게이트 문서에 남은 `M5`·`M6` 같은 옛 번호를 잡는다. M2·M3 는 Material Design
// 판 이름이라 제외한다. 다른 문서의 `M숫자`는 보지 않는다(고유명과 구분할 수 없다).
export const LEGACY_M_DOCS = new Set(["references/audit-gate.md", "references/audit-gate-rules.md", PREFLIGHT_DOC]);
export const LEGACY_M_ALLOW = new Set(["M2", "M3"]);
// 게이트 사본(V-29). 사이트가 쓰는 사본과 스킬에 싣는 사본이 갈라지면 문서가 말하는 동작과 설치본의 동작이
// 달라진다. 스킬 디렉터리에서 본 상대 위치이고, 그 파일이 있을 때(저장소 안에서 돌 때)만 대조한다.
export const GATE_TOOL = "tools/design-gate.mjs";
export const SITE_GATE_FROM_SKILL = "../../apps/site/tools/design-gate.mjs";
// 줄끝·BOM 을 검사할 텍스트 파일 확장자.
const TEXT_EXT = new Set([".md", ".yaml", ".yml", ".json", ".mjs", ".js", ".cjs", ".ts", ".txt", ".css", ".html", ".toml"]);
// ── 유틸 ────────────────────────────────────────────────────────────────
export function estimateTokens(text) {
const hangul = (text.match(/[가-힣]/g) || []).length;
return Math.round(hangul + (text.length - hangul) / 3.5);
}
function lineOf(text, index) {
return text.slice(0, index).split("\n").length;
}
function lineText(text, index) {
const start = text.lastIndexOf("\n", index - 1) + 1;
const end = text.indexOf("\n", index);
return text.slice(start, end === -1 ? text.length : end);
}
/** `## 인터뷰 게이트` 부터 다음 `## ` 제목 직전까지를 잘라 낸다. */
export function gateSection(md) {
const m = /^## 인터뷰 게이트.*$/m.exec(md);
if (!m) return null;
const rest = md.slice(m.index + m[0].length);
const next = /^#{1,2} /m.exec(rest);
return md.slice(m.index, m.index + m[0].length + (next ? next.index : rest.length));
}
// § 참조 문법. 절 번호는 `6`, `6-5`, `6-b`, `1.1` 꼴이다. 끝의 마침표(`§6.`)는 문장부호로 본다.
const SEC_ID = String.raw`\d+(?:[-.][0-9A-Za-z]+)*`;
const SEC_CHAIN = String.raw`§\s?${SEC_ID}(?:\s?[·~,]\s?§\s?${SEC_ID})*`;
/** 문서 제목에서 절 번호 집합을 뽑는다(`## 6.`, `### 6-5.`, `### 6-b. …`, `## §3.`, `## 1.1 …`). 코드 펜스 안은 제외한다. */
export function sectionIds(md) {
const ids = new Set();
let fence = false;
for (const line of md.split("\n")) {
if (/^\s*(?:```|~~~)/.test(line)) fence = !fence;
if (fence) continue;
const m = new RegExp(String.raw`^#{1,6}\s+(?:§\s?)?(${SEC_ID})(?=[.\s):]|$)`).exec(line);
if (m) ids.add(m[1]);
}
return ids;
}
/**
* 다른 파일을 가리키는 § 참조를 뽑는다. 반환: { file, ids[], index, label? }. 같은 문서 안의 맨 § 는 보지 않는다.
* bareNames 를 주면 확장자 없는 문서 이름(`critique §2-1`, `harness §6-b`)도 읽는다(V-27). SKILL.md 가 주로 이 꼴이다.
* 못 잡는 꼴: 앞 문서를 이어받는 표기(`critique §2-1 … §2-2` 의 둘째), 조사가 낀 표기(`critique의 §2`),
* 제목을 이름으로 가리키는 참조(`audit-gate.md "루프와 재작업 상한"`), 앵커(`#…`).
*/
export function sectionRefs(md, bareNames = []) {
const out = [];
const pick = (chain) => [...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`(?<![\w./-])\`?((?:\.\.\/|references\/)?[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*\.md)\`?\s?(${SEC_CHAIN})`, "g");
for (const m of blanked.matchAll(plainRe)) out.push({ file: m[1], ids: pick(m[2]), index: m.index });
// (3) 확장자 없는 문서 이름: `critique §2-1` — (1)(2)에서 읽은 자리는 지우고 찾는다
if (bareNames.length > 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`(?<![\w./-])(${alt})\s?(${SEC_CHAIN})`, "g");
for (const m of rest.matchAll(bareRe)) out.push({ file: `${m[1]}.md`, label: m[1], ids: pick(m[2]), index: m.index });
}
return out;
}
/** 코드 펜스 안과 펜스 줄을 빈 줄로 바꾼 줄 배열. 줄 번호는 그대로다. */
export function unfencedLines(md) {
let fence = false;
return md.split("\n").map((line) => {
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<id, 줄 번호[]>.
* 정의로 보는 꼴은 셋이다 — 표의 첫 칸(`| 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<number>. */
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<prefix, Set<n>>. */
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 = "(?<![A-Za-z0-9_#/.-])";
const edgeR = "(?![A-Za-z0-9_-])";
const ranges = [];
for (const m of line.matchAll(new RegExp(`${edgeL}(${tok})\s?[~–]\s?(${tok})${edgeR}`, "g"))) {
ranges.push({ from: m[1], to: m[2], col: m.index });
}
const ids = [];
for (const m of line.matchAll(new RegExp(`${edgeL}(${tok})${edgeR}`, "g"))) ids.push({ id: m[1], col: m.index });
return { ids, ranges };
}
async function walk(dir, base = dir) {
const out = [];
let entries = [];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
if (e.name === "node_modules" || e.name === ".git") continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walk(full, base)));
else out.push(path.relative(base, full).split(path.sep).join("/"));
}
return out;
}
async function exists(p) {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
/** references 문서별 추정 토큰. 반환: [rel, tokens][] (큰 것부터). 예산 표를 다시 잠글 때 쓴다. */
export async function measureSizes(root) {
const out = [];
for (const rel of await walk(root)) {
if (!rel.startsWith("references/") || !rel.endsWith(".md")) continue;
out.push([rel, estimateTokens(await fs.readFile(path.join(root, rel), "utf8"))]);
}
return out.sort((a, b) => 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(/(?<![\w/.-])tools\/[A-Za-z0-9._-]+\.(?:mjs|js|cjs)/g)].map((m) => 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])|(?<![\w-])(?:${[...refNames, "SKILL"].join("|")})(?![\w-])`);
for (const [rel, text] of texts) {
if (rel !== "SKILL.md" && !rel.startsWith("references/")) continue;
for (const ref of sectionRefs(text, refNames)) {
const target = resolveMd(rel, ref.file);
if (!target || target === rel) continue;
const ids = idsOf(target);
for (const id of ref.ids) {
if (!ids.has(id)) {
errors.push(`${rel}:${lineOf(text, ref.index)}: '${ref.label ?? ref.file} §${id}' — ${target} 에 그 번호의 제목이 없다(분할·재번호 뒤 참조를 고친다)`);
}
}
}
}
// 9. 죽은 문서 — 참조 문서가 본문 어디에서도 언급되지 않는다
for (const f of mdFiles) {
if (!f.startsWith("references/")) continue;
if (!md.includes(f)) warnings.push(`본문에서 참조되지 않는 문서: ${f}`);
}
// 10. 경고: 토큰 예산(03 보고서 P0-1)
const skillTokens = estimateTokens(md);
if (skillTokens > 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(/(?<![\w/.:@-])(?:\.\.\/)*(?:research|docs)\/[A-Za-z0-9._/-]+/g)) {
warnings.push(`${rel}:${lineOf(text, m.index)}: 설치본에 없는 저장소 경로 '${m[0]}' — 핵심 내용을 문서에 넣거나 "(저장소 전용, 설치본 미포함)"으로 표기하고 경로 대신 근거 ID 를 쓴다`);
}
}
// 12. 문서 안의 맨 § 와 "이 문서를 읽는 법" 표(V-31). 문서 이름이 붙지 않은 `§N` 은 같은 문서의 절을 뜻한다.
// - 읽는 법 표의 § 가 그 문서에 없으면 오류다. 이 표는 절 단위 읽기의 입구라서 틀리면 없는 절을 찾는다.
// - 본문 산문의 맨 § 가 그 문서에 없으면 경고다. 실측: harness.md 의 "(§3-3)"은 §3 의 번호 목록 3번을
// 뜻했고 그런 제목은 없었다(C-26).
// 오탐을 피하려고 좁힌 것: 같은 줄에서 앞쪽(산문은 줄 어디든)에 다른 문서 이름이 있으면 그 문서를
// 이어받는 표기(`critique §2-1 … §2-2`)로 보고 건너뛴다. 산문 검사는 표의 행도 건너뛴다 — 표는 머리
// 칸이나 위 문단이 문서를 정하는 일이 많다(antipatterns.md 의 "§6-5 어절 줄바꿈" 열이 그 예다).
// 못 잡는 것: 문서 이름이 여러 줄 앞에 있는 이어받기, `color`·`motion` 처럼 문서 이름과 같은 낱말이
// 우연히 앞에 온 줄의 § (건너뛴다), 번호 없는 제목을 이름으로 가리키는 참조.
const secTokenRe = new RegExp(String.raw`§\s?(${SEC_ID})`, "g");
for (const [rel, text] of texts) {
if (!rel.startsWith("references/")) continue;
const own = idsOf(rel);
if (own.size === 0) continue;
const lines = unfencedLines(text);
const guideStart = lines.findIndex((l) => 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(/(?<![A-Za-z0-9_#/.-])M\d+(?![A-Za-z0-9_-])/g)) {
if (LEGACY_M_ALLOW.has(m[0])) continue;
errors.push(`${rel}:${i + 1}: '${m[0]}' 은 정의가 없는 번호다 — 기계 검사 행은 M-A~M-E 다(preflight.md §0). 옛 번호면 지우거나 행 ID 로 바꾼다`);
}
});
}
// (e) 게이트 규칙 번호(`G15`). 정의는 audit-gate-rules.md 의 `- **G15. …**` 다. preflight 의 `G-1` 계열과는
// 하이픈으로 구분한다. `G1~G36` 처럼 1번에서 시작하는 범위는 전체를 뜻하므로 마지막 번호와 같아야 한다.
const gateRules = gateRuleNumbers(texts.get(GATE_RULES_DOC) ?? "");
if (gateRules.size > 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(/(?<![A-Za-z0-9_#/.-])G(\d+)(?![A-Za-z0-9_-])/g)) {
if (gateRules.has(Number(m[1]))) continue;
errors.push(`${rel}:${i + 1}: 게이트 규칙 G${m[1]} 은 audit-gate-rules.md 에 정의가 없다(정의는 G1~G${gMax}). preflight 의 최종 게이트 행이면 G-${m[1]} 로 적는다`);
}
for (const m of line.matchAll(/(?<![A-Za-z0-9_#/.-])G1\s?[~–]\s?G(\d+)(?![A-Za-z0-9_-])/g)) {
if (Number(m[1]) === gMax || !gateRules.has(Number(m[1]))) continue;
errors.push(`${rel}:${i + 1}: 게이트 규칙 범위 G1~G${m[1]} 가 정의와 다르다 — audit-gate-rules.md 의 정의는 G1~G${gMax} 다`);
}
});
}
}
// 14. 경고: 문서 크기 예산(V-30). 조용한 비대화를 막는다.
let refTotal = 0;
for (const [rel, text] of texts) {
if (!rel.startsWith("references/")) continue;
const tokens = estimateTokens(text);
refTotal += tokens;
const budget = REF_TOKEN_BUDGETS[rel] ?? REF_TOKEN_BUDGET_DEFAULT;
if (tokens > 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}건)`);
}