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
This commit is contained in:
Yun Chan 2026-10-06 16:55:38 +09:00
parent ee3bef2ce5
commit 9bcb45cbd3
122 changed files with 5836 additions and 831 deletions

View file

@ -1,66 +1,393 @@
// 스킬 문서의 최소 규약을 검사한다. CI 와 `pnpm test` 가 함께 쓴다.
// - SKILL.md 존재와 프론트매터 필수 필드
// - description 길이(트리거 정확도에 직접 영향)
// - references 링크가 실제 파일을 가리키는지
// - 인터뷰 게이트 절의 핵심 문구·규칙 수·링크, 완화 문구 회귀(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";
const root = path.resolve(process.argv[2] ?? ".");
const errors = [];
const warnings = [];
// ── 상수 ────────────────────────────────────────────────────────────────
const skillPath = path.join(root, "SKILL.md");
let md;
try {
md = await fs.readFile(skillPath, "utf8");
} catch {
console.error(`SKILL.md 를 찾을 수 없다: ${skillPath}`);
process.exit(1);
// 특정 하네스에만 있는 도구 이름. 모든 타깃 어댑터가 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");
}
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}자) — 요약해라`);
// 인터뷰 게이트 절(## 인터뷰 게이트 ~ 다음 ## ) 안에 반드시 있어야 할 문구. 하나라도 빠지면 오류다.
// 실측 사고: 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;
}
// 0단계 인터뷰 게이트가 조용히 약해지지 않았는지 확인한다. v0.11.0(45ba92e)에서 "거의 항상 묻는다"가
// "결과를 크게 바꿀 때만 묻는다"로 바뀌었는데 커밋·체인지로그 어디에도 적히지 않아 아무도 알아채지 못했다.
if (!/^## 인터뷰 게이트/m.test(md) || !md.includes("명시적으로 의존한다")) {
errors.push("인터뷰 게이트 절(## 인터뷰 게이트, '명시적으로 의존한다')이 없다");
}
for (const rel of ["references/harness.md", "references/brief-interview.md"]) {
if (!md.includes(rel)) errors.push(`인터뷰 게이트가 가리켜야 할 문서 링크가 없다: ${rel}`);
}
if (/때만\s*(?:아래 항목을\s*)?묻는다/.test(md)) {
errors.push("본문에 '~때만 묻는다' 조건이 있다 — 질문 여부를 모델의 자기평가에 맡기는 완화 문구다");
}
// 본문이 참조하는 파일이 실제로 있는지 확인한다 (references/xxx.md 형태)
const refs = [...md.matchAll(/references\/[A-Za-z0-9._/-]+\.md/g)].map((m) => m[0]);
for (const rel of new Set(refs)) {
try {
await fs.access(path.join(root, rel));
} catch {
errors.push(`본문이 가리키는 참조 문서가 없다: ${rel}`);
/**
* 다른 파일을 가리키는 § 참조를 뽑는다. 반환: { 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 = [];
@ -70,23 +397,418 @@ async function walk(dir, base = dir) {
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;
}
const refDir = path.join(root, "references");
for (const f of await walk(refDir, root)) {
if (!f.endsWith(".md")) continue;
const rel = f.split(path.sep).join("/");
if (!md.includes(rel)) warnings.push(`본문에서 참조되지 않는 문서: ${rel}`);
async function exists(p) {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
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);
/** 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}건)`);
}
console.log(`스킬 검사 통과 (경고 ${warnings.length}건)`);

View file

@ -0,0 +1,680 @@
// 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\)과 다르다/);
});

View file

@ -11,6 +11,11 @@
set -euo pipefail
PKG_DIR="${1:-packages/cli}"
# set -e 로 중간에 죽어도 토큰을 담은 임시 npmrc 가 /tmp 에 남지 않게 한다
npmrc=""
cleanup() { if [ -n "$npmrc" ]; then rm -f "$npmrc"; fi; }
trap cleanup EXIT
cd "$PKG_DIR"
name="$(node -p 'require("./package.json").name')"

View file

@ -9,7 +9,13 @@ set -euo pipefail
[ -n "${RELEASE_TOKEN:-}" ] || { echo "RELEASE_TOKEN 환경변수가 없습니다" >&2; exit 2; }
tag="$1"; shift
api="${GITHUB_SERVER_URL%/}/api/v1/repos/${RELEASE_REPO:-$GITHUB_REPOSITORY}"
auth="Authorization: token ${RELEASE_TOKEN}"
# 토큰을 curl 인자(-H "...")로 넘기면 같은 호스트의 다른 프로세스에 ps 로 보인다.
# 0600 임시 파일에 헤더를 적고 -H @file 로 넘긴다(curl 7.55+). 끝나면 지운다.
hdr="$(mktemp)"
chmod 600 "$hdr"
trap 'rm -f "$hdr"' EXIT
printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$hdr"
auth="@$hdr"
# draft 릴리스는 tags/<tag> 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다
# tr -d '\r': Windows Git Bash 에서 python 출력이 CRLF 로 나와 id 가 오염되는 것 방지

View file

@ -69,6 +69,22 @@ MSG
exit 1
fi
# 이 러너는 PR CI 와 릴리스가 같은 host 를 공유한다. PR 잡이 러너 홈(~/.nvm 등)에 가짜 node 를
# 심으면 위 탐색이 그걸 PATH 에 올릴 수 있다. 실제로 쓰는 바이너리의 위치·해시를 항상 남기고,
# EXPECTED_NODE_SHA256 이 주어지면(릴리스 잡) 다를 때 멈춘다.
node_bin="$(command -v node)"
node_real="$(readlink -f "$node_bin" 2>/dev/null || echo "$node_bin")"
if command -v sha256sum >/dev/null 2>&1; then
node_sum="$(sha256sum "$node_real" | cut -d' ' -f1)"
else
node_sum="$(shasum -a 256 "$node_real" | cut -d' ' -f1)"
fi
echo "node 바이너리: $node_real (sha256 $node_sum)"
if [ -n "${EXPECTED_NODE_SHA256:-}" ] && [ "$node_sum" != "$EXPECTED_NODE_SHA256" ]; then
echo "node 바이너리 해시가 기대값(EXPECTED_NODE_SHA256)과 다르다 — 러너 오염 가능성이 있어 멈춘다" >&2
exit 1
fi
# pnpm 은 corepack 으로 확보한다 — 러너에 전역 설치를 요구하지 않는다
if ! command -v pnpm >/dev/null 2>&1; then
corepack enable >/dev/null 2>&1 || true