textcat_harness-noting/harness-noting-cli.js
2026-04-30 13:45:12 +08:00

466 lines
15 KiB
JavaScript

#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const DEFAULT_SETTINGS = {
rules: [],
folderStructureRules: [],
rootWhitelistRules: [],
};
function usage() {
console.log(`Harness Noting CLI
Usage:
node .obsidian/plugins/harness-noting/harness-noting-cli.js [--vault <path>] [--file <path>] [--json]
Options:
--vault <path> Vault root. Defaults to current directory.
--file <path> Check one markdown file instead of the whole vault.
--json Print JSON instead of a readable report.
--help Show this help.
`);
}
function parseArgs(argv) {
const args = { vault: process.cwd(), file: "", json: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
args.help = true;
} else if (arg === "--json") {
args.json = true;
} else if (arg === "--vault") {
args.vault = argv[++i];
} else if (arg === "--file") {
args.file = argv[++i];
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return args;
}
function lines(value) {
return String(value || "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function normalizePath(value) {
return value.split(path.sep).join("/");
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function patternMatches(pattern, value) {
const trimmed = String(pattern || "").trim();
if (!trimmed) return false;
if (trimmed.startsWith("regex:")) {
try {
return new RegExp(trimmed.slice(6)).test(value);
} catch {
return false;
}
}
const regex = new RegExp(`^${trimmed.split("*").map(escapeRegExp).join(".*")}$`);
return regex.test(value);
}
function normalizeTag(tag) {
return String(tag || "").replace(/^#/, "").trim();
}
function parseFrontmatter(text) {
if (!text.startsWith("---\n")) return {};
const end = text.indexOf("\n---", 4);
if (end === -1) return {};
const yaml = text.slice(4, end).split("\n");
const result = {};
let currentKey = null;
for (const rawLine of yaml) {
const line = rawLine.replace(/\r$/, "");
const listItem = line.match(/^\s*-\s+(.*)$/);
if (listItem && currentKey) {
if (!Array.isArray(result[currentKey])) result[currentKey] = [];
result[currentKey].push(cleanScalar(listItem[1]));
continue;
}
const pair = line.match(/^([^:#][^:]*):\s*(.*)$/);
if (!pair) continue;
currentKey = pair[1].trim();
const value = pair[2].trim();
if (value === "") {
result[currentKey] = [];
} else if (value.startsWith("[") && value.endsWith("]")) {
result[currentKey] = value
.slice(1, -1)
.split(",")
.map((item) => cleanScalar(item.trim()))
.filter(Boolean);
} else {
result[currentKey] = cleanScalar(value);
}
}
return result;
}
function cleanScalar(value) {
return String(value).replace(/^["']|["']$/g, "");
}
function readFrontmatterTags(frontmatter) {
const tags = frontmatter.tags;
if (!tags) return [];
if (Array.isArray(tags)) return tags.map(normalizeTag);
return String(tags)
.split(/[,\s]+/)
.map(normalizeTag)
.filter(Boolean);
}
function parsePropertyCondition(line) {
const match = String(line).match(/^([^\s]+)\s+(exists|notExists|equals|notEquals|contains|regex)\s*(.*)$/);
if (!match) return null;
return {
key: match[1],
operator: match[2],
value: match[3].trim(),
};
}
function arrayContainsValue(value, expected) {
if (Array.isArray(value)) return value.some((item) => String(item) === expected);
return String(value ?? "") === expected;
}
function valueContains(value, expected) {
if (Array.isArray(value)) return value.some((item) => String(item).includes(expected));
return String(value ?? "").includes(expected);
}
function propertyConditionMatches(condition, frontmatter) {
const value = frontmatter[condition.key];
if (condition.operator === "exists") return value !== undefined && value !== null;
if (condition.operator === "notExists") return value === undefined || value === null;
if (condition.operator === "equals") return arrayContainsValue(value, condition.value);
if (condition.operator === "notEquals") return !arrayContainsValue(value, condition.value);
if (condition.operator === "contains") return valueContains(value, condition.value);
if (condition.operator === "regex") {
try {
return new RegExp(condition.value).test(String(value ?? ""));
} catch {
return false;
}
}
return false;
}
function loadSettings(vaultRoot) {
const settingsPath = path.join(vaultRoot, ".obsidian/plugins/harness-noting/data.json");
if (!fs.existsSync(settingsPath)) return DEFAULT_SETTINGS;
const loaded = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
return {
...DEFAULT_SETTINGS,
...loaded,
rules: loaded.rules || DEFAULT_SETTINGS.rules,
folderStructureRules: loaded.folderStructureRules || DEFAULT_SETTINGS.folderStructureRules,
rootWhitelistRules: loaded.rootWhitelistRules || DEFAULT_SETTINGS.rootWhitelistRules,
};
}
function collectMarkdownFiles(vaultRoot, singleFile) {
if (singleFile) {
const fullPath = path.isAbsolute(singleFile) ? singleFile : path.join(vaultRoot, singleFile);
return [fullPath];
}
const ignored = new Set([".git", ".obsidian", ".trash", "node_modules"]);
const files = [];
const stack = [vaultRoot];
while (stack.length) {
const dir = stack.pop();
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (ignored.has(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
}
}
return files.sort();
}
function ruleMatches(rule, fileInfo, frontmatter) {
if (!rule.enabled) return false;
const tests = [];
const fileRules = lines(rule.files);
const folderRules = lines(rule.folders);
const excludedFileRules = lines(rule.excludedFiles);
const excludedFolderRules = lines(rule.excludedFolders);
const excludedTagRules = lines(rule.excludedTags).map(normalizeTag);
const tagRules = lines(rule.tags).map(normalizeTag);
const filenameRules = lines(rule.filenameIncludes);
const propertyRules = lines(rule.propertyConditions).map(parsePropertyCondition).filter(Boolean);
if (excludedFileRules.some((rulePath) => fileInfo.relativePath === rulePath || fileInfo.basename === rulePath.replace(/\.md$/, ""))) {
return false;
}
if (
excludedFolderRules.some((folder) => {
const normalized = folder.replace(/\/$/, "");
return fileInfo.relativePath.includes(`/${normalized}/`) || fileInfo.relativePath.startsWith(`${normalized}/`);
})
) {
return false;
}
if (excludedTagRules.length) {
const tags = new Set(readFrontmatterTags(frontmatter));
if (excludedTagRules.some((tag) => tags.has(tag))) return false;
}
if (fileRules.length) {
tests.push(fileRules.some((rulePath) => fileInfo.relativePath === rulePath || fileInfo.basename === rulePath.replace(/\.md$/, "")));
}
if (folderRules.length) {
tests.push(folderRules.some((folder) => fileInfo.relativePath.startsWith(folder.replace(/\/$/, "") + "/")));
}
if (tagRules.length) {
const tags = new Set(readFrontmatterTags(frontmatter));
tests.push(tagRules.every((tag) => tags.has(tag)));
}
if (filenameRules.length) {
tests.push(filenameRules.every((part) => fileInfo.basename.includes(part)));
}
if (propertyRules.length) {
tests.push(propertyRules.every((condition) => propertyConditionMatches(condition, frontmatter)));
}
if (!tests.length) return false;
return rule.matchLogic === "any" ? tests.some(Boolean) : tests.every(Boolean);
}
function checkRule(rule, fileInfo, frontmatter, text) {
const issues = [];
for (const key of lines(rule.requiredProperties)) {
const value = frontmatter[key];
if (value === undefined || value === null || value === "") {
issues.push({ rule: rule.name, message: `缺少属性:${key}` });
}
}
for (const line of lines(rule.propertyChecks)) {
const condition = parsePropertyCondition(line);
if (!condition) {
issues.push({ rule: rule.name, message: `属性检查写法无效:${line}` });
continue;
}
if (!propertyConditionMatches(condition, frontmatter)) {
issues.push({ rule: rule.name, message: `属性检查未通过:${line}` });
}
}
let lastIndex = -1;
for (const heading of lines(rule.requiredHeadings)) {
const index = text.indexOf(heading);
if (index === -1) {
issues.push({ rule: rule.name, message: `缺少标题:${heading}` });
continue;
}
if (rule.requireHeadingOrder && index < lastIndex) {
issues.push({ rule: rule.name, message: `标题顺序不正确:${heading}` });
}
lastIndex = Math.max(lastIndex, index);
}
if (rule.filenameRegex) {
try {
const regex = new RegExp(rule.filenameRegex);
if (!regex.test(fileInfo.basename)) {
issues.push({ rule: rule.name, message: `文件名不符合规则:${rule.filenameRegex}` });
}
} catch {
issues.push({ rule: rule.name, message: `文件名正则无效:${rule.filenameRegex}` });
}
}
return issues;
}
function checkFile(vaultRoot, fullPath, rules) {
const text = fs.readFileSync(fullPath, "utf8");
const relativePath = normalizePath(path.relative(vaultRoot, fullPath));
const basename = path.basename(fullPath, ".md");
const fileInfo = { fullPath, relativePath, basename };
const frontmatter = parseFrontmatter(text);
const matchedRules = rules.filter((rule) => ruleMatches(rule, fileInfo, frontmatter));
const issues = matchedRules.flatMap((rule) => checkRule(rule, fileInfo, frontmatter, text));
return {
path: relativePath,
rules: matchedRules.map((rule) => rule.name),
issues,
};
}
function folderStructureRuleMatches(rule, folderName) {
if (rule.includeFolderRegex) {
try {
if (!new RegExp(rule.includeFolderRegex).test(folderName)) return false;
} catch {
return true;
}
}
if (rule.excludeFolderRegex) {
try {
if (new RegExp(rule.excludeFolderRegex).test(folderName)) return false;
} catch {
return true;
}
}
return true;
}
function checkFolderStructureRules(vaultRoot, rules) {
const results = [];
for (const rule of rules || []) {
if (!rule.enabled || !rule.rootFolder) continue;
const rootFolder = normalizePath(rule.rootFolder).replace(/\/+$/, "");
const rootPath = path.join(vaultRoot, rootFolder);
if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
results.push({
path: rootFolder,
rules: [rule.name],
issues: [{ rule: rule.name, message: "根文件夹不存在" }],
});
continue;
}
const entries = fs.readdirSync(rootPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!folderStructureRuleMatches(rule, entry.name)) continue;
const folderPath = path.join(rootPath, entry.name);
const relativePath = normalizePath(path.relative(vaultRoot, folderPath));
const issues = checkOneFolderStructure(rule, folderPath, entry.name);
results.push({
path: relativePath,
rules: [rule.name],
issues,
});
}
}
return results;
}
function checkRootWhitelistRules(vaultRoot, rules) {
const results = [];
for (const rule of rules || []) {
if (!rule.enabled) continue;
const allowedEntries = new Set(lines(rule.allowedEntries));
const ignoredEntries = new Set(lines(rule.ignoredEntries));
const entries = fs.readdirSync(vaultRoot, { withFileTypes: true }).map((entry) => entry.name);
const issues = [];
for (const entry of entries) {
if (rule.ignoreDotEntries && entry.startsWith(".")) continue;
if (ignoredEntries.has(entry)) continue;
if (!allowedEntries.has(entry)) {
issues.push({ rule: rule.name, message: `根目录不在白名单:${entry}` });
}
}
results.push({
path: "/",
rules: [rule.name],
issues,
});
}
return results;
}
function checkOneFolderStructure(rule, folderPath, folderName) {
const issues = [];
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
const directFiles = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
const directFolders = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
for (const requiredFolder of lines(rule.requiredSubfolders)) {
if (!directFolders.includes(requiredFolder)) {
issues.push({ rule: rule.name, message: `缺少子文件夹:${requiredFolder}` });
}
}
for (const requiredFile of lines(rule.requiredFiles)) {
const pattern = requiredFile.replaceAll("{{folderName}}", folderName);
if (!directFiles.some((file) => patternMatches(pattern, file))) {
issues.push({ rule: rule.name, message: `缺少文件:${requiredFile}` });
}
}
return issues;
}
function printReadable(results) {
const checked = results.filter((result) => result.rules.length).length;
const failed = results.filter((result) => result.issues.length).length;
console.log(`Harness Noting: checked ${checked} matched item(s), ${failed} failed.`);
for (const result of results.filter((item) => item.issues.length)) {
console.log(`\nFAIL ${result.path}`);
console.log(`Rules: ${result.rules.join(", ")}`);
for (const issue of result.issues) {
console.log(`- [${issue.rule}] ${issue.message}`);
}
}
if (!failed) {
console.log("All matched notes passed.");
}
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
const vaultRoot = path.resolve(args.vault);
const settings = loadSettings(vaultRoot);
const files = collectMarkdownFiles(vaultRoot, args.file);
const results = files
.map((file) => checkFile(vaultRoot, file, settings.rules || []))
.filter((result) => result.rules.length || result.issues.length);
if (!args.file) {
results.push(...checkFolderStructureRules(vaultRoot, settings.folderStructureRules || []));
results.push(...checkRootWhitelistRules(vaultRoot, settings.rootWhitelistRules || []));
}
if (args.json) {
console.log(JSON.stringify({ checkedAt: new Date().toISOString(), results }, null, 2));
} else {
printReadable(results);
}
const failed = results.some((result) => result.issues.length);
process.exitCode = failed ? 1 : 0;
}
try {
main();
} catch (error) {
console.error(`Harness Noting failed: ${error.message}`);
process.exitCode = 2;
}