Initial Harness Noting release

This commit is contained in:
Textcat 2026-04-30 13:45:12 +08:00
commit 2ae4e19192
9 changed files with 1756 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
data.json

21
LICENSE Normal file
View 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
View 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
View 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
View 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;
}

1044
main.js Normal file

File diff suppressed because it is too large Load diff

10
manifest.json Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"1.0.0": "1.5.0"
}