import { existsSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import type { ObsidianContextInput, ObsidianSyncInput, ObsidianValidateInput, ProjectAlias } from '../types.js'; import { projectAliases, projectLabels } from '../core/constants.js'; import { detectRepoType } from '../core/project.js'; import { resolveInsideRoot, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; type FindingLevel = 'error' | 'info' | 'warning'; interface ObsidianFinding { check: string; level: FindingLevel; message: string; path?: string; target?: string; } interface CanvasNode { color?: string; file?: string; height?: number; id?: string; label?: string; text?: string; type?: string; width?: number; x?: number; y?: number; } interface CanvasEdge { fromNode?: string; id?: string; toNode?: string; } interface CanvasDocument { edges?: CanvasEdge[]; nodes?: CanvasNode[]; } interface ObsidianSettings { attachmentFolderPath?: string; userIgnoreFilters?: string[]; } interface BookmarkItem { items?: BookmarkItem[]; path?: string; title?: string; type?: string; } interface BookmarkSettings { items?: BookmarkItem[]; } interface ObsidianContextDocument { excerpt?: string; links: string[]; path: string; tags: string[]; title: string; } interface CodeAnchorCatalogItem { alias: ProjectAlias; keyFiles: string[]; keywords: string[]; ruleFiles: string[]; sourceRoots: string[]; } interface CodeAnchorResult { alias: ProjectAlias; exists: boolean; inspectTool: { input: { includeGit: boolean; project: ProjectAlias; }; name: 'kt_inspect_project'; }; keyFiles: Array<{ exists: boolean; path: string; }>; label: string; projectPath: string; repoType: string; riskTool: { input: { changedFiles: string[]; project: ProjectAlias; }; name: 'kt_risk_scan'; }; score: number; ruleFiles: Array<{ exists: boolean; path: string; }>; sourceRoots: Array<{ exists: boolean; path: string; }>; verificationTool: { input: { changeType: 'general' | 'mcp'; project: ProjectAlias; }; name: 'kt_suggest_verification'; }; } const obsidianConfigFiles = [ '.obsidian/app.json', '.obsidian/appearance.json', '.obsidian/bookmarks.json', '.obsidian/community-plugins.json', '.obsidian/core-plugins.json', '.obsidian/graph.json', '.obsidian/templates.json', ]; const expectedObsidianFiles = [ 'docs/obsidian/KT 知识图谱.md', 'docs/obsidian/KT 项目关系.md', 'docs/obsidian/KT 业务能力关系.md', 'docs/obsidian/KT 发布运维关系.md', 'docs/obsidian/KT 工作流关系.md', 'docs/obsidian/KT Obsidian 工程化.md', 'docs/obsidian/KT 工作流工程化.md', 'docs/obsidian/KT 文档矩阵.md', 'docs/obsidian/KT 文档矩阵.base', 'docs/obsidian/KT 关系图谱.canvas', 'docs/obsidian/modules/KT 模块索引.md', 'docs/obsidian/modules/KT 模块 - Admin.md', 'docs/obsidian/modules/KT 模块 - API Backend.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'docs/obsidian/modules/KT 模块 - CI CD NAS.md', 'docs/obsidian/modules/KT 模块 - Data Observability.md', 'docs/obsidian/modules/KT 模块 - KtTable.md', 'docs/obsidian/modules/KT 模块 - Plugins.md', 'docs/obsidian/modules/KT 模块 - Public Web Playground.md', 'docs/obsidian/modules/KT 模块 - QQBot BangDream FFLogs.md', 'docs/obsidian/modules/KT 模块 - Root Governance.md', 'docs/obsidian/modules/KT 模块 - ktWorkflow Skills.md', ]; const requiredBookmarkPaths = [ 'AGENTS.md', 'TASKS.md', 'SKILLS.md', 'docs/obsidian/KT 知识图谱.md', 'docs/obsidian/modules/KT 模块索引.md', 'docs/obsidian/KT 文档矩阵.md', 'docs/obsidian/KT 关系图谱.canvas', 'docs/obsidian/KT 文档矩阵.base', 'docs/kt-testing-workflows.md', ]; const requiredCorePlugins = [ 'backlink', 'bases', 'bookmarks', 'canvas', 'command-palette', 'global-search', 'graph', 'outgoing-link', 'templates', ]; const expectedIgnoredRoots = [ '.git/', '.kt-workspace/', 'Node/', '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', 'docs/obsidian/KT 文档矩阵.md', 'docs/obsidian/KT 工作流关系.md', ]; const codeAnchorCatalog: CodeAnchorCatalogItem[] = [ { alias: 'root', keyFiles: ['AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-workspace-structure.md', 'docs/kt-context-semantics.md'], keywords: ['root', 'governance', 'agents', 'skills', 'tasks', 'obsidian', '上下文', '根目录', '治理', '图谱'], ruleFiles: ['AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-context-semantics.md', 'docs/kt-workspace-structure.md'], sourceRoots: ['docs', 'skills', 'ci'], }, { alias: 'mcp', keyFiles: ['mcp/ktWorkflow/README.md', 'mcp/ktWorkflow/src/server.ts', 'mcp/ktWorkflow/src/registerTools.ts', 'mcp/ktWorkflow/src/tools'], keywords: ['mcp', 'ktworkflow', 'workflow', 'skill', 'tools', '工作流', '校验', '上下文工具'], ruleFiles: ['AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-testing-workflows.md', 'mcp/ktWorkflow/README.md'], sourceRoots: ['mcp/ktWorkflow/src', 'mcp/ktWorkflow/scripts'], }, { alias: 'api', keyFiles: ['Node/kt-template-online-api/README.md', 'Node/kt-template-online-api/API.md', 'Node/kt-template-online-api/src/qqbot', 'Node/kt-template-online-api/src/admin'], keywords: ['api', 'backend', 'nestjs', 'qqbot', 'bangdream', 'fflogs', 'napcat', 'systemlog', '后端', '接口', '日志'], ruleFiles: ['AGENTS.md', 'Node/kt-template-online-api/README.md', 'Node/kt-template-online-api/API.md', 'docs/kt-testing-workflows.md', 'docs/qqbot-nas-runtime.md'], sourceRoots: ['Node/kt-template-online-api/src', 'Node/kt-template-online-api/test'], }, { alias: 'admin', keyFiles: ['Vue/kt-template-admin/README.md', 'Vue/kt-template-admin/skills/SKILL.md', 'Vue/kt-template-admin/apps/web-antdv-next/src'], keywords: ['admin', 'vben', 'kttable', 'profile', 'system', '后台', '表格', '个人中心', '系统管理'], ruleFiles: ['AGENTS.md', 'Vue/kt-template-admin/README.md', 'Vue/kt-template-admin/skills/SKILL.md', 'docs/kt-testing-workflows.md'], sourceRoots: ['Vue/kt-template-admin/apps/web-antdv-next/src'], }, { alias: 'blog', keyFiles: ['Vue/kt-blog-web/README.md', 'Vue/kt-blog-web/src'], keywords: ['blog', 'argon', 'wordpress', '博客', '主题'], ruleFiles: ['AGENTS.md', 'Vue/kt-blog-web/README.md', 'skills/kt-component-specialist/SKILL.md', 'docs/kt-testing-workflows.md'], sourceRoots: ['Vue/kt-blog-web/src'], }, { alias: 'web', keyFiles: ['Vue/kt-template-online-web/README.md', 'Vue/kt-template-online-web/src'], keywords: ['web', 'public', 'online', '前台', '展示'], ruleFiles: ['AGENTS.md', 'Vue/kt-template-online-web/README.md', 'docs/kt-testing-workflows.md'], sourceRoots: ['Vue/kt-template-online-web/src'], }, { alias: 'playground', keyFiles: ['Vue/kt-template-online-playground/README.md', 'Vue/kt-template-online-playground/src'], keywords: ['playground', 'repl', 'component', '组件', '预览', '保存'], ruleFiles: ['AGENTS.md', 'Vue/kt-template-online-playground/README.md', 'docs/kt-testing-workflows.md'], sourceRoots: ['Vue/kt-template-online-playground/src'], }, { alias: 'knife4j', keyFiles: ['Plugins/knife4j-swagger-vue3/README.md', 'Plugins/knife4j-swagger-vue3/src'], keywords: ['knife4j', 'swagger', 'openapi', '插件'], ruleFiles: ['AGENTS.md', 'Plugins/knife4j-swagger-vue3/AGENTS.md', 'Plugins/knife4j-swagger-vue3/README.md', 'docs/kt-testing-workflows.md'], sourceRoots: ['Plugins/knife4j-swagger-vue3/src'], }, { alias: 'fnosK8s', keyFiles: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'Plugins/fnos-k8s-dashboard-fpk/apps/kt-k8s-dashboard'], keywords: ['fnos', 'k8s', 'kubernetes', 'fpk', 'nas', 'jenkins', 'caddy', '飞牛', '插件', '部署'], ruleFiles: ['AGENTS.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'Plugins/fnos-k8s-dashboard-fpk/README.md', 'docs/fnos-docker-jenkins-k8s-standard.md', 'docs/tencent-cloud-gateway-runtime.md'], sourceRoots: ['Plugins/fnos-k8s-dashboard-fpk/apps/kt-k8s-dashboard'], }, ]; const staleReferencePatterns = [ 'docs/next-session-context-019e2f8f.md', 'next-session-context-019e2f8f', 'docs/fnos-docker-jenkins-k8s-technical-solution.md', 'fnos-docker-jenkins-k8s-technical-solution', 'yd.frp-bag.com', '45122', ]; const allowedStaleReferenceHints = [ 'Do not use stale historical', '不要使用旧入口', '旧 FRP', 'historical FRP', '历史 FRP', '旧文档清理', ]; const ignoredDirectoryNames = new Set([ '.cache', '.git', '.kt-workspace', '.next', '.pnpm', '.turbo', '.vite', 'build', 'coverage', 'dist', 'node_modules', 'target', ]); function normalizeInput(input: ObsidianValidateInput = {}): Required { return { checkLayout: input.checkLayout !== false, failOnWarnings: input.failOnWarnings === true, includeMarkdownLinks: input.includeMarkdownLinks !== false, includeStaleReferences: input.includeStaleReferences !== false, }; } 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, '') || content.match(/^#\s+(.+)$/m)?.[1]?.trim() || path.basename(fallback, path.extname(fallback)) ); } /** * 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 []; const tagsBlock = frontmatter[1]?.match(/^tags:\n((?:\s+-\s+.+\n?)+)/m)?.[1] || ''; return tagsBlock .split(/\r?\n/) .map((line) => line.match(/^\s+-\s+(.+)$/)?.[1]?.trim()) .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()) .filter((value): value is string => Boolean(value)); } function normalizeSearchValue(value: string): string { return value.trim().toLowerCase(); } function buildExcerpt(content: string, terms: string[]): string { const body = content.replace(/^---\n[\s\S]*?\n---\n?/, ''); const lines = body .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('---')); const matchedLine = lines.find((line) => { const loweredLine = normalizeSearchValue(line); return terms.some((term) => loweredLine.includes(term)); }); return (matchedLine || lines[0] || '').slice(0, 220); } function readContextDocument(relativePath: string, includeContent: boolean, terms: string[] = []): ObsidianContextDocument { const content = readTextFile(relativePath); const document: ObsidianContextDocument = { links: extractWikiLinks(content), path: relativePath, tags: getMarkdownTags(content), title: getMarkdownTitle(content, relativePath), }; if (includeContent) { document.excerpt = buildExcerpt(content, terms); } return document; } function listObsidianMarkdownPaths(): string[] { return [ ...listFiles(resolveInsideRoot('docs/obsidian'), new Set(['.md'])).map(relativeToWorkspace), 'AGENTS.md', 'SKILLS.md', 'TASKS.md', ].filter((file) => existsSync(resolveInsideRoot(file))); } function scoreContextPath(relativePath: string, terms: string[]): number { if (terms.length === 0) return defaultContextPaths.includes(relativePath) ? 10 : 0; const content = readTextFile(relativePath); const haystack = normalizeSearchValue(`${relativePath}\n${content}`); return terms.reduce((score, term) => score + (haystack.includes(term) ? 1 : 0), 0); } function buildContextTerms(input: ObsidianContextInput): string[] { return [input.module, input.query] .filter((value): value is string => Boolean(value?.trim())) .flatMap((value) => value.split(/[,\s]+/)) .map(normalizeSearchValue) .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; const canvas = readJsonFile('docs/obsidian/KT 关系图谱.canvas', []); if (!canvas) return null; const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : []; const fileNodes = nodes .filter((node) => node.type === 'file' && node.file) .map((node) => ({ color: node.color, file: node.file, id: node.id, })); return { edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0, fileNodes, indexLinks: extractCanvasIndexLinks(nodes), nodes: nodes.length, }; } function scoreCodeAnchor(anchor: CodeAnchorCatalogItem, terms: string[]): number { if (terms.length === 0) return anchor.alias === 'root' || anchor.alias === 'mcp' ? 1 : 0; const haystack = normalizeSearchValue([ anchor.alias, projectLabels[anchor.alias], ...anchor.keywords, ...anchor.keyFiles, ...anchor.sourceRoots, ].join('\n')); return terms.reduce((score, term) => score + (haystack.includes(term) ? 1 : 0), 0); } function buildFileAnchor(relativePath: string): { exists: boolean; path: string } { return { exists: existsSync(resolveInsideRoot(relativePath)), path: relativePath, }; } function buildRuleAnchors(codeAnchors: CodeAnchorResult[]): Array<{ exists: boolean; path: string }> { const rulePaths = new Set([ 'AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-testing-workflows.md', ...codeAnchors.flatMap((anchor) => anchor.ruleFiles.map((file) => file.path)), ]); return [...rulePaths].map(buildFileAnchor); } function buildCodeAnchors(terms: string[], maxAnchors = 5): CodeAnchorResult[] { return codeAnchorCatalog .map((anchor) => ({ anchor, score: scoreCodeAnchor(anchor, terms), })) .filter((item) => item.score > 0) .sort((left, right) => { if (right.score !== left.score) return right.score - left.score; return left.anchor.alias.localeCompare(right.anchor.alias); }) .slice(0, maxAnchors) .map(({ anchor, score }) => { const project = resolveProject(anchor.alias); return { alias: anchor.alias, exists: existsSync(project.path), inspectTool: { input: { includeGit: true, project: anchor.alias, }, name: 'kt_inspect_project', }, keyFiles: anchor.keyFiles.map((file) => ({ exists: existsSync(resolveInsideRoot(file)), path: file, })), label: projectLabels[anchor.alias], projectPath: project.relativePath, repoType: existsSync(project.path) ? detectRepoType(project.path) : 'missing', riskTool: { input: { changedFiles: [], project: anchor.alias, }, name: 'kt_risk_scan', }, score, ruleFiles: anchor.ruleFiles.map(buildFileAnchor), sourceRoots: anchor.sourceRoots.map((file) => ({ exists: existsSync(resolveInsideRoot(file)), path: file, })), verificationTool: { input: { changeType: anchor.alias === 'mcp' ? 'mcp' : 'general', project: anchor.alias, }, name: 'kt_suggest_verification', }, }; }); } function readJsonFile(relativePath: string, findings: ObsidianFinding[]): T | null { if (!existsSync(resolveInsideRoot(relativePath))) { findings.push({ check: 'json', level: 'error', message: '文件不存在。', path: relativePath, }); return null; } try { return JSON.parse(readTextFile(relativePath)) as T; } catch (error) { findings.push({ check: 'json', level: 'error', message: error instanceof Error ? error.message : 'JSON 解析失败。', path: relativePath, }); return null; } } function listFiles(absoluteRoot: string, extensions: Set, output: string[] = []): string[] { if (!existsSync(absoluteRoot)) return output; for (const entry of readdirSync(absoluteRoot, { withFileTypes: true })) { if (entry.isDirectory()) { if (!ignoredDirectoryNames.has(entry.name)) { listFiles(path.join(absoluteRoot, entry.name), extensions, output); } continue; } if (entry.isFile() && extensions.has(path.extname(entry.name).toLowerCase())) { output.push(path.join(absoluteRoot, entry.name)); } } return output; } function relativeToWorkspace(filePath: string): string { return toPosix(path.relative(workspaceRoot, filePath)); } function stripKnownExtension(value: string): string { return value.replace(/\.(base|canvas|md)$/i, ''); } function buildFileIndex(): { basename: Map; full: Set } { const files = listFiles(workspaceRoot, new Set(['.base', '.canvas', '.md'])); const full = new Set(); const basename = new Map(); for (const file of files) { const relativePath = relativeToWorkspace(file); const fullKey = stripKnownExtension(relativePath); const baseKey = stripKnownExtension(path.basename(relativePath)); full.add(fullKey); full.add(relativePath); basename.set(baseKey, [...(basename.get(baseKey) || []), relativePath]); } return { basename, full }; } function isWikiLinkResolved(target: string, fileIndex: ReturnType): boolean { const normalizedTarget = stripKnownExtension(target.replaceAll('\\', '/')); if (fileIndex.full.has(normalizedTarget) || fileIndex.full.has(target)) return true; if (normalizedTarget.includes('/')) return false; return (fileIndex.basename.get(normalizedTarget) || []).length === 1; } function getObsidianMarkdownFiles(): string[] { return [ ...listFiles(resolveInsideRoot('docs/obsidian'), new Set(['.md'])), ...listFiles(resolveInsideRoot('.obsidian/templates'), new Set(['.md'])), ]; } function validateWikiLinks(findings: ObsidianFinding[]): number { const fileIndex = buildFileIndex(); let linkCount = 0; for (const absolutePath of getObsidianMarkdownFiles()) { const relativePath = relativeToWorkspace(absolutePath); const content = readFileSync(absolutePath, 'utf8'); const matches = content.matchAll(/\[\[([^\]\n]+)\]\]/g); for (const match of matches) { linkCount += 1; const rawTarget = match[1]?.split('|')[0]?.split('#')[0]?.trim() || ''; if (!rawTarget) continue; if (!isWikiLinkResolved(rawTarget, fileIndex)) { findings.push({ check: 'wikilink', level: 'error', message: '内部链接无法解析到工作区文件。', path: relativePath, target: rawTarget, }); } } } return linkCount; } function isExternalMarkdownTarget(target: string): boolean { return /^(https?:|mailto:|data:|obsidian:)/i.test(target); } function decodeLinkTarget(target: string): string { try { return decodeURIComponent(target); } catch { return target; } } function validateMarkdownLinks(findings: ObsidianFinding[]): number { let linkCount = 0; for (const absolutePath of getObsidianMarkdownFiles()) { const relativePath = relativeToWorkspace(absolutePath); const content = readFileSync(absolutePath, 'utf8'); const matches = content.matchAll(/!?\[[^\]\n]*\]\(([^)\n]+)\)/g); for (const match of matches) { const rawTarget = match[1]?.trim().replace(/^<|>$/g, '') || ''; const targetWithoutAnchor = decodeLinkTarget(rawTarget.split('#')[0]?.trim() || ''); if (!targetWithoutAnchor || targetWithoutAnchor.startsWith('#') || isExternalMarkdownTarget(targetWithoutAnchor)) { continue; } linkCount += 1; const absoluteTarget = path.resolve(path.dirname(absolutePath), targetWithoutAnchor); const relativeTarget = path.relative(workspaceRoot, absoluteTarget); if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) { findings.push({ check: 'markdown-link', level: 'error', message: 'Markdown 相对链接指向工作区外。', path: relativePath, target: rawTarget, }); continue; } if (!existsSync(absoluteTarget)) { findings.push({ check: 'markdown-link', level: 'error', message: 'Markdown 相对链接目标不存在。', path: relativePath, target: rawTarget, }); } } } return linkCount; } function hasNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } function rectanglesOverlap(a: CanvasNode, b: CanvasNode): boolean { if (!hasNumber(a.x) || !hasNumber(a.y) || !hasNumber(a.width) || !hasNumber(a.height)) return false; if (!hasNumber(b.x) || !hasNumber(b.y) || !hasNumber(b.width) || !hasNumber(b.height)) return false; return ( a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y ); } /** * Validates JSON Canvas IDs, file targets, edge references, lane groups, and optional visual overlap. * @param findings - Shared finding accumulator for the current Obsidian validation run. * @param checkLayout - Whether non-group node overlap should be reported as a warning. * @returns Basic Canvas counts for command output summaries. */ function validateCanvas( findings: ObsidianFinding[], checkLayout: boolean, ): { edgeCount: number; fileNodeCount: number; groupCount: number; indexLinkCount: number; nodeCount: number } { const canvasPath = 'docs/obsidian/KT 关系图谱.canvas'; const canvas = readJsonFile(canvasPath, findings); 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) { findings.push({ check: 'canvas-node', level: 'error', message: 'Canvas 节点缺少 id。', path: canvasPath, }); continue; } if (nodeIds.has(node.id)) { findings.push({ check: 'canvas-node', level: 'error', message: 'Canvas 节点 id 重复。', path: canvasPath, target: node.id, }); } 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', level: 'error', message: 'Canvas 文件节点目标不存在。', path: canvasPath, target: node.file, }); } } for (const edge of edges) { if (!edge.id) { findings.push({ check: 'canvas-edge', level: 'error', message: 'Canvas 边缺少 id。', path: canvasPath, }); continue; } if (edgeIds.has(edge.id)) { findings.push({ check: 'canvas-edge', level: 'error', message: 'Canvas 边 id 重复。', path: canvasPath, target: edge.id, }); } edgeIds.add(edge.id); if (!edge.fromNode || !nodeIds.has(edge.fromNode) || !edge.toNode || !nodeIds.has(edge.toNode)) { findings.push({ check: 'canvas-edge', level: 'error', message: 'Canvas 边引用了不存在的节点。', path: canvasPath, target: edge.id, }); } } 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) { for (let nextIndex = index + 1; nextIndex < visibleNodes.length; nextIndex += 1) { const current = visibleNodes[index]; const next = visibleNodes[nextIndex]; if (current && next && rectanglesOverlap(current, next)) { findings.push({ check: 'canvas-layout', level: 'warning', message: 'Canvas 非分组节点存在重叠,可能影响图谱可读性。', path: canvasPath, target: `${current.id || 'unknown'} <-> ${next.id || 'unknown'}`, }); } } } } 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))) { findings.push({ check: 'base', level: 'error', message: 'Base 文件不存在。', path: basePath, }); return { views: 0 }; } const content = readTextFile(basePath); const requiredTokens = ['filters:', 'properties:', 'views:', 'type: table', 'type: cards']; for (const token of requiredTokens) { if (!content.includes(token)) { findings.push({ check: 'base', level: 'error', message: `Base 文件缺少必要结构:${token}`, path: basePath, }); } } 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'), 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; for (const absolutePath of scanFiles) { const relativePath = relativeToWorkspace(absolutePath); if (relativePath === 'mcp/ktWorkflow/src/tools/obsidian.ts') continue; const lines = readFileSync(absolutePath, 'utf8').split(/\r?\n/); lines.forEach((line, index) => { const matchedPattern = staleReferencePatterns.find((pattern) => line.includes(pattern)); if (!matchedPattern) return; hitCount += 1; const allowed = allowedStaleReferenceHints.some((hint) => line.includes(hint)); findings.push({ check: 'stale-reference', level: allowed ? 'info' : 'warning', message: allowed ? '旧引用只作为禁用规则或清理记录保留。' : '发现旧入口或已删除文档引用,请确认是否还需要保留。', path: `${relativePath}:${index + 1}`, target: matchedPattern, }); }); } return hitCount; } function countByLevel(findings: ObsidianFinding[]): Record { return findings.reduce>( (result, finding) => { result[finding.level] += 1; return result; }, { error: 0, info: 0, warning: 0 }, ); } export function validateObsidianVault(input: ObsidianValidateInput = {}): Record { const options = normalizeInput(input); const findings: ObsidianFinding[] = []; for (const configFile of obsidianConfigFiles) { readJsonFile>(configFile, findings); } 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; const levels = countByLevel(findings); const ok = levels.error === 0 && (!options.failOnWarnings || levels.warning === 0); return { checks: { base, canvas, configFiles: obsidianConfigFiles.length, markdownLinks: markdownLinkCount, moduleFrontmatter: moduleFrontmatterCount, nestedDocs: nestedDocsCount, staleReferences: staleReferenceCount, wikiLinks: wikiLinkCount, }, findings, ok, options, summary: { errors: levels.error, infos: levels.info, warnings: levels.warning, }, }; } export function readObsidianContext(input: ObsidianContextInput = {}): Record { const maxDocuments = Math.min(Math.max(input.maxDocuments || 8, 1), 30); const terms = buildContextTerms(input); const codeAnchors = buildCodeAnchors(terms, Math.min(maxDocuments, 8)); const ruleAnchors = buildRuleAnchors(codeAnchors); const includeContent = input.includeContent !== false; const allPaths = Array.from(new Set([...defaultContextPaths, ...listObsidianMarkdownPaths()])); const scoredPaths = allPaths .map((relativePath) => ({ relativePath, score: scoreContextPath(relativePath, terms), })) .filter((item) => defaultContextPaths.includes(item.relativePath) || item.score > 0) .sort((left, right) => { if (right.score !== left.score) return right.score - left.score; return left.relativePath.localeCompare(right.relativePath, 'zh-Hans-CN'); }); const selectedPaths = scoredPaths.slice(0, maxDocuments).map((item) => item.relativePath); const documents = selectedPaths.map((relativePath) => readContextDocument(relativePath, includeContent, terms)); return { canvas: readCanvasSummary(input.includeCanvas), codeAnchors, commands: [ 'pnpm --dir mcp/ktWorkflow run obsidian-context', 'pnpm --dir mcp/ktWorkflow run obsidian-validate', ], documents, ok: true, route: { defaultIndex: defaultContextPaths, module: input.module?.trim() || null, query: input.query?.trim() || null, terms, }, ruleAnchors, summary: { allIndexedDocuments: allPaths.length, codeAnchors: codeAnchors.length, ruleAnchors: ruleAnchors.length, returnedDocuments: documents.length, }, workflowAnchors: [ { input: { includeFullDocs: false, taskRecordCount: 3, }, name: 'kt_read_context', purpose: '读取当前任务记录、项目清单和根目录规则,补齐 Obsidian 文档索引之外的运行上下文。', }, ...codeAnchors.map((anchor) => ({ input: { includeGit: true, project: anchor.alias, }, name: 'kt_inspect_project', purpose: `锚定 ${anchor.label} 的仓库状态、包管理器、Node 和 env。`, })), ], }; } function readOptionalJson(relativePath: string): T | null { if (!existsSync(resolveInsideRoot(relativePath))) return null; try { return JSON.parse(readTextFile(relativePath)) as T; } catch { return 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))); missingFiles.forEach((file) => { findings.push({ check: 'expected-file', level: 'error', message: 'Obsidian 工作流入口文件缺失。', path: file, }); }); const bookmarks = readOptionalJson('.obsidian/bookmarks.json'); const bookmarkPaths = new Set(collectBookmarkPaths(bookmarks?.items || [])); requiredBookmarkPaths .filter((file) => !bookmarkPaths.has(file)) .forEach((file) => { findings.push({ check: 'bookmarks', level: 'warning', message: '常用入口没有加入 Obsidian 书签。', path: '.obsidian/bookmarks.json', target: file, }); }); const corePlugins = readOptionalJson>('.obsidian/core-plugins.json') || {}; requiredCorePlugins .filter((pluginName) => corePlugins[pluginName] !== true) .forEach((pluginName) => { findings.push({ check: 'core-plugins', level: 'warning', message: '推荐启用的 Obsidian 核心插件没有开启。', path: '.obsidian/core-plugins.json', target: pluginName, }); }); const appSettings = readOptionalJson('.obsidian/app.json'); const ignoredRoots = new Set(appSettings?.userIgnoreFilters || []); expectedIgnoredRoots .filter((root) => !ignoredRoots.has(root)) .forEach((root) => { findings.push({ check: 'app-ignore', level: 'warning', message: '源码或运行态目录没有加入 Obsidian 忽略列表,可能污染关系图。', path: '.obsidian/app.json', target: root, }); }); if (appSettings?.attachmentFolderPath !== 'docs/assets/obsidian') { findings.push({ check: 'attachments', level: 'warning', message: '附件目录建议固定到 docs/assets/obsidian。', path: '.obsidian/app.json', target: appSettings?.attachmentFolderPath || '', }); } return findings; } export function syncObsidianWorkflow(input: ObsidianSyncInput = {}): Record { const includeValidation = input.includeValidation !== false; const syncFindings = buildSyncFindings(); const validation = includeValidation ? validateObsidianVault(input) : null; const validationFindings = (validation?.findings || []) as ObsidianFinding[]; const findings = [...syncFindings, ...validationFindings]; const levels = countByLevel(findings); const ok = levels.error === 0 && (input.failOnWarnings !== true || levels.warning === 0); return { commands: [ 'pnpm --dir mcp/ktWorkflow run obsidian-validate', 'pnpm --dir mcp/ktWorkflow run obsidian-sync', 'pnpm --dir mcp/ktWorkflow run self-test', ], findings, nextActions: findings.length === 0 ? ['Obsidian 图谱、配置和工作流入口已连通;后续文档改动后复跑 obsidian-validate。'] : ['先修复 error;warning 确认后可进入 TASKS.md 或 docs/obsidian 记录。'], ok, summary: { errors: levels.error, expectedFiles: expectedObsidianFiles.length, infos: levels.info, warnings: levels.warning, }, validation, }; }