mirror of
https://github.com/textcat/harness-noting.git
synced 2026-07-22 06:51:17 +00:00
Initial Harness Noting release
This commit is contained in:
commit
2ae4e19192
9 changed files with 1756 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
data.json
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Textcat
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
124
README.md
Normal file
124
README.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Harness Noting
|
||||
|
||||
Harness Noting is an Obsidian plugin for checking note structure against scoped rules.
|
||||
|
||||
It is designed for AI-assisted note maintenance: rules can be edited in Obsidian, and the same rules can be checked from the command line so an AI agent can read failures and fix notes before finishing.
|
||||
|
||||
## Features
|
||||
|
||||
- Match notes by exact file, folder, tag, filename text, and frontmatter property conditions
|
||||
- Combine scope conditions with `all` or `any`
|
||||
- Exclude files, folders, or tags from a rule
|
||||
- Check required frontmatter properties
|
||||
- Check property values, including `equals`, `contains`, and `regex`
|
||||
- Check required headings and heading order
|
||||
- Check filename patterns with regular expressions
|
||||
- Check direct child folder structures, including required subfolders and file patterns
|
||||
- Check vault root entries against a configurable whitelist
|
||||
- Run all checks from an Obsidian ribbon button
|
||||
- Copy a readable check conclusion from the results modal
|
||||
- Show Obsidian notices after file changes
|
||||
- Run checks from the command line with failure exit codes
|
||||
|
||||
## CLI
|
||||
|
||||
Run from the vault root:
|
||||
|
||||
```bash
|
||||
node .obsidian/plugins/harness-noting/harness-noting-cli.js
|
||||
```
|
||||
|
||||
Check a single note:
|
||||
|
||||
```bash
|
||||
node .obsidian/plugins/harness-noting/harness-noting-cli.js --file "path/to/note.md"
|
||||
```
|
||||
|
||||
Print JSON:
|
||||
|
||||
```bash
|
||||
node .obsidian/plugins/harness-noting/harness-noting-cli.js --json
|
||||
```
|
||||
|
||||
Exit codes:
|
||||
|
||||
- `0`: all matched notes passed
|
||||
- `1`: at least one matched note failed
|
||||
- `2`: the checker itself failed
|
||||
|
||||
## Rule Syntax
|
||||
|
||||
Property conditions and property checks use one rule per line:
|
||||
|
||||
```txt
|
||||
type equals project
|
||||
tags contains planning
|
||||
tags regex ^project/[A-Za-z0-9-]+$
|
||||
updated exists
|
||||
```
|
||||
|
||||
Supported operators:
|
||||
|
||||
- `exists`
|
||||
- `notExists`
|
||||
- `equals`
|
||||
- `notEquals`
|
||||
- `contains`
|
||||
- `regex`
|
||||
|
||||
## Folder Structure Rules
|
||||
|
||||
Folder structure rules check every direct child folder under a configured root folder.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Project folder structure",
|
||||
"rootFolder": "Projects",
|
||||
"requiredSubfolders": "Assets",
|
||||
"requiredFiles": "Overview.md\n* Plan.md"
|
||||
}
|
||||
```
|
||||
|
||||
Required file patterns support:
|
||||
|
||||
- `*` wildcards
|
||||
- `regex:` regular expressions
|
||||
- `{{folderName}}` as the current folder name
|
||||
|
||||
## Root Whitelist Rules
|
||||
|
||||
Root whitelist rules check only direct children of the vault root.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Vault root whitelist",
|
||||
"allowedEntries": "Inbox\nProjects\nResources\nArchive\nHome.md",
|
||||
"ignoredEntries": ".git\n.obsidian\n.trash\n.DS_Store",
|
||||
"ignoreDotEntries": true
|
||||
}
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Install from Obsidian community plugins after approval, or install manually:
|
||||
|
||||
Copy this folder to:
|
||||
|
||||
```txt
|
||||
<vault>/.obsidian/plugins/harness-noting
|
||||
```
|
||||
|
||||
Then enable `Harness Noting` in Obsidian community plugins.
|
||||
|
||||
## Release
|
||||
|
||||
Create a GitHub release with the same tag as `manifest.json`'s `version`.
|
||||
Attach these files:
|
||||
|
||||
- `main.js`
|
||||
- `manifest.json`
|
||||
- `styles.css`
|
||||
47
data.example.json
Normal file
47
data.example.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"checkOnModify": true,
|
||||
"showNoticeOnViolation": true,
|
||||
"rules": [
|
||||
{
|
||||
"id": "project-note",
|
||||
"name": "Project note",
|
||||
"enabled": true,
|
||||
"matchLogic": "all",
|
||||
"files": "",
|
||||
"folders": "Projects",
|
||||
"excludedFiles": "",
|
||||
"excludedFolders": "Archive",
|
||||
"excludedTags": "",
|
||||
"tags": "",
|
||||
"filenameIncludes": "Plan",
|
||||
"propertyConditions": "",
|
||||
"requiredProperties": "updated\ntags",
|
||||
"propertyChecks": "tags contains project",
|
||||
"requiredHeadings": "## Summary\n## Next steps",
|
||||
"requireHeadingOrder": true,
|
||||
"filenameRegex": "^[A-Za-z0-9 ._-]+ Plan$"
|
||||
}
|
||||
],
|
||||
"folderStructureRules": [
|
||||
{
|
||||
"id": "project-folder-structure",
|
||||
"name": "Project folder structure",
|
||||
"enabled": true,
|
||||
"rootFolder": "Projects",
|
||||
"includeFolderRegex": "",
|
||||
"excludeFolderRegex": "",
|
||||
"requiredSubfolders": "Assets",
|
||||
"requiredFiles": "Overview.md\n* Plan.md"
|
||||
}
|
||||
],
|
||||
"rootWhitelistRules": [
|
||||
{
|
||||
"id": "vault-root-whitelist",
|
||||
"name": "Vault root whitelist",
|
||||
"enabled": true,
|
||||
"allowedEntries": "Inbox\nProjects\nResources\nArchive\nHome.md",
|
||||
"ignoredEntries": ".git\n.obsidian\n.trash\n.DS_Store",
|
||||
"ignoreDotEntries": true
|
||||
}
|
||||
]
|
||||
}
|
||||
466
harness-noting-cli.js
Normal file
466
harness-noting-cli.js
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
#!/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;
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "harness-noting",
|
||||
"name": "Harness Noting",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Create scoped note rules and check required properties, headings, filename patterns, folder structures, and vault root entries.",
|
||||
"author": "Textcat",
|
||||
"authorUrl": "https://github.com/Textcat",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
40
styles.css
Normal file
40
styles.css
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
.harness-noting-rule {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.harness-noting-rule-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.harness-noting-rule-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.harness-noting-issue-list {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.harness-noting-ok {
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
.harness-noting-fail {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.harness-noting-modal textarea {
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.harness-noting-report {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-monospace);
|
||||
min-height: 140px;
|
||||
width: 100%;
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue