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` 默认值。
- 生成 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` 转储、备份库和行数校验。
- 生成或执行部署观测:把 Jenkins build、`build.xml` SCM revision、日志尾部、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据Jenkins 状态以日志尾部最终 `Finished:` 为准,日志存在但缺少最终态时不认定发布完成。
@ -40,6 +40,7 @@ pnpm run self-test
pnpm run obsidian-context -- --module ktWorkflow
pnpm run obsidian-validate
pnpm run obsidian-sync
pnpm run change-doc-sync -- --project root
pnpm run workstream-closeout -- --title "发布闭环" --verification "Jenkins SUCCESS" --doc-sync "无需文档更新" --cleanup "cleanup-history dry-run deleted=0" --cleanup-final-deleted 0 --review "global-review findings=0" --superpowers-review "Superpowers reviewer completed; no Critical/Important findings" --problem "无新卡点" --solution "无新增稳定解法"
pnpm run napcat-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync
pnpm run napcat-sync-candidate-review

View File

@ -6,6 +6,7 @@
"description": "Reusable MCP workflow tools for the KT workspace.",
"scripts": {
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
"change-doc-sync": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --change-doc-sync",
"cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history",
"deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation",
"global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review",

View File

@ -1,4 +1,5 @@
import type {
ChangeDocSyncInput,
DeployObservationInput,
GlobalCodeReviewInput,
NapcatRemoteDevHandoffInput,
@ -35,6 +36,23 @@ export function parseGlobalReviewCliArgs(
};
}
/**
* Parses the change documentation sync CLI mode into the same input shape as the MCP tool.
* @param argv - Full process argv including `--change-doc-sync` and optional project/file flags.
* @returns Project key plus explicitly supplied changed files; absent files make the tool infer git status.
*/
export function parseChangeDocSyncCliArgs(argv: string[]): ChangeDocSyncInput {
return {
changedFiles: readOptionList(argv, [
'--changed-file',
'--changed-files',
'--file',
'--files',
]),
project: readOption(argv, ['--project']),
};
}
function readOption(argv: string[], names: string[]): string | undefined {
for (const name of names) {
const keyValue = argv.find((item) => item.startsWith(`${name}=`));

View File

@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
import path from "node:path";
import {
parseChangeDocSyncCliArgs,
parseNasCodexBootstrapCliArgs,
parseDeployObservationCliArgs,
parseGlobalReviewCliArgs,
@ -408,6 +409,9 @@ export async function runSelfTest(): Promise<void> {
if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) {
throw new Error("NapCat remote handoff npm script self-check failed");
}
if (!packageJson.scripts?.["change-doc-sync"]) {
throw new Error("change-doc-sync npm script self-check failed");
}
const registeredByFakeServer: string[] = [];
const fakeMcpServer = {
/**
@ -2141,6 +2145,33 @@ export async function runSelfTest(): Promise<void> {
if ((obsidianValidation.summary as { errors?: number }).errors !== 0) {
throw new Error("obsidian validation self-check failed");
}
const obsidianChecks = obsidianValidation.checks as {
base?: { requiredViews?: number; views?: number };
canvas?: { fileNodeCount?: number; groupCount?: number; indexLinkCount?: number };
moduleFrontmatter?: number;
nestedDocs?: number;
};
if (
!obsidianChecks.base ||
obsidianChecks.base.views !== obsidianChecks.base.requiredViews
) {
throw new Error("obsidian base view self-check failed");
}
if (!obsidianChecks.canvas || obsidianChecks.canvas.groupCount !== 5) {
throw new Error("obsidian canvas lane self-check failed");
}
if (obsidianChecks.canvas.fileNodeCount !== 0) {
throw new Error("obsidian canvas preview-node self-check failed");
}
if (!obsidianChecks.canvas.indexLinkCount) {
throw new Error("obsidian canvas index-link self-check failed");
}
if (!obsidianChecks.moduleFrontmatter) {
throw new Error("obsidian module frontmatter self-check failed");
}
if (obsidianChecks.nestedDocs !== 0) {
throw new Error("obsidian nested docs guard self-check failed");
}
const cleanupCliParser = {
defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"])
@ -2190,6 +2221,22 @@ export async function runSelfTest(): Promise<void> {
],
project: "api",
});
const docSyncCli = parseChangeDocSyncCliArgs([
"node",
"server",
"--change-doc-sync",
"--project",
"api",
"--file",
"src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts",
]);
if (
docSyncCli.project !== "api" ||
docSyncCli.changedFiles?.[0] !==
"src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts"
) {
throw new Error("doc sync CLI parser self-check failed");
}
const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{
path: string;
}>;

View File

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

View File

@ -34,9 +34,9 @@ const docSyncRules: DocSyncRule[] = [
reason: 'KtTable 组件或迁移规则变更,需要同步专项模块和 skill。',
},
{
docs: ['Vue/kt-blog-web/README.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'skills/kt-blog-argon-workflow/SKILL.md'],
docs: ['Vue/kt-blog-web/README.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'skills/kt-component-specialist/SKILL.md'],
match: /Vue\/kt-blog-web\/|blog|argon/i,
reason: 'Blog/Argon 能力变更,需要同步博客 README、模块页和专项 skill。',
reason: 'Blog/Argon 能力变更,需要同步博客 README、模块页和根内专项 skill;已安装 Codex skill 仅作为执行时加载入口。',
},
{
docs: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'docs/obsidian/modules/KT 模块 - Plugins.md'],
@ -44,12 +44,25 @@ const docSyncRules: DocSyncRule[] = [
reason: 'fnOS K8s 插件变更,需要同步插件 README/AGENTS 和 Obsidian 模块。',
},
{
docs: ['mcp/ktWorkflow/README.md', 'AGENTS.md', 'TASKS.md', 'docs/kt-testing-workflows.md', 'docs/obsidian/KT 工作流工程化.md'],
docs: [
'mcp/ktWorkflow/README.md',
'AGENTS.md',
'TASKS.md',
'docs/kt-stabilized-runtime-rules.md',
'docs/kt-testing-workflows.md',
'docs/obsidian/KT 工作流工程化.md',
],
match: /mcp\/ktWorkflow|src\/tools\/|src\/registerTools|src\/core|workflow|cleanup|review|selfTest/i,
reason: 'ktWorkflow 或自动化规则变更,需要同步 README、根规则、测试流程和当前任务记录。',
},
{
docs: ['AGENTS.md', 'SKILLS.md', 'docs/kt-context-semantics.md', 'docs/obsidian/modules/KT 模块 - Root Governance.md'],
docs: [
'AGENTS.md',
'SKILLS.md',
'docs/kt-context-semantics.md',
'docs/kt-stabilized-runtime-rules.md',
'docs/obsidian/modules/KT 模块 - Root Governance.md',
],
match: /AGENTS\.md|SKILLS\.md|skills\/.*\/SKILL\.md|docs\/kt-context-semantics\.md/,
reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。',
},
@ -118,6 +131,7 @@ export async function buildChangeDocSync(input: ChangeDocSyncInput = {}): Promis
return {
changedFiles,
commands: [
'pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project <project>',
'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query <module>',
'pnpm --dir mcp/ktWorkflow run obsidian-validate',
'pnpm --dir mcp/ktWorkflow run global-review',

View File

@ -21,6 +21,8 @@ interface CanvasNode {
file?: string;
height?: number;
id?: string;
label?: string;
text?: string;
type?: string;
width?: number;
x?: number;
@ -44,7 +46,9 @@ interface ObsidianSettings {
}
interface BookmarkItem {
items?: BookmarkItem[];
path?: string;
title?: string;
type?: string;
}
@ -173,11 +177,33 @@ const expectedIgnoredRoots = [
'.git/',
'.kt-workspace/',
'Node/',
'Plugins/',
'Vue/',
'Plugins/',
'mcp/',
'GitHub/',
'Other/',
'workspace-assets/',
];
const requiredBaseViewNames = [
'开工路由',
'模块-仓库矩阵',
'工作流入口',
'验证与收尾',
'运行态与远程排查',
'文档归口',
];
const requiredCanvasGroupLabels = [
'00 当前上下文',
'10 路由索引',
'20 模块仓库',
'30 工作流执行',
'40 验证与固化',
];
const canvasTextNodeMaxChars = 180;
const defaultContextPaths = [
'docs/obsidian/KT 知识图谱.md',
'docs/obsidian/modules/KT 模块索引.md',
@ -297,6 +323,12 @@ function readTextFile(relativePath: string): string {
return readFileSync(resolveInsideRoot(relativePath), 'utf8');
}
/**
* Resolves the Obsidian display title from frontmatter, the first H1, or the file name.
* @param content - Markdown file content read from the KT vault.
* @param fallback - Workspace-relative file path used when title metadata is absent.
* @returns The title shown in Obsidian context output.
*/
function getMarkdownTitle(content: string, fallback: string): string {
return (
content.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') ||
@ -305,6 +337,11 @@ function getMarkdownTitle(content: string, fallback: string): string {
);
}
/**
* Extracts tags from the YAML frontmatter subset used by KT Obsidian notes.
* @param content - Markdown content that may start with frontmatter.
* @returns Tag values without additional normalization.
*/
function getMarkdownTags(content: string): string[] {
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatter) return [];
@ -316,6 +353,59 @@ function getMarkdownTags(content: string): string[] {
.filter((tag): tag is string => Boolean(tag));
}
/**
* Removes matching quote wrappers from a YAML-like scalar used by KT Obsidian metadata.
* @param value - Raw scalar value from frontmatter or a Base view line.
* @returns The unquoted scalar value.
*/
function stripYamlQuotes(value: string): string {
return value.trim().replace(/^["']|["']$/g, '');
}
/**
* Parses the small frontmatter subset that KT uses for Obsidian routing fields.
* @param content - Markdown content with optional leading frontmatter.
* @returns Frontmatter fields as scalar strings or string arrays.
*/
function parseFrontmatterFields(content: string): Map<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())
@ -380,6 +470,27 @@ function buildContextTerms(input: ObsidianContextInput): string[] {
.filter(Boolean);
}
/**
* Extracts linked index targets from text nodes so Canvas can stay readable without file previews.
* @param nodes - Parsed JSON Canvas nodes from the KT relationship graph.
* @returns Text-node link summaries used by obsidian-context output.
*/
function extractCanvasIndexLinks(nodes: CanvasNode[]): Array<{ color?: string; id?: string; links: string[] }> {
return nodes
.filter((node) => node.type === 'text' && node.text)
.map((node) => ({
color: node.color,
id: node.id,
links: extractWikiLinks(node.text || ''),
}))
.filter((node) => node.links.length > 0);
}
/**
* Reads a lightweight Canvas summary for context routing without embedding full note previews.
* @param includeCanvas - Explicit false disables the Canvas summary for callers that need smaller output.
* @returns Canvas node/edge counts plus indexed wikilink targets, or null when disabled/unreadable.
*/
function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, unknown> | null {
if (includeCanvas === false) return null;
@ -398,6 +509,7 @@ function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, u
return {
edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0,
fileNodes,
indexLinks: extractCanvasIndexLinks(nodes),
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 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 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) {
@ -708,6 +832,26 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
}
nodeIds.add(node.id);
if (node.type === 'file') {
findings.push({
check: 'canvas-node',
level: 'error',
message: 'KT 关系图谱必须使用文本索引节点,不能使用会嵌入全文预览的 file 节点。',
path: canvasPath,
target: node.id,
});
}
if (node.type === 'text' && (node.text?.length || 0) > canvasTextNodeMaxChars) {
findings.push({
check: 'canvas-node',
level: 'error',
message: 'Canvas 文本节点过长,可能在 Obsidian 中出现内部滚动条。',
path: canvasPath,
target: `${node.id || 'unknown'}:${node.text?.length || 0}`,
});
}
if (node.type === 'file' && node.file && !existsSync(resolveInsideRoot(node.file))) {
findings.push({
check: 'canvas-file',
@ -752,6 +896,18 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
}
}
requiredCanvasGroupLabels
.filter((label) => !groupLabels.has(label))
.forEach((label) => {
findings.push({
check: 'canvas-group',
level: 'error',
message: 'Canvas 缺少工程任务流泳道。',
path: canvasPath,
target: label,
});
});
if (checkLayout) {
const visibleNodes = nodes.filter((node) => node.type !== 'group');
for (let index = 0; index < visibleNodes.length; index += 1) {
@ -771,9 +927,57 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
}
}
return { edgeCount: edges.length, nodeCount: nodes.length };
return {
edgeCount: edges.length,
fileNodeCount,
groupCount: groupLabels.size,
indexLinkCount,
nodeCount: nodes.length,
};
}
/**
* Extracts Obsidian Base view names from the repository-owned YAML subset without adding a parser dependency.
* @param content - Text content of `KT 文档矩阵.base`.
* @returns View names declared below the `views:` section.
*/
function extractBaseViewNames(content: string): string[] {
const names: string[] = [];
const lines = content.split(/\r?\n/);
let inViews = false;
let currentView = false;
for (const line of lines) {
if (/^views:\s*$/.test(line)) {
inViews = true;
currentView = false;
continue;
}
if (inViews && /^[A-Za-z_][A-Za-z0-9_-]*:\s*/.test(line)) {
break;
}
if (!inViews) continue;
if (/^\s*-\s+type:\s+/.test(line)) {
currentView = true;
continue;
}
const name = line.match(/^\s+name:\s+(.+)$/)?.[1];
if (currentView && name) {
names.push(stripYamlQuotes(name));
currentView = false;
}
}
return names;
}
/**
* Validates the engineering Base shape used by the KT Obsidian document matrix.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @returns Base section and view summary for command output.
*/
function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown> {
const basePath = 'docs/obsidian/KT 文档矩阵.base';
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 {
hasCardsView: content.includes('type: cards'),
hasTableView: content.includes('type: table'),
views: [...content.matchAll(/^\s*-\s+type:\s+/gm)].length,
requiredViews: requiredBaseViewNames.length,
viewNames,
views: viewNames.length,
};
}
/**
* Validates that module MOC files carry routing metadata for Base views and `obsidian-context`.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @returns Number of module notes inspected.
*/
function validateModuleFrontmatter(findings: ObsidianFinding[]): number {
const moduleFiles = listFiles(resolveInsideRoot('docs/obsidian/modules'), new Set(['.md']))
.map(relativeToWorkspace)
.filter((file) => file !== 'docs/obsidian/modules/KT 模块索引.md');
const routeFields = ['rule_anchors', 'code_anchors', 'workflow_entries', 'validation_entries'];
for (const file of moduleFiles) {
const fields = parseFrontmatterFields(readTextFile(file));
for (const requiredField of ['kt_type', 'module']) {
const value = fields.get(requiredField);
if (!value || (Array.isArray(value) && value.length === 0)) {
findings.push({
check: 'module-frontmatter',
level: 'error',
message: '模块页缺少 Obsidian 工程路由 frontmatter 字段。',
path: file,
target: requiredField,
});
}
}
const hasRouteField = routeFields.some((field) => {
const value = fields.get(field);
return Array.isArray(value) ? value.length > 0 : Boolean(value);
});
if (!hasRouteField) {
findings.push({
check: 'module-frontmatter',
level: 'error',
message: '模块页没有 rule/code/workflow/validation 任一工程锚点。',
path: file,
target: routeFields.join('|'),
});
}
}
return moduleFiles.length;
}
/**
* Rejects accidental nested Obsidian documents created by resolving wiki links from inside `docs/obsidian`.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @returns Number of nested files found, or 1 when only the invalid directory exists.
*/
function validateNoNestedObsidianDocs(findings: ObsidianFinding[]): number {
const nestedRoot = 'docs/obsidian/docs';
const absoluteRoot = resolveInsideRoot(nestedRoot);
if (!existsSync(absoluteRoot)) return 0;
const nestedFiles = listFiles(absoluteRoot, new Set(['.base', '.canvas', '.json', '.md']))
.map(relativeToWorkspace);
findings.push({
check: 'obsidian-nested-docs',
level: 'error',
message: 'docs/obsidian 下出现误嵌套 docs 目录Obsidian 索引只能放在 docs/obsidian 根层和 modules 层。',
path: nestedRoot,
target: nestedFiles.join(', ') || nestedRoot,
});
return nestedFiles.length || 1;
}
function validateStaleReferences(findings: ObsidianFinding[]): number {
const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml']));
let hitCount = 0;
@ -855,6 +1140,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
const base = validateBaseFile(findings);
const canvas = validateCanvas(findings, options.checkLayout);
const moduleFrontmatterCount = validateModuleFrontmatter(findings);
const nestedDocsCount = validateNoNestedObsidianDocs(findings);
const wikiLinkCount = validateWikiLinks(findings);
const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0;
const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0;
@ -867,6 +1154,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
canvas,
configFiles: obsidianConfigFiles.length,
markdownLinks: markdownLinkCount,
moduleFrontmatter: moduleFrontmatterCount,
nestedDocs: nestedDocsCount,
staleReferences: staleReferenceCount,
wikiLinks: wikiLinkCount,
},
@ -954,6 +1243,18 @@ function readOptionalJson<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[] {
const findings: ObsidianFinding[] = [];
const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file)));
@ -968,7 +1269,7 @@ function buildSyncFindings(): ObsidianFinding[] {
});
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
.filter((file) => !bookmarkPaths.has(file))
.forEach((file) => {