feat: 补全文档同步和Obsidian校验
This commit is contained in:
parent
114eef3195
commit
1ad2baf37f
@ -18,7 +18,7 @@
|
||||
- 静态检查 NapCat 设备身份护栏:确认 API 仍保留 QQNT 可见 hostname、实体 OUI 风格 MAC、QQNT `machine-info` 写入、持久化 runtime dir 和 `DB_TIMEZONE` 默认值。
|
||||
- 生成 NapCatQQ 上游同步与 runtime 发布自动化入口:MCP/CLI 默认 dry-run,覆盖 upstream audit、sync candidate review、runtime readiness、remote dev handoff 和 NAS Codex bootstrap plan;upstream audit 在显式 `execute=true` 且 `useCodex=true` 时可运行 Codex,NAS Codex bootstrap 在显式 `execute=true` 时只返回目录准备、nvm 管理的 Node 22.14.0 / pnpm 10.28.2 / Codex CLI 安装计划,不复制 secrets、不写 systemd、不触发发布。
|
||||
- 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。
|
||||
- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口。
|
||||
- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口;MCP 不可用时用 `pnpm run change-doc-sync -- --project <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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}=`));
|
||||
|
||||
@ -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;
|
||||
}>;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user