-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathcheck_project_rules.py
More file actions
324 lines (273 loc) · 11.1 KB
/
Copy pathcheck_project_rules.py
File metadata and controls
324 lines (273 loc) · 11.1 KB
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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
import os
import re
import sys
import argparse
from datetime import date, datetime, timedelta
from pathlib import Path
from urllib.parse import unquote, urlparse
ROOT = Path(__file__).resolve().parent
SKIP_DIRS = {
".agent",
".git",
".mdpress",
"_book",
"_site",
"dist",
"node_modules",
}
LINK_RE = re.compile(r"(!?)\[[^\]]*\]\(([^)\s]+(?:\s+\"[^\"]*\")?)\)")
FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
VOLATILE_FACTS = ROOT / "appendix/volatile_facts.md"
def iter_markdown_files() -> list[Path]:
files: list[Path] = []
for path in ROOT.rglob("*.md"):
if any(part in SKIP_DIRS for part in path.relative_to(ROOT).parts):
continue
files.append(path)
return sorted(files)
def strip_fenced_blocks(text: str) -> str:
output: list[str] = []
in_fence = False
fence_marker = ""
fence_len = 0
for line in text.splitlines():
match = FENCE_RE.match(line)
if match:
marker = match.group(1)
char = marker[0]
length = len(marker)
if not in_fence:
in_fence = True
fence_marker = char
fence_len = length
elif char == fence_marker and length >= fence_len:
in_fence = False
output.append("")
continue
output.append("" if in_fence else line)
return "\n".join(output)
def check_fences(path: Path, text: str) -> list[str]:
issues: list[str] = []
stack: list[tuple[str, int, int]] = []
for line_no, line in enumerate(text.splitlines(), 1):
match = FENCE_RE.match(line)
if not match:
continue
marker = match.group(1)
char = marker[0]
length = len(marker)
if not stack:
stack.append((char, length, line_no))
continue
open_char, open_len, _ = stack[-1]
if char == open_char and length >= open_len:
stack.pop()
else:
stack.append((char, length, line_no))
for _, _, line_no in stack:
issues.append(f"{path.relative_to(ROOT)}:{line_no}: unclosed fenced code block")
return issues
def is_local_target(target: str) -> bool:
parsed = urlparse(target)
return not parsed.scheme and not parsed.netloc and not target.startswith("#")
def normalize_target(raw_target: str) -> str:
target = raw_target.strip()
if " " in target and target.count('"') >= 2:
target = target.split(" ", 1)[0]
return unquote(target.split("#", 1)[0])
def check_links(path: Path, text: str) -> list[str]:
issues: list[str] = []
body = strip_fenced_blocks(text)
for match in LINK_RE.finditer(body):
raw_target = match.group(2).strip()
target = normalize_target(raw_target)
if not target or not is_local_target(raw_target):
continue
target_path = (path.parent / target).resolve()
try:
target_path.relative_to(ROOT)
except ValueError:
continue
if not target_path.exists():
line_no = body[: match.start()].count("\n") + 1
issues.append(
f"{path.relative_to(ROOT)}:{line_no}: missing local link target: {raw_target}"
)
return issues
def check_summary_links() -> list[str]:
summary = ROOT / "SUMMARY.md"
if not summary.exists():
return []
return check_links(summary, summary.read_text(encoding="utf-8", errors="ignore"))
def check_volatile_facts(filepath=VOLATILE_FACTS, today=None):
"""Validate the dated snapshot that contains deliberately volatile claims."""
path = Path(filepath)
current_date = today or date.today()
issues = []
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
return [f"{path} [Volatile facts] Cannot read ledger: {exc}"]
metadata = re.search(
r"`verified_at`:\s*(\d{4}-\d{2}-\d{2})\s*·\s*"
r"`expires_at`:\s*(\d{4}-\d{2}-\d{2})\s*·\s*"
r"`ttl_days`:\s*(\d+)",
content,
)
if metadata is None:
return [
f"{path} [Volatile facts] Missing verified_at, expires_at, or ttl_days metadata."
]
try:
verified_at = datetime.strptime(metadata.group(1), "%Y-%m-%d").date()
expires_at = datetime.strptime(metadata.group(2), "%Y-%m-%d").date()
ttl_days = int(metadata.group(3))
except ValueError as exc:
return [f"{path} [Volatile facts] Invalid metadata: {exc}"]
if ttl_days != 30 or expires_at - verified_at != timedelta(days=30):
issues.append(
f"{path} [Volatile facts] Snapshot TTL must be exactly 30 days."
)
if verified_at > current_date:
issues.append(
f"{path} [Volatile facts] verified_at is in the future: {verified_at}."
)
if current_date > expires_at:
issues.append(
f"{path} [Volatile facts] Snapshot expired on {expires_at}."
)
statuses = re.findall(
r"<!--\s*volatile-status:\s+id=[^\s]+\s+status=([^\s]+)\s*-->",
content,
)
if not statuses:
issues.append(f"{path} [Volatile facts] Missing volatile-status marker.")
for status in statuses:
if status == "open-conflict":
issues.append(f"{path} [Volatile facts] Ledger has an unresolved conflict.")
elif status not in {"current", "resolved-conflict"}:
issues.append(
f"{path} [Volatile facts] Unsupported volatile-status: {status}."
)
return issues
def check_trailing_newline(content, filepath, issues):
if not content.endswith('\n') or content.endswith('\n\n'):
issues.append(f"{filepath} [Rule 1.4] File must end with exactly one newline.")
def check_file(filepath, issues):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.split('\n')
check_trailing_newline(content, filepath, issues)
# Pre-process lines to identify code blocks
in_code_block = False
code_block_level = 0
in_code_block_lines = []
for line in lines:
stripped = line.strip()
match = re.match(r'^(`{3,})', stripped)
if match:
level = len(match.group(1))
if not in_code_block:
in_code_block = True
code_block_level = level
in_code_block_lines.append(True)
elif level == code_block_level:
in_code_block = False
code_block_level = 0
in_code_block_lines.append(True)
else:
in_code_block_lines.append(in_code_block)
else:
in_code_block_lines.append(in_code_block)
pattern_quotes = r'"[^"]*?[\u4e00-\u9fa5]+[^"]*?"'
header_pattern = re.compile(r'^(#{1,6})\s+(.*)')
last_level = 0
for i, line in enumerate(lines):
if in_code_block_lines[i]:
continue
# Rule 3.3
top_level = Path(filepath).parts[0]
if '参考资料' in line and top_level.startswith('0') and re.search(r'https?://|\]\(', line):
issues.append(f"{filepath}:{i+1} [Rule 3.3] Reference links should be centralized in appendix.")
# Rule 1.6: Chinese Quotes
if re.search(pattern_quotes, line) and not '<' in line:
issues.append(f"{filepath}:{i+1} [Rule 1.6] Chinese content should use Chinese curly quotes.")
# Rule 1.1: Bold spacing
clean_line = line.replace('\\*\\*', '\x00')
parts = clean_line.split('**')
# Check all odd indices (text inside ** **)
for idx in range(1, len(parts), 2):
# If the length of parts is even, the last split means an unclosed '**', skip it.
if idx == len(parts) - 1 and len(parts) % 2 == 0:
continue
inside_text = parts[idx]
if inside_text and (inside_text[0] in ' \t' or inside_text[-1] in ' \t'):
issues.append(f"{filepath}:{i+1} [Rule 1.1] Spaces inside bold markers.")
break
match = header_pattern.match(line)
if match:
level = len(match.group(1))
text = match.group(2)
# Rule 1.2 Header Spacing
if i + 1 < len(lines):
if lines[i+1].strip() != '':
if header_pattern.match(lines[i+1]) is None:
issues.append(f"{filepath}:{i+1} [Rule 1.2] Missing blank line after header.")
elif i + 2 < len(lines) and lines[i+2].strip() == '':
issues.append(f"{filepath}:{i+1} [Rule 1.2] Multiple blank lines after header.")
# Rule 1.3 Hierarchy
if last_level > 0 and level - last_level > 1:
issues.append(f"{filepath}:{i+1} [Rule 1.3] Header level skipped from H{last_level} to H{level}.")
last_level = level
# Rule 2.3 English terms in parens
if re.search(r'\([a-zA-Z\s]+\)', text) and not re.search(r'(API|LLM|AI|RAG)', text):
issues.append(f"{filepath}:{i+1} [Rule 2.3] Header contains English parentheses: {text}")
# Rule 2.2 File Header Levels
first_header = None
for line in lines:
match = re.search(r'^(#{1,6})\s', line)
if match:
first_header = match.group(1)
break
basename = os.path.basename(filepath)
if first_header:
if basename.lower() == 'readme.md' and len(first_header) != 1:
issues.append(f"{filepath} [Rule 2.2] First header in README must be H1.")
elif re.match(r'^\d+\.\d+', basename) and len(first_header) != 2:
issues.append(f"{filepath} [Rule 2.2] First header in section file must be H2.")
def main():
parser = argparse.ArgumentParser(description='Check project markdown rules.')
parser.add_argument('-v', '--verbose', action='store_true', help='Show all scanned files')
args = parser.parse_args()
os.chdir(Path(__file__).resolve().parent)
repo_dir = '.'
issues = []
scanned_files = 0
issues.extend(check_volatile_facts())
# 围栏闭合、本地链接可达、SUMMARY 覆盖——12 个兄弟仓库都有,本仓库此前缺失
md_files = iter_markdown_files()
for md in md_files:
md_text = md.read_text(encoding='utf-8', errors='ignore')
issues.extend(check_fences(md, md_text))
issues.extend(check_links(md, md_text))
issues.extend(check_summary_links())
for root, dirs, files in os.walk(repo_dir):
if any(skip in root.split(os.sep) for skip in ['.git', '.obsidian', '_images', '__pycache__', '.agent', 'node_modules', '_book']):
continue
for file in files:
if file.endswith('.md'):
path = os.path.join(root, file)
if args.verbose:
print(f"Scanning {path}...")
check_file(path, issues)
scanned_files += 1
if issues:
print(f"Found {len(issues)} rule violations across {scanned_files} files:")
for issue in issues:
print(issue)
sys.exit(1)
else:
print(f"All {scanned_files} files passed checks!")
sys.exit(0)
if __name__ == '__main__':
main()