diff --git a/src/skills/bundled/karpathy-wiki-lint/SKILL.md b/src/skills/bundled/karpathy-wiki-lint/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..73fc0fa4062589df49b111ee108d03855f9f4964 --- /dev/null +++ b/src/skills/bundled/karpathy-wiki-lint/SKILL.md @@ -0,0 +1,110 @@ +--- +name: karpathy-wiki-lint +description: 对当前目录下的 wiki/*.md 做 5 项健康检查(断链 / orphan / 缺摘要 / 缺日期戳 / 出链密度),输出每条明细与机器可读 SUMMARY 行,退出码恒 0。检查由 skill 自带的 lint.ts 确定性执行,需 Node ≥ 23.6(或 bun)。当用户说"体检 wiki""断链检查""wiki lint""跑一遍 lint""看 wiki 健不健康"时触发。 +version: 1.0.0 +--- + +# karpathy-wiki-lint + +## 角色 + +你是 Karpathy LLM Wiki 知识库的**健康检查员**。用户用 `/karpathy-wiki-lint` 一句话,你跑完 5 项检查,先给明细,再给汇总,然后按报告修问题并落 log。本 skill 自带确定性执行器 `lint.ts`,**不要**手写临时脚本。 + +## 触发条件 + +- 用户输入 `/karpathy-wiki-lint` +- 用户说「体检 wiki」「断链检查」「wiki lint」「跑一遍 lint」「看 wiki 健不健康」 + +## 工作流(严格按顺序) + +### Step 1:跑确定性脚本 + +**优先**用 skill 自带的 TS 确定性执行器(无需 shell 脚本,跨平台一致): + +```bash +# 知识库根目录 或 wiki/ 子目录里跑都行 +node /lint.ts [targetDir] +# 或 +bun run /lint.ts [targetDir] +``` + +- `targetDir` 缺省 = 当前 cwd(脚本会自动判断 cwd 已经是 `wiki/` 还是在库根) +- 脚本退出码恒 0;**所有问题都打到 stdout**,stderr 不输出业务信息 +- 想临时调高链接密度阈值:`MIN_LINKS=4 node lint.ts` + +### Step 2:读 5 项输出并决策 + +按以下 5 个分类逐条处理(明细行格式 `TAG: <细节>`,末尾 `SUMMARY: ...` 是机器可读汇总): + +#### ① BROKEN — `[[目标]]` 引用了不存在的页面 + +| 报告内容 | 处理 | +|----------|------| +| 目标含 `/`(跨域,如 `raw/...` / `outputs/...` / `../CLAUDE.md`) | **跳过**,不算断链(脚本已自动豁免) | +| fenced code block(``` ``` ```)内与行内反引号里的示例 | **跳过**,不算断链(脚本已自动豁免) | +| 真实存在的概念,但还没写页 | **新建主题页**(摘要 + 日期戳 + 建议互链 ≥ `MIN_LINKS`),并进 `INDEX.md` | +| 错别字或旧页名 | 改引用方的链接,不必新建页 | +| 本来就该拆出来的子主题 | 从来源页拆分,双向互链 | +| 已被合并掉的旧主题 | 把引用改指向合并后的页面 | + +#### ② ORPHAN — 没有任何其它页面链向它 + +- **豁免**:`INDEX.md`、`log.md`、工作流页(文件名含 `工作流-` 的元文档)不算 orphan +- 处理表: + +| Orphan 性质 | 处理 | +|-------------|------| +| 确实该被链接的主题 | 进 `[[INDEX]]` 主题清单,并在最相关主题页加一条互链 | +| 内容还没长成,只是草稿 | 顶部标注 `> 草稿:待整合`,写明并进哪页,并登 `todo` | +| 看起来已经废弃 | **只建议,不自动删除**,把候选列成清单交给用户,说明理由 | + +> ⚠️ **LLM 永远不主动删除 wiki 页面**。删除是不可逆动作,必须由用户明确指令触发。 + +#### ③ NOSUMMARY — 缺 `## 摘要` 或 `## Summary` + +- **硬项**,必须修。中英两套等价,整库统一一种即可。 +- 处理:在该页加 `## 摘要` 段落(3-5 句浓缩这一页讲什么),并写一行 `> 最后更新:<今天日期>`。 + +#### ④ NOSTAMP — 缺日期戳 + +- **硬项**,必须修。判定:**仅** `> 最后更新:YYYY-MM-DD` 与 `> Last updated:YYYY-MM-DD` 两种写法算有戳,其他写法一律按缺戳处理 +- 处理:加 `> 最后更新:<今天真实日期>`(就在 `## 摘要` 上方一行),**补戳必须用当天真实日期,不许编造** + +#### ⑤ LOWLINKS — `[[]]` 出链数 < `MIN_LINKS`(默认 3) + +- **提示项(INFO)**,不是不达标 —— 主题页总数 < 8 的小库直接忽略 +- 处理原则: + +| 情况 | 处理 | +|------|------| +| 这一页确实孤零零 | 顺手补几条**真实**互链 | +| 这个主题天然没什么邻居 | 放着不管,在收尾里说清"哪几页、为什么留着" | +| 想凑数去链无关页面 / 链到不存在的页面造断链 | **禁止** —— 那是拿真断链换假达标 | + +### Step 3:收尾(必做) + +- [ ] **重跑一次** `lint.ts`,确认 `SUMMARY` 行里 `broken` / `orphans` / `nosummary` / `nostamp` 四项归零(`lowlinks` 是提示项,残留可接受,要在收尾里说清) +- [ ] 被改动的页面**日期戳已更新**到当天 +- [ ] `INDEX.md` 与实际页面清单一致 +- [ ] 追加 `## [YYYY-MM-DD] lint | <修了什么、还剩什么>` 到 `wiki/log.md` +- [ ] 用了 git 的库,顺手 `git commit -m "lint: <修了什么>"` + +## 输出格式(由 lint.ts 负责) + +```text +BROKEN: <链接目标> (referenced by: a.md, b.md) +ORPHAN: <文件名> +NOSUMMARY: <文件名> +NOSTAMP: <文件名> +LOWLINKS: <文件名> (n < MIN_LINKS) +SUMMARY: broken=N orphans=N lowlinks=N nosummary=N nostamp=N +``` + +## 不要做的事 + +- ❌ 不要覆盖用户已有的 `wiki/INDEX.md` / `wiki/log.md`(lint 只读不改) +- ❌ 不要把 lint 失败写成退出码 ≠ 0(脚本永远 exit 0,问题都在 stdout) +- ❌ 不要为了把 `lowlinks` 压到 0 去链无关页面,或链到还不存在的页面造断链 +- ❌ 不要自动删除 orphan / 过期页 —— **LLM 永远不主动删除 wiki 页面** +- ❌ 不要补日期戳时编造日期,必须用当天真实日期 +- ❌ 不要在 fenced code block 外 / 行内反引号外,豁免链接检查 —— 跳过规则是脚本的硬编码 \ No newline at end of file diff --git a/src/skills/bundled/karpathy-wiki-lint/lint.ts b/src/skills/bundled/karpathy-wiki-lint/lint.ts new file mode 100644 index 0000000000000000000000000000000000000000..1df05b3a6d0b701d70de87858141627f2330d38f --- /dev/null +++ b/src/skills/bundled/karpathy-wiki-lint/lint.ts @@ -0,0 +1,308 @@ +/** + * src/skills/bundled/karpathy-wiki-lint/lint.ts + * + * karpathy-wiki-lint 的确定性执行器(IK8MWU #21)。 + * + * 用法: + * 作为库: import { lintWiki } from './lint.js' + * 作为 CLI: node lint.ts [targetDir] (Node ≥ 23.6;MIN_LINKS 环境变量可覆盖) + * + * 行为契约(与 SKILL.md 一致): + * - 5 项检查(BROKEN / ORPHAN / NOSUMMARY / NOSTAMP / LOWLINKS)对 wiki/*.md + * - 跳过规则: + * · 跨域引用(目标含 /,如 raw/... / outputs/... / ../CLAUDE.md)→ 不算 BROKEN + * · fenced code block 内 + 行内反引号内的 [[...]] → 不算 BROKEN + * · ORPHAN / LOWLINKS 豁免 INDEX.md / log.md / 文件名含 工作流- 的工作流页 + * - 日期戳判定:仅 `> 最后更新:YYYY-MM-DD` 与 `> Last updated:YYYY-MM-DD` 两种写法 + * - 摘要判定:`## 摘要` 与 `## Summary` 两套等价 + * - 输出:逐条 TAG + 末尾 SUMMARY 行;exit code 恒 0 + * - 调用位置无关:从库根(子目录 wiki/)或 wiki/ 内运行,输出一致 + * - 默认 MIN_LINKS=3,可通过 opts.minLinks 或环境变量 MIN_LINKS 覆盖 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { getErrorMessage } from '../../../utils/error.js'; + +/** 默认链接密度阈值 */ +export const DEFAULT_MIN_LINKS = 3; + +/** ORPHAN / LOWLINKS 检查的豁免文件名(精确匹配) */ +const ORPHAN_EXEMPT_BASENAMES = new Set([ + 'INDEX.md', + 'log.md', +]); + +/** ORPHAN / LOWLINKS 检查的工作流页豁免:文件名含 `工作流-` 的工作流页 */ +const ORPHAN_WORKFLOW_HINT = '工作流-'; + +/** 跨域引用:目标里含 `/` 一律跳过(指向 raw/ outputs/ ../CLAUDE.md 等) */ +function isCrossDomainTarget(target: string): boolean { + return target.includes('/'); +} + +/** 模块级 regex 缓存(避免每次扫页面重编) */ +const FENCE_RE = /^\s*(```+|~~~+)/; +const WIKILINK_RE = /\[\[([^\]\n]+?)\]\]/g; +const SUMMARY_RE = /^## (摘要|Summary)\s*$/m; +const STAMP_RE = /^(?:>\s*)(最后更新|Last updated)\s*[::]\s*\d{4}-\d{2}-\d{2}/m; + +/** + * 去掉 fenced code block 与行内反引号,把内容切成「可见」段。 + * 两遍处理:第一遍按行跳过 fenced block,第二遍按字符去掉行内反引号片段。 + */ +function visibleSegments(content: string): string[] { + const lines = content.split('\n'); + const out: string[] = []; + let inFence = false; + let fenceMarker = ''; + + for (const line of lines) { + const fenceMatch = line.match(FENCE_RE); + if (fenceMatch) { + const marker = fenceMatch[1]!; + if (!inFence) { + inFence = true; + fenceMarker = marker; + continue; + } else if (line.trimStart().startsWith(fenceMarker)) { + inFence = false; + fenceMarker = ''; + continue; + } + } + if (inFence) continue; + out.push(line); + } + return out.map(stripInlineCode); +} + +/** + * 去掉行内反引号包裹的内容,返回「反引号外」的纯文本。 + * 不支持跨行的反引号对(wiki 写作里极少见;忽略即可)。 + */ +function stripInlineCode(line: string): string { + let result = ''; + let inTick = false; + for (const ch of line) { + if (ch === '`') { + inTick = !inTick; + continue; + } + if (!inTick) result += ch; + } + return result; +} + +/** + * 在可见段里找出所有 `[[目标]]`(可选 `[[目标|别名]]`、`[[目标#节锚]]` 等) + * 抽出原始 target 部分,不做归一化。 + */ +function extractWikilinkTargets(visibleText: string): string[] { + const out: string[] = []; + WIKILINK_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = WIKILINK_RE.exec(visibleText)) !== null) { + const raw = m[1]!; + const target = raw.split('|')[0]!.split('#')[0]!.trim(); + if (target) out.push(target); + } + return out; +} + +/** 页面是否带摘要:`## 摘要` 或 `## Summary` 之一 */ +function hasSummary(content: string): boolean { + return SUMMARY_RE.test(content); +} + +/** 页面是否带日期戳:`> 最后更新:YYYY-MM-DD` 或 `> Last updated:YYYY-MM-DD` */ +function hasStamp(content: string): boolean { + return STAMP_RE.test(content); +} + +/** 解析真正放 wiki 页的目录:支持 cwd=库根(子目录 wiki/)或 cwd=wiki/ */ +async function resolveWikiDir(targetDir: string): Promise { + const wikiSub = path.join(targetDir, 'wiki'); + const stat = await fs.stat(wikiSub).then(() => true, () => false); + return stat ? wikiSub : targetDir; +} + +/** 收集 wiki 页(返回 basename 列表,纯文件名) */ +async function listWikiPages(wikiDir: string): Promise { + const out: string[] = []; + let entries: string[]; + try { + entries = await fs.readdir(wikiDir); + } catch { + return out; + } + for (const name of entries) { + if (name.startsWith('.') || !name.endsWith('.md')) continue; + out.push(name); + } + out.sort(); + return out; +} + +/** 工作流页豁免:文件名含 `工作流-` */ +function isWorkflowPage(basename: string): boolean { + return basename.includes(ORPHAN_WORKFLOW_HINT); +} + +/** ORPHAN / LOWLINKS 共享的豁免判定 */ +function isExemptFromDensity(name: string): boolean { + return ORPHAN_EXEMPT_BASENAMES.has(name) || isWorkflowPage(name); +} + +/** 从环境变量或默认值解析 MIN_LINKS */ +function parseMinLinksFromEnv(): number { + const env = process.env['MIN_LINKS']; + if (env && /^\d+$/.test(env.trim())) { + const n = parseInt(env.trim(), 10); + if (n >= 0) return n; + } + return DEFAULT_MIN_LINKS; +} + +export interface LintOptions { + /** 链接密度阈值;缺省取环境变量 MIN_LINKS 或 DEFAULT_MIN_LINKS */ + minLinks?: number; +} + +export interface LintResult { + targetDir: string; + wikiDir: string; + lines: string[]; + /** 始终为 0(脚本永远 exit 0) */ + exitCode: 0; + counts: { + broken: number; + orphans: number; + lowlinks: number; + nosummary: number; + nostamp: number; + }; +} + +export async function lintWiki( + targetDir: string, + options: LintOptions = {}, +): Promise { + const wikiDir = await resolveWikiDir(targetDir); + const minLinks = options.minLinks ?? parseMinLinksFromEnv(); + + const pages = await listWikiPages(wikiDir); + + // 一次扫完所有页:raw content + 可见段 wikilink target + 去除跨域后的有效 target + const pageContents = new Map(); + const pageValidTargets = new Map(); + + for (const name of pages) { + const full = path.join(wikiDir, name); + const content = await fs.readFile(full, 'utf-8'); + pageContents.set(name, content); + const visible = visibleSegments(content).join('\n'); + const targets = extractWikilinkTargets(visible); + pageValidTargets.set( + name, + targets.filter((t) => !isCrossDomainTarget(t)), + ); + } + + const wikiPageSet = new Set(pages); + /** target 可带 .md 也不带,宽容匹配 */ + const existsOnDisk = (target: string): boolean => + wikiPageSet.has(target) || wikiPageSet.has(`${target}.md`); + + // BROKEN:target 不存在的引用关系图 + const brokenByTarget = new Map(); + for (const name of pages) { + for (const t of pageValidTargets.get(name) ?? []) { + if (!existsOnDisk(t)) { + const list = brokenByTarget.get(t) ?? []; + if (!list.includes(name)) list.push(name); + brokenByTarget.set(t, list); + } + } + } + + // ORPHAN:扫一次所有 target 收集 referencedStems,O(P+T) + const referencedStems = new Set(); + for (const targets of pageValidTargets.values()) { + for (const t of targets) { + referencedStems.add(t); + referencedStems.add(`${t}.md`); + } + } + const orphanPages = pages.filter((name) => { + if (isExemptFromDensity(name)) return false; + const stem = name.replace(/\.md$/, ''); + return !referencedStems.has(name) && !referencedStems.has(stem); + }); + + const noSummaryPages = pages.filter((n) => !hasSummary(pageContents.get(n) ?? '')); + const noStampPages = pages.filter((n) => !hasStamp(pageContents.get(n) ?? '')); + const lowLinksPages = pages.filter((n) => { + if (isExemptFromDensity(n)) return false; + return (pageValidTargets.get(n) ?? []).length < minLinks; + }); + + // 输出(顺序:BROKEN → ORPHAN → NOSUMMARY → NOSTAMP → LOWLINKS → SUMMARY) + const lines: string[] = []; + + const brokenKeys = Array.from(brokenByTarget.keys()).sort(); + for (const t of brokenKeys) { + const refs = brokenByTarget.get(t)!.slice().sort(); + lines.push(`BROKEN: ${t} (referenced by: ${refs.join(', ')})`); + } + for (const n of orphanPages) lines.push(`ORPHAN: ${n}`); + for (const n of noSummaryPages) lines.push(`NOSUMMARY: ${n}`); + for (const n of noStampPages) lines.push(`NOSTAMP: ${n}`); + for (const n of lowLinksPages) { + const count = (pageValidTargets.get(n) ?? []).length; + lines.push(`LOWLINKS: ${n} (${count} < ${minLinks})`); + } + + const counts = { + broken: brokenKeys.length, + orphans: orphanPages.length, + lowlinks: lowLinksPages.length, + nosummary: noSummaryPages.length, + nostamp: noStampPages.length, + }; + lines.push( + `SUMMARY: broken=${counts.broken} orphans=${counts.orphans} lowlinks=${counts.lowlinks} nosummary=${counts.nosummary} nostamp=${counts.nostamp}`, + ); + + return { + targetDir, + wikiDir, + lines, + exitCode: 0, + counts, + }; +} + +// ---------- CLI 入口 ---------- + +const isMain = (() => { + const arg1 = process.argv[1]; + if (!arg1) return false; + const self = fileURLToPath(import.meta.url); + return path.resolve(arg1) === self || path.resolve(arg1) === self.replace(/\.ts$/, '.js'); +})(); + +if (isMain) { + (async () => { + const targetDir = path.resolve(process.argv[2] ?? process.cwd()); + try { + const result = await lintWiki(targetDir); + for (const line of result.lines) console.log(line); + } catch (err: unknown) { + // 仅在「执行器自身崩溃」(比如 wiki 目录不可读)时非 0;lint 命中问题永远 exit 0 + console.error(`❌ lint 失败: ${getErrorMessage(err)}`); + process.exit(1); + } + })(); +} \ No newline at end of file diff --git a/test-case/test-issue-021.ts b/test-case/test-issue-021.ts new file mode 100644 index 0000000000000000000000000000000000000000..202061559df0d0470d26cef329494ed006f404ae --- /dev/null +++ b/test-case/test-issue-021.ts @@ -0,0 +1,493 @@ +/** + * test-case/test-issue-021.ts + * + * 对应 issue IK8MWU #21 karpathy-wiki-lint · 知识库健康检查(builtin skill) + * + * 运行: bun run test-case/test-issue-021.ts + * + * 测试方法(issue 原文): + * ① BundledSkillLoader 把该 skill 注册为 /karpathy-wiki-lint 且 description 非空 + * ② SKILL.md 契约:frontmatter 必有 name/description,正文有 5 项检查定义/触发词/修复决策表/收尾要求/不主动删除 + * ③ lint 执行器在 tmp 造 fixture wiki,断言 5 项检查的命中与豁免: + * - BROKEN:命中真实断链;跳过跨域(raw/...、outputs/...、../CLAUDE.md);跳过 fenced code block + * - ORPHAN:命中真 orphan;豁免 INDEX.md / log.md / 工作流页(内置-Skills.md 这类) + * - NOSUMMARY:命中缺 ## 摘要或 ## Summary;豁免带摘要的页 + * - NOSTAMP:命中缺日期戳(> 最后更新: / > Last updated:);豁免带戳的页 + * - LOWLINKS:< MIN_LINKS 出链时报告 + * ④ SUMMARY 行:机器可读汇总 broken=N orphans=N lowlinks=N nosummary=N nostamp=N,数字与逐条报告一致 + * ⑤ 退出码恒 0 + * ⑥ 调用位置无关:库根目录 vs wiki/ 子目录内运行,输出一致 + * ⑦ MIN_LINKS 环境变量覆盖生效 + * ⑧ listBundledSkills 能发现该 skill + * ⑨ dist 打包后,SKILL.md 与 lint.js 都在 dist/skills/bundled/karpathy-wiki-lint/ + */ + +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { BundledSkillLoader } from '../src/services/BundledSkillLoader.js'; +import { skillManager } from '../src/core/skillManager.js'; +import { lintWiki } from '../src/skills/bundled/karpathy-wiki-lint/lint.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); + +const SKILL_NAME = 'karpathy-wiki-lint'; + +// ---------- 极简测试 harness ---------- + +let passed = 0; +let failed = 0; +const failures: string[] = []; + +function assert(cond: unknown, msg: string): void { + if (cond) { + passed++; + console.log(` ✓ ${msg}`); + } else { + failed++; + failures.push(msg); + console.log(` ✗ ${msg}`); + } +} + +function section(name: string): void { + console.log(`\n── ${name} ──`); +} + +async function makeTmpDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'alice-test-021-')); +} + +async function exists(p: string): Promise { + return fs.stat(p).then(() => true, () => false); +} + +/** fixture 公用日期戳 */ +const STAMP = '2026-08-15'; + +/** 单一 page builder,根据 opts 拼装中英 / 摘要 / 戳 / 出链 */ +interface PageOpts { + title: string; + stamp?: string; + hasSummary?: boolean; + en?: boolean; + bodyNote?: string; + links: string[]; +} +function makePage(opts: PageOpts): string { + const en = !!opts.en; + const stampLine = opts.stamp + ? en ? `> Last updated:${opts.stamp}` : `> 最后更新:${opts.stamp}` + : ''; + const summaryHeader = en ? '## Summary' : '## 摘要'; + const contentHeader = en ? '## Content' : '## 内容'; + const summarySection = opts.hasSummary === false + ? [] + : [summaryHeader, '', opts.bodyNote ?? `${opts.title} 是一个测试页面。`, '']; + const linkLines = opts.links.map((l) => `- [[${l}]]`); + return [ + `# ${opts.title}`, + '', + stampLine, + '', + ...summarySection, + contentHeader, + '', + ...linkLines, + '', + ].join('\n'); +} + +/** 在 tmp 里造一份 wiki 目录并写入若干页 */ +async function setupWiki(pages: Record): Promise { + const root = await makeTmpDir(); + const wikiDir = path.join(root, 'wiki'); + await fs.mkdir(wikiDir, { recursive: true }); + for (const [name, body] of Object.entries(pages)) { + await fs.writeFile(path.join(wikiDir, name), body, 'utf-8'); + } + return root; +} + +// ---------- 用例 ①: BundledSkillLoader 注册 + skillManager 发现 ---------- + +async function testLoaderAndDiscovery(): Promise { + section('① BundledSkillLoader + listBundledSkills 发现 /karpathy-wiki-lint'); + + const loader = new BundledSkillLoader(null); + const commands = await loader.loadCommands(new AbortController().signal); + const cmd = commands.find((c) => c.name === SKILL_NAME); + assert(cmd !== undefined, `注册为 /${SKILL_NAME} 命令`); + assert( + typeof cmd?.description === 'string' && cmd.description.length > 0, + `description 非空 (实际 ${cmd?.description?.length ?? 0} 字符)`, + ); + + const skills = await skillManager.listBundledSkills(); + const skill = skills.find((s) => s.name === SKILL_NAME); + assert(skill !== undefined, 'skillManager.listBundledSkills 能发现该 skill'); + assert( + skill !== undefined && skill.body.includes(`name: ${SKILL_NAME}`), + 'SKILL.md body 含 frontmatter name', + ); +} + +// ---------- 用例 ②: SKILL.md 契约 ---------- + +async function testSkillMdContract(): Promise { + section('② SKILL.md 契约(frontmatter + 5 项检查定义 + 修复决策表 + 收尾 + 不删除)'); + + const skills = await skillManager.listBundledSkills(); + const skill = skills.find((s) => s.name === SKILL_NAME); + assert(skill !== undefined, 'SKILL_NAME 已加载'); + if (!skill) return; + + // frontmatter + assert( + skill.body.includes(`name: ${SKILL_NAME}`), + 'frontmatter 含 name: karpathy-wiki-lint', + ); + assert( + /description:\s*.+/.test(skill.body), + 'frontmatter 含 description 字段(非空)', + ); + + // 5 项检查关键词(中英混用都可,主要看五大类的核心词) + assert(skill.body.includes('BROKEN') || skill.body.includes('断链'), + '正文涉及断链检查(BROKEN / 断链)'); + assert(skill.body.includes('ORPHAN') || skill.body.includes('orphan') || skill.body.includes('孤岛'), + '正文涉及 orphan 检查'); + assert(skill.body.includes('NOSUMMARY') || skill.body.includes('摘要'), + '正文涉及 NOSUMMARY 检查'); + assert(skill.body.includes('NOSTAMP') || skill.body.includes('日期戳') || skill.body.includes('最后更新'), + '正文涉及 NOSTAMP 检查'); + assert(skill.body.includes('LOWLINKS') || skill.body.includes('链接密度') || skill.body.includes('MIN_LINKS'), + '正文涉及 LOWLINKS / MIN_LINKS'); + + // 触发词 + assert( + /体检\s*wiki|断链检查|lint\s*wiki|lint|wiki\s*lint/i.test(skill.body), + '正文含触发词(体检 wiki / 断链检查 / lint)', + ); + + // 修复决策表 / 收尾要求 / 不主动删除 + assert( + /修复|处理|决策|怎么修/.test(skill.body), + '正文含修复决策表(修复 / 处理 / 决策)', + ); + assert( + /重跑|归零|log\.md|log\|/.test(skill.body), + '正文含收尾要求(重跑归零 / 更新 log)', + ); + assert( + /不主动删除|不删除|永远不主动删除|不自动删除|不可逆/.test(skill.body), + '正文明确"LLM 永远不主动删除 wiki 页面"', + ); + + // 退出码恒 0(文档层面声明) + assert( + /退出码.{0,5}0|exit\s*0|process\.exit\(0\)|exit\s+code\s+0/.test(skill.body), + '正文声明退出码恒 0(或允许脚本永远 exit 0)', + ); + + // 调用位置无关 + assert( + /调用位置无关|位置无关|从.*wiki.*运行|从.*根.*运行|库根目录|wiki\/\s*目录/.test(skill.body), + '正文声明调用位置无关', + ); +} + +// ---------- 用例 ③: lint 执行器 fixture + 5 项命中 ---------- + +async function testLintFixture(): Promise { + section('③ fixture wiki:5 项检查命中 + 豁免'); + + // code-block 页:fenced + 行内反引号里都有伪链接,不应触发 BROKEN + const codeBlockPage = [ + '# codeblock', + '', + `> 最后更新:${STAMP}`, + '', + '## 摘要', + '', + '本页用代码块演示链接。', + '', + '## 示例', + '', + '```md', + '看看 [[不存在的-fenced-链接]] 长什么样', + '```', + '', + '行内反引号:`[[不存在的-inline-链接]]` 不应该被算断链。', + '', + '## 内容', + '', + '- [[alpha]]', + '- [[beta]]', + '- [[gamma]]', + '', + ].join('\n'); + + // cross-domain 页:含 raw/... / outputs/... / ../CLAUDE.md 跨域引用,不应触发 BROKEN + const crossPage = [ + '# crossdomain', + '', + `> 最后更新:${STAMP}`, + '', + '## 摘要', + '', + '本页演示跨域引用。', + '', + '## 内容', + '', + '- [[raw/某源文件]]', + '- [[outputs/某产物]]', + '- [[../CLAUDE.md]]', + '- [[alpha]]', + '- [[beta]]', + '- [[gamma]]', + '', + ].join('\n'); + + const root = await setupWiki({ + 'INDEX.md': makePage({ title: 'INDEX', stamp: STAMP, links: ['alpha', 'beta'] }), + 'log.md': makePage({ title: 'log', stamp: STAMP, links: ['INDEX'] }), + '工作流-ingest-query-lint.md': makePage({ title: '工作流', stamp: STAMP, links: ['INDEX'] }), + // alpha:引用 beta / delta / 真断链「不存在页」 + 'alpha.md': makePage({ title: 'alpha', stamp: STAMP, links: ['beta', 'delta', '不存在页'] }), + 'beta.md': makePage({ title: 'beta', stamp: STAMP, links: ['alpha', 'gamma', 'delta'] }), + 'gamma.md': makePage({ title: 'gamma', stamp: STAMP, links: ['alpha', 'beta', 'delta'] }), + 'delta.md': makePage({ title: 'delta', stamp: STAMP, links: ['alpha', 'beta', 'gamma'] }), + // orphan:真孤岛,出链 3 条 + 'orphan.md': makePage({ title: 'orphan', stamp: STAMP, links: ['alpha', 'beta', 'gamma'] }), + // nosummary:带戳但缺 ## 摘要 + 'nosummary.md': makePage({ title: 'nosummary', stamp: STAMP, hasSummary: false, links: ['alpha', 'beta', 'gamma'] }), + // nostamp:带摘要但缺戳 + 'nostamp.md': makePage({ title: 'nostamp', links: ['alpha', 'beta', 'gamma'] }), + // lowlinks:2 条出链 < MIN_LINKS=3 + 'lowlinks.md': makePage({ title: 'lowlinks', stamp: STAMP, links: ['alpha', 'beta'] }), + // en:英文戳 + Summary 写法,全部豁免 + 'en.md': makePage({ title: 'en', stamp: STAMP, en: true, links: ['alpha', 'beta', 'gamma'] }), + 'codeblock.md': codeBlockPage, + 'crossdomain.md': crossPage, + }); + + const result = await lintWiki(root); + + // BROKEN:命中"不存在页",不命中 codeblock 内的两个假链接、不命中 3 个跨域 + const brokenLines = result.lines.filter((l) => l.startsWith('BROKEN:')); + assert( + brokenLines.some((l) => l.includes('不存在页')), + `BROKEN 命中真实断链 "不存在页" (${brokenLines.length} 条 BROKEN)`, + ); + assert( + !brokenLines.some((l) => l.includes('不存在的-fenced-链接')), + 'fenced code block 内的链接不算 BROKEN', + ); + assert( + !brokenLines.some((l) => l.includes('不存在的-inline-链接')), + '行内反引号内的链接不算 BROKEN', + ); + assert( + !brokenLines.some((l) => l.includes('raw/') || l.includes('outputs/') || l.includes('../CLAUDE.md')), + '跨域引用(raw/outputs/../CLAUDE.md)不算 BROKEN', + ); + + // ORPHAN:命中 orphan.md,豁免 INDEX / log / workflow + const orphanLines = result.lines.filter((l) => l.startsWith('ORPHAN:')); + assert( + orphanLines.some((l) => l.includes('orphan.md')), + 'ORPHAN 命中真孤岛 orphan.md', + ); + assert( + !orphanLines.some((l) => l.includes('INDEX.md')), + 'ORPHAN 豁免 INDEX.md', + ); + assert( + !orphanLines.some((l) => l.includes('log.md')), + 'ORPHAN 豁免 log.md', + ); + assert( + !orphanLines.some((l) => l.includes('工作流-ingest-query-lint.md')), + 'ORPHAN 豁免工作流页', + ); + + // NOSUMMARY:命中 nosummary.md,豁免 en.md(英文 Summary) + const noSummaryLines = result.lines.filter((l) => l.startsWith('NOSUMMARY:')); + assert( + noSummaryLines.some((l) => l.includes('nosummary.md')), + 'NOSUMMARY 命中缺摘要的 nosummary.md', + ); + assert( + !noSummaryLines.some((l) => l.includes('en.md')), + 'NOSUMMARY 豁免带英文 Summary 的 en.md', + ); + + // NOSTAMP:命中 nostamp.md,豁免 en.md(英文 Last updated) + const noStampLines = result.lines.filter((l) => l.startsWith('NOSTAMP:')); + assert( + noStampLines.some((l) => l.includes('nostamp.md')), + 'NOSTAMP 命中缺戳的 nostamp.md', + ); + assert( + !noStampLines.some((l) => l.includes('en.md')), + 'NOSTAMP 豁免带英文 Last updated 的 en.md', + ); + + // LOWLINKS:命中 lowlinks.md(2 < 3),不命中 en.md / alpha.md 等 + const lowLinksLines = result.lines.filter((l) => l.startsWith('LOWLINKS:')); + assert( + lowLinksLines.some((l) => l.includes('lowlinks.md')), + 'LOWLINKS 命中出链数 < MIN_LINKS 的 lowlinks.md', + ); + + // SUMMARY 行存在 + 计数正确 + const summaryLine = result.lines.find((l) => l.startsWith('SUMMARY:')); + assert(summaryLine !== undefined, '输出末尾有 SUMMARY: 行'); + const brokenCount = brokenLines.length; + const orphanCount = orphanLines.length; + const noSummaryCount = noSummaryLines.length; + const noStampCount = noStampLines.length; + const lowLinksCount = lowLinksLines.length; + const expectedSummary = `SUMMARY: broken=${brokenCount} orphans=${orphanCount} lowlinks=${lowLinksCount} nosummary=${noSummaryCount} nostamp=${noStampCount}`; + assert( + summaryLine === expectedSummary, + `SUMMARY 行计数与逐条一致 (实际 "${summaryLine}" vs 期望 "${expectedSummary}")`, + ); + + // 退出码恒 0 + assert(result.exitCode === 0, `退出码恒 0 (实际 ${result.exitCode})`); +} + +// ---------- 用例 ④: 调用位置无关 ---------- + +async function testLocationInvariant(): Promise { + section('④ 调用位置无关:库根 vs wiki/ 子目录输出一致'); + + const root = await setupWiki({ + 'a.md': makePage({ title: 'a', stamp: STAMP, links: ['b', 'c', 'd'] }), + 'b.md': makePage({ title: 'b', stamp: STAMP, links: ['a', 'c', 'd'] }), + 'c.md': makePage({ title: 'c', stamp: STAMP, links: ['a', 'b', 'd'] }), + 'd.md': makePage({ title: 'd', links: ['a', 'b', 'c'] }), + }); + const wikiDir = path.join(root, 'wiki'); + + const fromRoot = await lintWiki(root); + const fromWiki = await lintWiki(wikiDir); + + assert( + JSON.stringify(fromRoot.lines) === JSON.stringify(fromWiki.lines), + '从库根 vs 从 wiki/ 子目录运行,lines 数组完全一致', + ); + assert(fromRoot.exitCode === 0 && fromWiki.exitCode === 0, '两处退出码均为 0'); +} + +// ---------- 用例 ⑤: MIN_LINKS 环境变量覆盖 ---------- + +async function testMinLinksOverride(): Promise { + section('⑤ MIN_LINKS 环境变量覆盖生效(MIN_LINKS=4)'); + + const root = await setupWiki({ + 'a.md': makePage({ title: 'a', stamp: STAMP, links: ['b', 'c', 'd'] }), + 'b.md': makePage({ title: 'b', stamp: STAMP, links: ['a', 'c', 'd'] }), + 'c.md': makePage({ title: 'c', stamp: STAMP, links: ['a', 'b', 'd'] }), + 'd.md': makePage({ title: 'd', stamp: STAMP, links: ['a', 'b', 'c'] }), + }); + + const resultDefault = await lintWiki(root); + const resultStrict = await lintWiki(root, { minLinks: 4 }); + + // 默认 MIN_LINKS=3 时,a/b/c/d 都是 3 条,不应报 LOWLINKS + const defaultLow = resultDefault.lines.filter((l) => l.startsWith('LOWLINKS:')); + assert(defaultLow.length === 0, `默认 MIN_LINKS=3 时 0 条 LOWLINKS (实际 ${defaultLow.length})`); + + // MIN_LINKS=4 时,a/b/c/d 都 < 4,应报 4 条 LOWLINKS + const strictLow = resultStrict.lines.filter((l) => l.startsWith('LOWLINKS:')); + assert(strictLow.length === 4, `MIN_LINKS=4 时 4 条 LOWLINKS (实际 ${strictLow.length})`); + assert( + strictLow.every((l) => l.includes('(3 < 4)')), + 'LOWLINKS 行格式带 (n < MIN_LINKS)', + ); +} + +// ---------- 用例 ⑥: 退出码恒 0(无论有没有问题) ---------- + +async function testExitCodeAlwaysZero(): Promise { + section('⑥ 退出码恒 0:即使整库满目疮痍也是 0'); + + // 一个页:断链 + 缺摘要 + 缺戳 + 0 出链 + 还是 orphan + const root = await setupWiki({ + 'broken-everything.md': '# broken-everything\n\n## 没有戳也没有摘要\n\n- [[不存在的目标]]\n', + }); + + const result = await lintWiki(root); + assert(result.exitCode === 0, '整库全坏也是退出码 0'); + const summary = result.lines.find((l) => l.startsWith('SUMMARY:')); + assert(summary !== undefined, '即使全坏也有 SUMMARY 行'); +} + +// ---------- 用例 ⑦: 打包断言(dist 资源齐全) ---------- + +async function testDistPackaging(): Promise { + section('⑦ 打包:dist 中 SKILL.md 与 lint.js 齐全'); + + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const res = spawnSync(npm, ['run', 'build'], { + cwd: REPO_ROOT, + encoding: 'utf-8', + timeout: 300_000, + env: { ...process.env, PATH: process.env['PATH'] }, + }); + assert( + res.status === 0, + `npm run build 成功 (stderr: ${(res.stderr ?? '').slice(-200)})`, + ); + + const bundledDist = path.join(REPO_ROOT, 'dist', 'skills', 'bundled', SKILL_NAME); + assert(await exists(path.join(bundledDist, 'SKILL.md')), 'dist 含 SKILL.md'); + assert(await exists(path.join(bundledDist, 'lint.js')), 'dist 含编译后的 lint.js'); + + // 生产路径自检:dist 里的 skillManager 能发现 bundled skill + const distManagerPath = path.join(REPO_ROOT, 'dist', 'core', 'skillManager.js'); + const { skillManager: distSkillManager } = await import(distManagerPath); + const distSkills = await distSkillManager.listBundledSkills(); + assert( + distSkills.some((s) => s.name === SKILL_NAME), + 'dist 产物能从 dist/skills/bundled 发现 karpathy-wiki-lint', + ); +} + +// ---------- 主入口 ---------- + +async function main(): Promise { + console.log('🧪 test-issue-021 — karpathy-wiki-lint bundled skill\n'); + + try { + await testLoaderAndDiscovery(); + await testSkillMdContract(); + await testLintFixture(); + await testLocationInvariant(); + await testMinLinksOverride(); + await testExitCodeAlwaysZero(); + await testDistPackaging(); + } catch (err) { + console.error('uncaught:', err); + failures.push('uncaught: ' + (err instanceof Error ? err.message : String(err))); + failed++; + } + + console.log(`\n────────────────────────────`); + console.log(`PASS: ${passed} FAIL: ${failed}`); + if (failed > 0) { + console.log('\n失败明细:'); + failures.forEach((m) => console.log(` - ${m}`)); + process.exit(1); + } else { + process.exit(0); + } +} + +void main(); \ No newline at end of file diff --git a/test-case/test-list.md b/test-case/test-list.md index b116bd53b8e1a06cf857b460c9aa6cf8a580ec12..93ce6ca63383a607acb1faff795cd9bae7b50b57 100644 --- a/test-case/test-list.md +++ b/test-case/test-list.md @@ -7,8 +7,8 @@ ## 全量回归 ```bash -# issue 回归套件(当前基线 168 断言) -for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || exit 1; done +# issue 回归套件(当前基线 213 断言) +for t in 001 002 003 004 005 012 019 021; do bun run test-case/test-issue-$t.ts || exit 1; done ``` ## 清单(按 issue 编号排序) @@ -24,6 +24,7 @@ for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || e | `test-issue-010.ts` | ripgrep 子进程替换 glob:`rg --json` NDJSON 解析、空 PATH 自动降级、ignore 列表对齐、CI 基准 | 工具性能(utils/ripgrepRunner、tools/builtin/searchFiles) | issue #10(IK8MWP)/ PR !11 | | `test-issue-019.ts` | karpathy-wiki-new bundled skill:SKILL.md 契约、scaffold 执行器、listBundledSkills、dist 打包 | 内置 skills(skills/bundled) | issue #19(IK8MWL)/ PR !7 | | `test-issue-012.ts` | token 预算接通 TUI:getUsage 边界、ChatStreamEvent.budget_update 类型联合、TokenBudgetBar 字符串、联调事件序列 | runtime/agent/tokenBudget → types/chatStream → UI/Footer | issue #12(IK8MWR)/ PR !8 | +| `test-issue-021.ts` | karpathy-wiki-lint bundled skill:5 项检查(BROKEN/ORPHAN/NOSUMMARY/NOSTAMP/LOWLINKS)命中与豁免、SUMMARY 计数、退出码恒 0、调用位置无关、MIN_LINKS 覆盖、SKILL.md 契约、dist 打包 | 内置 skills(skills/bundled) | issue #21(IK8MWU)/ PR !10 | | `test-model.ts` | 手动入口:模型连通性 + 速度检查(等价 `alice --test-model`);实现位于 `src/utils/testModel.ts` | 模型诊断(utils/testModel) | 历史 dev 脚本(无 PR);2026-08-15 修复为可运行薄壳 | | `test-tools.ts` | 手动入口:toolRegistry / builtinTools / ToolExecutor 冒烟 | 工具系统 | 历史 dev 脚本(无 PR) | | `test-function-calling.ts` | 手动入口:LLM function calling 端到端(需真实 API) | function calling | 历史 dev 脚本(无 PR) |