feat: 补全文档同步和Obsidian校验

This commit is contained in:
sunlei 2026-06-26 14:56:27 +08:00
parent 114eef3195
commit 1ad2baf37f
7 changed files with 403 additions and 11 deletions

View File

@ -18,7 +18,7 @@
- 静态检查 NapCat 设备身份护栏:确认 API 仍保留 QQNT 可见 hostname、实体 OUI 风格 MAC、QQNT `machine-info` 写入、持久化 runtime dir 和 `DB_TIMEZONE` 默认值。 - 静态检查 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 planupstream audit 在显式 `execute=true``useCodex=true` 时可运行 CodexNAS Codex bootstrap 在显式 `execute=true` 时只返回目录准备、nvm 管理的 Node 22.14.0 / pnpm 10.28.2 / Codex CLI 安装计划,不复制 secrets、不写 systemd、不触发发布。 - 生成 NapCatQQ 上游同步与 runtime 发布自动化入口MCP/CLI 默认 dry-run覆盖 upstream audit、sync candidate review、runtime readiness、remote dev handoff 和 NAS Codex bootstrap planupstream audit 在显式 `execute=true``useCodex=true` 时可运行 CodexNAS 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 <project>` 作为 CLI fallback
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。 - 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、`.kt-workspace/db-sync` 转储、备份库和行数校验。 - 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 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:` 为准,日志存在但缺少最终态时不认定发布完成。 - 生成或执行部署观测:把 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-context -- --module ktWorkflow
pnpm run obsidian-validate pnpm run obsidian-validate
pnpm run obsidian-sync 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 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-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync
pnpm run napcat-sync-candidate-review pnpm run napcat-sync-candidate-review

View File

@ -6,6 +6,7 @@
"description": "Reusable MCP workflow tools for the KT workspace.", "description": "Reusable MCP workflow tools for the KT workspace.",
"scripts": { "scripts": {
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts", "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", "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", "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", "global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review",

View File

@ -1,4 +1,5 @@
import type { import type {
ChangeDocSyncInput,
DeployObservationInput, DeployObservationInput,
GlobalCodeReviewInput, GlobalCodeReviewInput,
NapcatRemoteDevHandoffInput, 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 { function readOption(argv: string[], names: string[]): string | undefined {
for (const name of names) { for (const name of names) {
const keyValue = argv.find((item) => item.startsWith(`${name}=`)); const keyValue = argv.find((item) => item.startsWith(`${name}=`));

View File

@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
import path from "node:path"; import path from "node:path";
import { import {
parseChangeDocSyncCliArgs,
parseNasCodexBootstrapCliArgs, parseNasCodexBootstrapCliArgs,
parseDeployObservationCliArgs, parseDeployObservationCliArgs,
parseGlobalReviewCliArgs, parseGlobalReviewCliArgs,
@ -408,6 +409,9 @@ export async function runSelfTest(): Promise<void> {
if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) { if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) {
throw new Error("NapCat remote handoff npm script self-check failed"); 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 registeredByFakeServer: string[] = [];
const fakeMcpServer = { const fakeMcpServer = {
/** /**
@ -2141,6 +2145,33 @@ export async function runSelfTest(): Promise<void> {
if ((obsidianValidation.summary as { errors?: number }).errors !== 0) { if ((obsidianValidation.summary as { errors?: number }).errors !== 0) {
throw new Error("obsidian validation self-check failed"); 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 = { const cleanupCliParser = {
defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"]) defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"])
@ -2190,6 +2221,22 @@ export async function runSelfTest(): Promise<void> {
], ],
project: "api", 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<{ const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{
path: string; path: string;
}>; }>;

View File

@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { import {
parseChangeDocSyncCliArgs,
parseNasCodexBootstrapCliArgs, parseNasCodexBootstrapCliArgs,
parseDeployObservationCliArgs, parseDeployObservationCliArgs,
parseGlobalReviewCliArgs, parseGlobalReviewCliArgs,
@ -17,6 +18,7 @@ import {
} from './core/cli.js'; } from './core/cli.js';
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js'; import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
import { buildWorkstreamCloseout } from './tools/closeout.js'; import { buildWorkstreamCloseout } from './tools/closeout.js';
import { buildChangeDocSync } from './tools/docSync.js';
import { buildDeployObservation } from './tools/deployObservation.js'; import { buildDeployObservation } from './tools/deployObservation.js';
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js'; import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
import { buildGlobalCodeReview } from './tools/review.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)); console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2));
} else if (process.argv.includes('--obsidian-sync')) { } else if (process.argv.includes('--obsidian-sync')) {
console.log(JSON.stringify(syncObsidianWorkflow(parseObsidianSyncCliArgs(process.argv)), null, 2)); 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')) { } else if (process.argv.includes('--deploy-observation')) {
console.log( console.log(
JSON.stringify( JSON.stringify(

View File

@ -34,9 +34,9 @@ const docSyncRules: DocSyncRule[] = [
reason: 'KtTable 组件或迁移规则变更,需要同步专项模块和 skill。', 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, 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'], 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 模块。', 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, match: /mcp\/ktWorkflow|src\/tools\/|src\/registerTools|src\/core|workflow|cleanup|review|selfTest/i,
reason: 'ktWorkflow 或自动化规则变更,需要同步 README、根规则、测试流程和当前任务记录。', 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/, match: /AGENTS\.md|SKILLS\.md|skills\/.*\/SKILL\.md|docs\/kt-context-semantics\.md/,
reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。', reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。',
}, },
@ -118,6 +131,7 @@ export async function buildChangeDocSync(input: ChangeDocSyncInput = {}): Promis
return { return {
changedFiles, changedFiles,
commands: [ commands: [
'pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project <project>',
'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query <module>', 'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query <module>',
'pnpm --dir mcp/ktWorkflow run obsidian-validate', 'pnpm --dir mcp/ktWorkflow run obsidian-validate',
'pnpm --dir mcp/ktWorkflow run global-review', 'pnpm --dir mcp/ktWorkflow run global-review',

View File

@ -21,6 +21,8 @@ interface CanvasNode {
file?: string; file?: string;
height?: number; height?: number;
id?: string; id?: string;
label?: string;
text?: string;
type?: string; type?: string;
width?: number; width?: number;
x?: number; x?: number;
@ -44,7 +46,9 @@ interface ObsidianSettings {
} }
interface BookmarkItem { interface BookmarkItem {
items?: BookmarkItem[];
path?: string; path?: string;
title?: string;
type?: string; type?: string;
} }
@ -173,11 +177,33 @@ const expectedIgnoredRoots = [
'.git/', '.git/',
'.kt-workspace/', '.kt-workspace/',
'Node/', 'Node/',
'Plugins/',
'Vue/', 'Vue/',
'Plugins/',
'mcp/', 'mcp/',
'GitHub/',
'Other/',
'workspace-assets/',
]; ];
const requiredBaseViewNames = [
'开工路由',
'模块-仓库矩阵',
'工作流入口',
'验证与收尾',
'运行态与远程排查',
'文档归口',
];
const requiredCanvasGroupLabels = [
'00 当前上下文',
'10 路由索引',
'20 模块仓库',
'30 工作流执行',
'40 验证与固化',
];
const canvasTextNodeMaxChars = 180;
const defaultContextPaths = [ const defaultContextPaths = [
'docs/obsidian/KT 知识图谱.md', 'docs/obsidian/KT 知识图谱.md',
'docs/obsidian/modules/KT 模块索引.md', 'docs/obsidian/modules/KT 模块索引.md',
@ -297,6 +323,12 @@ function readTextFile(relativePath: string): string {
return readFileSync(resolveInsideRoot(relativePath), 'utf8'); 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 { function getMarkdownTitle(content: string, fallback: string): string {
return ( return (
content.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') || 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[] { function getMarkdownTags(content: string): string[] {
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/); const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatter) return []; if (!frontmatter) return [];
@ -316,6 +353,59 @@ function getMarkdownTags(content: string): string[] {
.filter((tag): tag is string => Boolean(tag)); .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[] { function extractWikiLinks(content: string): string[] {
return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)] return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)]
.map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim()) .map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim())
@ -380,6 +470,27 @@ function buildContextTerms(input: ObsidianContextInput): string[] {
.filter(Boolean); .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 { function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, unknown> | null {
if (includeCanvas === false) return null; if (includeCanvas === false) return null;
@ -398,6 +509,7 @@ function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, u
return { return {
edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0, edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0,
fileNodes, fileNodes,
indexLinks: extractCanvasIndexLinks(nodes),
nodes: nodes.length, nodes: nodes.length,
}; };
} }
@ -676,15 +788,27 @@ function rectanglesOverlap(a: CanvasNode, b: CanvasNode): boolean {
); );
} }
function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { edgeCount: number; nodeCount: number } { /**
* 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 canvasPath = 'docs/obsidian/KT 关系图谱.canvas';
const canvas = readJsonFile<CanvasDocument>(canvasPath, findings); const canvas = readJsonFile<CanvasDocument>(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 nodes = Array.isArray(canvas.nodes) ? canvas.nodes : [];
const edges = Array.isArray(canvas.edges) ? canvas.edges : []; 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 nodeIds = new Set<string>();
const edgeIds = 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) { for (const node of nodes) {
if (!node.id) { if (!node.id) {
@ -708,6 +832,26 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
} }
nodeIds.add(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))) { if (node.type === 'file' && node.file && !existsSync(resolveInsideRoot(node.file))) {
findings.push({ findings.push({
check: 'canvas-file', 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) { if (checkLayout) {
const visibleNodes = nodes.filter((node) => node.type !== 'group'); const visibleNodes = nodes.filter((node) => node.type !== 'group');
for (let index = 0; index < visibleNodes.length; index += 1) { 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<string, unknown> { function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown> {
const basePath = 'docs/obsidian/KT 文档矩阵.base'; const basePath = 'docs/obsidian/KT 文档矩阵.base';
if (!existsSync(resolveInsideRoot(basePath))) { if (!existsSync(resolveInsideRoot(basePath))) {
@ -799,13 +1003,94 @@ function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown>
} }
} }
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 { return {
hasCardsView: content.includes('type: cards'), hasCardsView: content.includes('type: cards'),
hasTableView: content.includes('type: table'), 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 { function validateStaleReferences(findings: ObsidianFinding[]): number {
const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml'])); const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml']));
let hitCount = 0; let hitCount = 0;
@ -855,6 +1140,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
const base = validateBaseFile(findings); const base = validateBaseFile(findings);
const canvas = validateCanvas(findings, options.checkLayout); const canvas = validateCanvas(findings, options.checkLayout);
const moduleFrontmatterCount = validateModuleFrontmatter(findings);
const nestedDocsCount = validateNoNestedObsidianDocs(findings);
const wikiLinkCount = validateWikiLinks(findings); const wikiLinkCount = validateWikiLinks(findings);
const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0; const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0;
const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0; const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0;
@ -867,6 +1154,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
canvas, canvas,
configFiles: obsidianConfigFiles.length, configFiles: obsidianConfigFiles.length,
markdownLinks: markdownLinkCount, markdownLinks: markdownLinkCount,
moduleFrontmatter: moduleFrontmatterCount,
nestedDocs: nestedDocsCount,
staleReferences: staleReferenceCount, staleReferences: staleReferenceCount,
wikiLinks: wikiLinkCount, wikiLinks: wikiLinkCount,
}, },
@ -954,6 +1243,18 @@ function readOptionalJson<T>(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[] { function buildSyncFindings(): ObsidianFinding[] {
const findings: ObsidianFinding[] = []; const findings: ObsidianFinding[] = [];
const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file))); const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file)));
@ -968,7 +1269,7 @@ function buildSyncFindings(): ObsidianFinding[] {
}); });
const bookmarks = readOptionalJson<BookmarkSettings>('.obsidian/bookmarks.json'); const bookmarks = readOptionalJson<BookmarkSettings>('.obsidian/bookmarks.json');
const bookmarkPaths = new Set((bookmarks?.items || []).map((item) => item.path).filter(Boolean)); const bookmarkPaths = new Set(collectBookmarkPaths(bookmarks?.items || []));
requiredBookmarkPaths requiredBookmarkPaths
.filter((file) => !bookmarkPaths.has(file)) .filter((file) => !bookmarkPaths.has(file))
.forEach((file) => { .forEach((file) => {