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:
parent
ee3bef2ce5
commit
9bcb45cbd3
122 changed files with 5836 additions and 831 deletions
|
|
@ -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}건)`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue