diff --git a/README.md b/README.md index 0b0b13c..68cbc64 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - 静态检查 NapCat 设备身份护栏:确认 API 仍保留 QQNT 可见 hostname、实体 OUI 风格 MAC、QQNT `machine-info` 写入、持久化 runtime dir 和 `DB_TIMEZONE` 默认值。 - 生成 NapCatQQ 上游同步与 runtime 发布自动化入口:MCP/CLI 默认 dry-run,覆盖 upstream audit、sync candidate review、runtime readiness、remote dev handoff 和 NAS Codex bootstrap plan;upstream audit 在显式 `execute=true` 且 `useCodex=true` 时可运行 Codex,NAS Codex bootstrap 在显式 `execute=true` 时只返回目录准备、nvm 管理的 Node 22.14.0 / pnpm 10.28.2 / Codex CLI 安装计划,不复制 secrets、不写 systemd、不触发发布。 - 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。 -- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口。 +- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口;MCP 不可用时用 `pnpm run change-doc-sync -- --project ` 作为 CLI fallback。 - 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。 - 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、`.kt-workspace/db-sync` 转储、备份库和行数校验。 - 生成或执行部署观测:把 Jenkins build、`build.xml` SCM revision、日志尾部、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据;Jenkins 状态以日志尾部最终 `Finished:` 为准,日志存在但缺少最终态时不认定发布完成。 @@ -40,6 +40,7 @@ pnpm run self-test pnpm run obsidian-context -- --module ktWorkflow pnpm run obsidian-validate pnpm run obsidian-sync +pnpm run change-doc-sync -- --project root pnpm run workstream-closeout -- --title "发布闭环" --verification "Jenkins SUCCESS" --doc-sync "无需文档更新" --cleanup "cleanup-history dry-run deleted=0" --cleanup-final-deleted 0 --review "global-review findings=0" --superpowers-review "Superpowers reviewer completed; no Critical/Important findings" --problem "无新卡点" --solution "无新增稳定解法" pnpm run napcat-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync pnpm run napcat-sync-candidate-review diff --git a/package.json b/package.json index 97563c0..bf92ecb 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "description": "Reusable MCP workflow tools for the KT workspace.", "scripts": { "admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts", + "change-doc-sync": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --change-doc-sync", "cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history", "deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation", "global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review", diff --git a/src/core/cli.ts b/src/core/cli.ts index 76e10a6..d54b7b3 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -1,4 +1,5 @@ import type { + ChangeDocSyncInput, DeployObservationInput, GlobalCodeReviewInput, NapcatRemoteDevHandoffInput, @@ -35,6 +36,23 @@ export function parseGlobalReviewCliArgs( }; } +/** + * Parses the change documentation sync CLI mode into the same input shape as the MCP tool. + * @param argv - Full process argv including `--change-doc-sync` and optional project/file flags. + * @returns Project key plus explicitly supplied changed files; absent files make the tool infer git status. + */ +export function parseChangeDocSyncCliArgs(argv: string[]): ChangeDocSyncInput { + return { + changedFiles: readOptionList(argv, [ + '--changed-file', + '--changed-files', + '--file', + '--files', + ]), + project: readOption(argv, ['--project']), + }; +} + function readOption(argv: string[], names: string[]): string | undefined { for (const name of names) { const keyValue = argv.find((item) => item.startsWith(`${name}=`)); diff --git a/src/selfTest.ts b/src/selfTest.ts index db6b594..5600cd4 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import path from "node:path"; import { + parseChangeDocSyncCliArgs, parseNasCodexBootstrapCliArgs, parseDeployObservationCliArgs, parseGlobalReviewCliArgs, @@ -408,6 +409,9 @@ export async function runSelfTest(): Promise { if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) { throw new Error("NapCat remote handoff npm script self-check failed"); } + if (!packageJson.scripts?.["change-doc-sync"]) { + throw new Error("change-doc-sync npm script self-check failed"); + } const registeredByFakeServer: string[] = []; const fakeMcpServer = { /** @@ -2141,6 +2145,33 @@ export async function runSelfTest(): Promise { if ((obsidianValidation.summary as { errors?: number }).errors !== 0) { throw new Error("obsidian validation self-check failed"); } + const obsidianChecks = obsidianValidation.checks as { + base?: { requiredViews?: number; views?: number }; + canvas?: { fileNodeCount?: number; groupCount?: number; indexLinkCount?: number }; + moduleFrontmatter?: number; + nestedDocs?: number; + }; + if ( + !obsidianChecks.base || + obsidianChecks.base.views !== obsidianChecks.base.requiredViews + ) { + throw new Error("obsidian base view self-check failed"); + } + if (!obsidianChecks.canvas || obsidianChecks.canvas.groupCount !== 5) { + throw new Error("obsidian canvas lane self-check failed"); + } + if (obsidianChecks.canvas.fileNodeCount !== 0) { + throw new Error("obsidian canvas preview-node self-check failed"); + } + if (!obsidianChecks.canvas.indexLinkCount) { + throw new Error("obsidian canvas index-link self-check failed"); + } + if (!obsidianChecks.moduleFrontmatter) { + throw new Error("obsidian module frontmatter self-check failed"); + } + if (obsidianChecks.nestedDocs !== 0) { + throw new Error("obsidian nested docs guard self-check failed"); + } const cleanupCliParser = { defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"]) @@ -2190,6 +2221,22 @@ export async function runSelfTest(): Promise { ], project: "api", }); + const docSyncCli = parseChangeDocSyncCliArgs([ + "node", + "server", + "--change-doc-sync", + "--project", + "api", + "--file", + "src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts", + ]); + if ( + docSyncCli.project !== "api" || + docSyncCli.changedFiles?.[0] !== + "src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts" + ) { + throw new Error("doc sync CLI parser self-check failed"); + } const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{ path: string; }>; diff --git a/src/server.ts b/src/server.ts index d01a93d..cb764f9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { + parseChangeDocSyncCliArgs, parseNasCodexBootstrapCliArgs, parseDeployObservationCliArgs, parseGlobalReviewCliArgs, @@ -17,6 +18,7 @@ import { } from './core/cli.js'; import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js'; import { buildWorkstreamCloseout } from './tools/closeout.js'; +import { buildChangeDocSync } from './tools/docSync.js'; import { buildDeployObservation } from './tools/deployObservation.js'; import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js'; import { buildGlobalCodeReview } from './tools/review.js'; @@ -63,6 +65,14 @@ if (process.argv.includes('--cleanup-history')) { console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2)); } else if (process.argv.includes('--obsidian-sync')) { console.log(JSON.stringify(syncObsidianWorkflow(parseObsidianSyncCliArgs(process.argv)), null, 2)); +} else if (process.argv.includes('--change-doc-sync')) { + console.log( + JSON.stringify( + await buildChangeDocSync(parseChangeDocSyncCliArgs(process.argv)), + null, + 2, + ), + ); } else if (process.argv.includes('--deploy-observation')) { console.log( JSON.stringify( diff --git a/src/tools/docSync.ts b/src/tools/docSync.ts index 410310d..5193f07 100644 --- a/src/tools/docSync.ts +++ b/src/tools/docSync.ts @@ -34,9 +34,9 @@ const docSyncRules: DocSyncRule[] = [ reason: 'KtTable 组件或迁移规则变更,需要同步专项模块和 skill。', }, { - docs: ['Vue/kt-blog-web/README.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'skills/kt-blog-argon-workflow/SKILL.md'], + docs: ['Vue/kt-blog-web/README.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'skills/kt-component-specialist/SKILL.md'], match: /Vue\/kt-blog-web\/|blog|argon/i, - reason: 'Blog/Argon 能力变更,需要同步博客 README、模块页和专项 skill。', + reason: 'Blog/Argon 能力变更,需要同步博客 README、模块页和根内专项 skill;已安装 Codex skill 仅作为执行时加载入口。', }, { docs: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'docs/obsidian/modules/KT 模块 - Plugins.md'], @@ -44,12 +44,25 @@ const docSyncRules: DocSyncRule[] = [ reason: 'fnOS K8s 插件变更,需要同步插件 README/AGENTS 和 Obsidian 模块。', }, { - docs: ['mcp/ktWorkflow/README.md', 'AGENTS.md', 'TASKS.md', 'docs/kt-testing-workflows.md', 'docs/obsidian/KT 工作流工程化.md'], + docs: [ + 'mcp/ktWorkflow/README.md', + 'AGENTS.md', + 'TASKS.md', + 'docs/kt-stabilized-runtime-rules.md', + 'docs/kt-testing-workflows.md', + 'docs/obsidian/KT 工作流工程化.md', + ], match: /mcp\/ktWorkflow|src\/tools\/|src\/registerTools|src\/core|workflow|cleanup|review|selfTest/i, reason: 'ktWorkflow 或自动化规则变更,需要同步 README、根规则、测试流程和当前任务记录。', }, { - docs: ['AGENTS.md', 'SKILLS.md', 'docs/kt-context-semantics.md', 'docs/obsidian/modules/KT 模块 - Root Governance.md'], + docs: [ + 'AGENTS.md', + 'SKILLS.md', + 'docs/kt-context-semantics.md', + 'docs/kt-stabilized-runtime-rules.md', + 'docs/obsidian/modules/KT 模块 - Root Governance.md', + ], match: /AGENTS\.md|SKILLS\.md|skills\/.*\/SKILL\.md|docs\/kt-context-semantics\.md/, reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。', }, @@ -118,6 +131,7 @@ export async function buildChangeDocSync(input: ChangeDocSyncInput = {}): Promis return { changedFiles, commands: [ + 'pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project ', 'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query ', 'pnpm --dir mcp/ktWorkflow run obsidian-validate', 'pnpm --dir mcp/ktWorkflow run global-review', diff --git a/src/tools/obsidian.ts b/src/tools/obsidian.ts index e9bee8e..c0a0927 100644 --- a/src/tools/obsidian.ts +++ b/src/tools/obsidian.ts @@ -21,6 +21,8 @@ interface CanvasNode { file?: string; height?: number; id?: string; + label?: string; + text?: string; type?: string; width?: number; x?: number; @@ -44,7 +46,9 @@ interface ObsidianSettings { } interface BookmarkItem { + items?: BookmarkItem[]; path?: string; + title?: string; type?: string; } @@ -173,11 +177,33 @@ const expectedIgnoredRoots = [ '.git/', '.kt-workspace/', 'Node/', - 'Plugins/', 'Vue/', + 'Plugins/', 'mcp/', + 'GitHub/', + 'Other/', + 'workspace-assets/', ]; +const requiredBaseViewNames = [ + '开工路由', + '模块-仓库矩阵', + '工作流入口', + '验证与收尾', + '运行态与远程排查', + '文档归口', +]; + +const requiredCanvasGroupLabels = [ + '00 当前上下文', + '10 路由索引', + '20 模块仓库', + '30 工作流执行', + '40 验证与固化', +]; + +const canvasTextNodeMaxChars = 180; + const defaultContextPaths = [ 'docs/obsidian/KT 知识图谱.md', 'docs/obsidian/modules/KT 模块索引.md', @@ -297,6 +323,12 @@ function readTextFile(relativePath: string): string { return readFileSync(resolveInsideRoot(relativePath), 'utf8'); } +/** + * Resolves the Obsidian display title from frontmatter, the first H1, or the file name. + * @param content - Markdown file content read from the KT vault. + * @param fallback - Workspace-relative file path used when title metadata is absent. + * @returns The title shown in Obsidian context output. + */ function getMarkdownTitle(content: string, fallback: string): string { return ( content.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') || @@ -305,6 +337,11 @@ function getMarkdownTitle(content: string, fallback: string): string { ); } +/** + * Extracts tags from the YAML frontmatter subset used by KT Obsidian notes. + * @param content - Markdown content that may start with frontmatter. + * @returns Tag values without additional normalization. + */ function getMarkdownTags(content: string): string[] { const frontmatter = content.match(/^---\n([\s\S]*?)\n---/); if (!frontmatter) return []; @@ -316,6 +353,59 @@ function getMarkdownTags(content: string): string[] { .filter((tag): tag is string => Boolean(tag)); } +/** + * Removes matching quote wrappers from a YAML-like scalar used by KT Obsidian metadata. + * @param value - Raw scalar value from frontmatter or a Base view line. + * @returns The unquoted scalar value. + */ +function stripYamlQuotes(value: string): string { + return value.trim().replace(/^["']|["']$/g, ''); +} + +/** + * Parses the small frontmatter subset that KT uses for Obsidian routing fields. + * @param content - Markdown content with optional leading frontmatter. + * @returns Frontmatter fields as scalar strings or string arrays. + */ +function parseFrontmatterFields(content: string): Map { + const frontmatter = content.match(/^---\n([\s\S]*?)\n---/); + const fields = new Map(); + if (!frontmatter?.[1]) return fields; + + const lines = frontmatter[1].split(/\r?\n/); + let activeArrayKey: string | null = null; + + for (const line of lines) { + const arrayItem = line.match(/^\s+-\s+(.+)$/); + if (arrayItem?.[1] && activeArrayKey) { + const current = fields.get(activeArrayKey); + const values = Array.isArray(current) ? current : []; + values.push(stripYamlQuotes(arrayItem[1])); + fields.set(activeArrayKey, values); + continue; + } + + const keyValue = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/); + if (!keyValue?.[1]) { + activeArrayKey = null; + continue; + } + + const key = keyValue[1]; + const rawValue = keyValue[2]?.trim() || ''; + if (!rawValue) { + fields.set(key, []); + activeArrayKey = key; + continue; + } + + fields.set(key, stripYamlQuotes(rawValue)); + activeArrayKey = null; + } + + return fields; +} + function extractWikiLinks(content: string): string[] { return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)] .map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim()) @@ -380,6 +470,27 @@ function buildContextTerms(input: ObsidianContextInput): string[] { .filter(Boolean); } +/** + * Extracts linked index targets from text nodes so Canvas can stay readable without file previews. + * @param nodes - Parsed JSON Canvas nodes from the KT relationship graph. + * @returns Text-node link summaries used by obsidian-context output. + */ +function extractCanvasIndexLinks(nodes: CanvasNode[]): Array<{ color?: string; id?: string; links: string[] }> { + return nodes + .filter((node) => node.type === 'text' && node.text) + .map((node) => ({ + color: node.color, + id: node.id, + links: extractWikiLinks(node.text || ''), + })) + .filter((node) => node.links.length > 0); +} + +/** + * Reads a lightweight Canvas summary for context routing without embedding full note previews. + * @param includeCanvas - Explicit false disables the Canvas summary for callers that need smaller output. + * @returns Canvas node/edge counts plus indexed wikilink targets, or null when disabled/unreadable. + */ function readCanvasSummary(includeCanvas: boolean | undefined): Record | null { if (includeCanvas === false) return null; @@ -398,6 +509,7 @@ function readCanvasSummary(includeCanvas: boolean | undefined): Record(canvasPath, findings); - if (!canvas) return { edgeCount: 0, nodeCount: 0 }; + if (!canvas) return { edgeCount: 0, fileNodeCount: 0, groupCount: 0, indexLinkCount: 0, nodeCount: 0 }; const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : []; const edges = Array.isArray(canvas.edges) ? canvas.edges : []; + const fileNodeCount = nodes.filter((node) => node.type === 'file').length; + const indexLinkCount = extractCanvasIndexLinks(nodes).reduce((count, node) => count + node.links.length, 0); const nodeIds = new Set(); const edgeIds = new Set(); + const groupLabels = new Set(nodes.filter((node) => node.type === 'group').map((node) => node.label).filter(Boolean)); for (const node of nodes) { if (!node.id) { @@ -708,6 +832,26 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed } nodeIds.add(node.id); + if (node.type === 'file') { + findings.push({ + check: 'canvas-node', + level: 'error', + message: 'KT 关系图谱必须使用文本索引节点,不能使用会嵌入全文预览的 file 节点。', + path: canvasPath, + target: node.id, + }); + } + + if (node.type === 'text' && (node.text?.length || 0) > canvasTextNodeMaxChars) { + findings.push({ + check: 'canvas-node', + level: 'error', + message: 'Canvas 文本节点过长,可能在 Obsidian 中出现内部滚动条。', + path: canvasPath, + target: `${node.id || 'unknown'}:${node.text?.length || 0}`, + }); + } + if (node.type === 'file' && node.file && !existsSync(resolveInsideRoot(node.file))) { findings.push({ check: 'canvas-file', @@ -752,6 +896,18 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed } } + requiredCanvasGroupLabels + .filter((label) => !groupLabels.has(label)) + .forEach((label) => { + findings.push({ + check: 'canvas-group', + level: 'error', + message: 'Canvas 缺少工程任务流泳道。', + path: canvasPath, + target: label, + }); + }); + if (checkLayout) { const visibleNodes = nodes.filter((node) => node.type !== 'group'); for (let index = 0; index < visibleNodes.length; index += 1) { @@ -771,9 +927,57 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed } } - return { edgeCount: edges.length, nodeCount: nodes.length }; + return { + edgeCount: edges.length, + fileNodeCount, + groupCount: groupLabels.size, + indexLinkCount, + nodeCount: nodes.length, + }; } +/** + * Extracts Obsidian Base view names from the repository-owned YAML subset without adding a parser dependency. + * @param content - Text content of `KT 文档矩阵.base`. + * @returns View names declared below the `views:` section. + */ +function extractBaseViewNames(content: string): string[] { + const names: string[] = []; + const lines = content.split(/\r?\n/); + let inViews = false; + let currentView = false; + + for (const line of lines) { + if (/^views:\s*$/.test(line)) { + inViews = true; + currentView = false; + continue; + } + if (inViews && /^[A-Za-z_][A-Za-z0-9_-]*:\s*/.test(line)) { + break; + } + if (!inViews) continue; + + if (/^\s*-\s+type:\s+/.test(line)) { + currentView = true; + continue; + } + + const name = line.match(/^\s+name:\s+(.+)$/)?.[1]; + if (currentView && name) { + names.push(stripYamlQuotes(name)); + currentView = false; + } + } + + return names; +} + +/** + * Validates the engineering Base shape used by the KT Obsidian document matrix. + * @param findings - Shared finding accumulator for the current Obsidian validation run. + * @returns Base section and view summary for command output. + */ function validateBaseFile(findings: ObsidianFinding[]): Record { const basePath = 'docs/obsidian/KT 文档矩阵.base'; if (!existsSync(resolveInsideRoot(basePath))) { @@ -799,13 +1003,94 @@ function validateBaseFile(findings: ObsidianFinding[]): Record } } + const viewNames = extractBaseViewNames(content); + requiredBaseViewNames + .filter((viewName) => !viewNames.includes(viewName)) + .forEach((viewName) => { + findings.push({ + check: 'base-view', + level: 'error', + message: 'Base 缺少工程控制视图。', + path: basePath, + target: viewName, + }); + }); + return { hasCardsView: content.includes('type: cards'), hasTableView: content.includes('type: table'), - views: [...content.matchAll(/^\s*-\s+type:\s+/gm)].length, + requiredViews: requiredBaseViewNames.length, + viewNames, + views: viewNames.length, }; } +/** + * Validates that module MOC files carry routing metadata for Base views and `obsidian-context`. + * @param findings - Shared finding accumulator for the current Obsidian validation run. + * @returns Number of module notes inspected. + */ +function validateModuleFrontmatter(findings: ObsidianFinding[]): number { + const moduleFiles = listFiles(resolveInsideRoot('docs/obsidian/modules'), new Set(['.md'])) + .map(relativeToWorkspace) + .filter((file) => file !== 'docs/obsidian/modules/KT 模块索引.md'); + const routeFields = ['rule_anchors', 'code_anchors', 'workflow_entries', 'validation_entries']; + + for (const file of moduleFiles) { + const fields = parseFrontmatterFields(readTextFile(file)); + for (const requiredField of ['kt_type', 'module']) { + const value = fields.get(requiredField); + if (!value || (Array.isArray(value) && value.length === 0)) { + findings.push({ + check: 'module-frontmatter', + level: 'error', + message: '模块页缺少 Obsidian 工程路由 frontmatter 字段。', + path: file, + target: requiredField, + }); + } + } + + const hasRouteField = routeFields.some((field) => { + const value = fields.get(field); + return Array.isArray(value) ? value.length > 0 : Boolean(value); + }); + if (!hasRouteField) { + findings.push({ + check: 'module-frontmatter', + level: 'error', + message: '模块页没有 rule/code/workflow/validation 任一工程锚点。', + path: file, + target: routeFields.join('|'), + }); + } + } + + return moduleFiles.length; +} + +/** + * Rejects accidental nested Obsidian documents created by resolving wiki links from inside `docs/obsidian`. + * @param findings - Shared finding accumulator for the current Obsidian validation run. + * @returns Number of nested files found, or 1 when only the invalid directory exists. + */ +function validateNoNestedObsidianDocs(findings: ObsidianFinding[]): number { + const nestedRoot = 'docs/obsidian/docs'; + const absoluteRoot = resolveInsideRoot(nestedRoot); + if (!existsSync(absoluteRoot)) return 0; + + const nestedFiles = listFiles(absoluteRoot, new Set(['.base', '.canvas', '.json', '.md'])) + .map(relativeToWorkspace); + findings.push({ + check: 'obsidian-nested-docs', + level: 'error', + message: 'docs/obsidian 下出现误嵌套 docs 目录;Obsidian 索引只能放在 docs/obsidian 根层和 modules 层。', + path: nestedRoot, + target: nestedFiles.join(', ') || nestedRoot, + }); + return nestedFiles.length || 1; +} + function validateStaleReferences(findings: ObsidianFinding[]): number { const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml'])); let hitCount = 0; @@ -855,6 +1140,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record const base = validateBaseFile(findings); const canvas = validateCanvas(findings, options.checkLayout); + const moduleFrontmatterCount = validateModuleFrontmatter(findings); + const nestedDocsCount = validateNoNestedObsidianDocs(findings); const wikiLinkCount = validateWikiLinks(findings); const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0; const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0; @@ -867,6 +1154,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record canvas, configFiles: obsidianConfigFiles.length, markdownLinks: markdownLinkCount, + moduleFrontmatter: moduleFrontmatterCount, + nestedDocs: nestedDocsCount, staleReferences: staleReferenceCount, wikiLinks: wikiLinkCount, }, @@ -954,6 +1243,18 @@ function readOptionalJson(relativePath: string): T | null { } } +/** + * Recursively collects file paths from Obsidian bookmarks, including grouped bookmark sections. + * @param items - Bookmark items from `.obsidian/bookmarks.json`. + * @returns File paths referenced by bookmark entries. + */ +function collectBookmarkPaths(items: BookmarkItem[]): string[] { + return items.flatMap((item) => [ + ...(item.path ? [item.path] : []), + ...(item.items ? collectBookmarkPaths(item.items) : []), + ]); +} + function buildSyncFindings(): ObsidianFinding[] { const findings: ObsidianFinding[] = []; const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file))); @@ -968,7 +1269,7 @@ function buildSyncFindings(): ObsidianFinding[] { }); const bookmarks = readOptionalJson('.obsidian/bookmarks.json'); - const bookmarkPaths = new Set((bookmarks?.items || []).map((item) => item.path).filter(Boolean)); + const bookmarkPaths = new Set(collectBookmarkPaths(bookmarks?.items || [])); requiredBookmarkPaths .filter((file) => !bookmarkPaths.has(file)) .forEach((file) => {