1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
|
#!/usr/bin/env python3
"""Verify Obsidian paper-note schema, Zotero-key coverage, and optional inventory consistency."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REQUIRED_HEADINGS = (
"## Claim",
"## Method",
"## Evidence",
"## Limitation",
"## Direct relevance to repo",
"## Relation to other papers",
)
REQUIRED_FRONTMATTER_FIELDS = (
"type",
"project",
"title",
"zotero_key",
"linked_knowledge",
"paper_relationships",
"updated",
)
ZOTERO_KEY_RE = re.compile(r'^zotero_key:\s*"?([A-Z0-9]+)"?', re.MULTILINE)
FRONTMATTER_RE = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
FIELD_RE_TEMPLATE = r'^{}:\s*'
INVENTORY_ROW_RE = re.compile(r'^\|\s*([A-Z0-9]+)\s*\|\s*(.*?)\|\s*(.*?)\|\s*(.*?)\|\s*$')
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Verify paper-note schema and optional Zotero-key / inventory coverage."
)
parser.add_argument("--papers-dir", required=True, help="Directory containing canonical paper notes.")
parser.add_argument(
"--expected-zotero-keys",
default="",
help="Comma-separated Zotero keys expected to be covered.",
)
parser.add_argument(
"--inventory-note",
default="",
help="Optional collection inventory note to cross-check against canonical paper notes.",
)
parser.add_argument(
"--strict-missing-zotero-key",
action="store_true",
help="Treat notes without zotero_key as errors. Default behavior skips them.",
)
return parser.parse_args()
def load_expected_keys(raw_keys: str) -> list[str]:
if not raw_keys.strip():
return []
return [key.strip() for key in raw_keys.split(",") if key.strip()]
def extract_frontmatter(text: str) -> str:
match = FRONTMATTER_RE.match(text)
return match.group(1) if match else ""
def missing_frontmatter_fields(frontmatter: str) -> list[str]:
missing: list[str] = []
for field in REQUIRED_FRONTMATTER_FIELDS:
if not re.search(FIELD_RE_TEMPLATE.format(re.escape(field)), frontmatter, re.MULTILINE):
missing.append(field)
return missing
def collect_note_status(
papers_dir: Path,
strict_missing_zotero_key: bool,
) -> tuple[dict[str, str], list[str], list[str]]:
key_to_file: dict[str, str] = {}
issues: list[str] = []
skipped_without_key: list[str] = []
for path in sorted(papers_dir.glob("*.md")):
text = path.read_text(encoding="utf-8")
frontmatter = extract_frontmatter(text)
match = ZOTERO_KEY_RE.search(text)
if not match:
skipped_without_key.append(path.name)
if strict_missing_zotero_key:
issues.append(f"{path.name}: missing zotero_key")
continue
zotero_key = match.group(1)
if zotero_key in key_to_file:
issues.append(
f"{path.name}: duplicate zotero_key {zotero_key} also used by {key_to_file[zotero_key]}"
)
key_to_file[zotero_key] = path.name
missing_headings = [heading for heading in REQUIRED_HEADINGS if heading not in text]
if missing_headings:
issues.append(f"{path.name}: missing headings -> {', '.join(missing_headings)}")
missing_fields = missing_frontmatter_fields(frontmatter)
if missing_fields:
issues.append(f"{path.name}: missing frontmatter fields -> {', '.join(missing_fields)}")
return key_to_file, issues, skipped_without_key
def parse_inventory_note(inventory_path: Path) -> tuple[dict[str, str], list[str]]:
text = inventory_path.read_text(encoding="utf-8")
mapping: dict[str, str] = {}
issues: list[str] = []
in_table = False
for line in text.splitlines():
if line.strip() == "## Item Mapping":
in_table = True
continue
if in_table and line.startswith("## "):
break
if not in_table or not line.startswith("|"):
continue
if "---" in line:
continue
match = INVENTORY_ROW_RE.match(line)
if not match:
continue
key, _title, note_path, _status = [part.strip() for part in match.groups()]
if key in mapping:
issues.append(f"inventory: duplicate zotero key {key}")
mapping[key] = note_path
if not mapping:
issues.append("inventory: no item mapping rows found")
return mapping, issues
def main() -> int:
args = parse_args()
papers_dir = Path(args.papers_dir).expanduser()
expected_keys = load_expected_keys(args.expected_zotero_keys)
if not papers_dir.exists():
print(f"ERROR: papers dir not found: {papers_dir}")
return 1
note_files = list(papers_dir.glob("*.md"))
key_to_file, issues, skipped_without_key = collect_note_status(
papers_dir, args.strict_missing_zotero_key
)
print(f"Papers dir: {papers_dir}")
print(f"Paper notes scanned: {len(note_files)}")
print(f"Notes with zotero_key: {len(key_to_file)}")
if skipped_without_key:
print(f"Notes skipped without zotero_key: {len(skipped_without_key)}")
if expected_keys:
missing = [key for key in expected_keys if key not in key_to_file]
extras = sorted(set(key_to_file) - set(expected_keys))
print(f"Expected Zotero keys: {len(expected_keys)}")
print(f"Covered Zotero keys: {len(expected_keys) - len(missing)} / {len(expected_keys)}")
if missing:
issues.append(f"Missing expected keys: {', '.join(missing)}")
if extras:
print(f"Extra zotero_key notes: {', '.join(extras)}")
if args.inventory_note:
inventory_path = Path(args.inventory_note).expanduser()
if not inventory_path.exists():
issues.append(f"inventory note not found: {inventory_path}")
else:
inventory_mapping, inventory_issues = parse_inventory_note(inventory_path)
issues.extend(inventory_issues)
if inventory_mapping:
for key, note_path in inventory_mapping.items():
expected_name = key_to_file.get(key)
if expected_name is None:
issues.append(f"inventory: key {key} not found in paper notes")
continue
if Path(note_path).name != expected_name:
issues.append(
f"inventory: key {key} points to {note_path}, expected file ending {expected_name}"
)
missing_in_inventory = sorted(set(key_to_file) - set(inventory_mapping))
if missing_in_inventory:
issues.append(
f"inventory: missing keys present in paper notes -> {', '.join(missing_in_inventory)}"
)
if skipped_without_key:
print("Skipped note files without zotero_key:")
for name in skipped_without_key:
print(f"- {name}")
if issues:
print("\nISSUES:")
for issue in issues:
print(f"- {issue}")
return 1
print("\nOK: schema and coverage checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
|