1355 lines
43 KiB
TypeScript
1355 lines
43 KiB
TypeScript
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<ObsidianValidateInput> {
|
||
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<string, string | string[]> {
|
||
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
|
||
const fields = new Map<string, string | string[]>();
|
||
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<string, unknown> | null {
|
||
if (includeCanvas === false) return null;
|
||
|
||
const canvas = readJsonFile<CanvasDocument>('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<T>(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<string>, 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<string, string[]>; full: Set<string> } {
|
||
const files = listFiles(workspaceRoot, new Set(['.base', '.canvas', '.md']));
|
||
const full = new Set<string>();
|
||
const basename = new Map<string, string[]>();
|
||
|
||
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<typeof buildFileIndex>): 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<CanvasDocument>(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<string>();
|
||
const edgeIds = new Set<string>();
|
||
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<string, unknown> {
|
||
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<FindingLevel, number> {
|
||
return findings.reduce<Record<FindingLevel, number>>(
|
||
(result, finding) => {
|
||
result[finding.level] += 1;
|
||
return result;
|
||
},
|
||
{ error: 0, info: 0, warning: 0 },
|
||
);
|
||
}
|
||
|
||
export function validateObsidianVault(input: ObsidianValidateInput = {}): Record<string, unknown> {
|
||
const options = normalizeInput(input);
|
||
const findings: ObsidianFinding[] = [];
|
||
|
||
for (const configFile of obsidianConfigFiles) {
|
||
readJsonFile<Record<string, unknown>>(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<string, unknown> {
|
||
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<T>(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<BookmarkSettings>('.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<Record<string, boolean>>('.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<ObsidianSettings>('.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<string, unknown> {
|
||
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,
|
||
};
|
||
}
|