Files
2026-10-18 16:21:54 +08:00

108 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Check file structure, card IDs and explicit typed links; never assert historical truth."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ID_FORMAT = re.compile(r'^(?:SRC-[PS]|EV|L|Q|H|C|CON|SYN|D|W)-\d{4}$')
ID_PREFIX = re.compile(r'^(SRC-[PS]|EV|L|Q|H|C|CON|SYN|D|W)-\d{4}(?=-|\.md$)')
REL_FIELDS = {
'source_ids', 'evidence_ids', 'question_ids', 'hypothesis_ids',
'claim_ids', 'synthesis_ids', 'related_ids', 'contradiction_ids', 'conclusion_ids',
}
CARD_DIRS = [
'01_sources/records/primary', '01_sources/records/secondary',
'02_evidence', '03_inquiry/leads', '03_inquiry/questions',
'03_inquiry/hypotheses', '05_historiography/works',
'06_arguments/claims', '06_arguments/syntheses', '06_arguments/conclusions',
]
CARD_SINGLE_DIRS = ['00_governance']
def parse_header(text: str) -> dict[str,str]:
if not text.startswith('---\n'): return {}
_, sep, after = text.partition('\n---\n')
if not sep: return {}
first = after # Read before divider, not body
header = text[4:].split('\n---\n',1)[0]
result = {}
for line in header.splitlines():
m = re.match(r'^([a-z_]+):\s*(.*)$', line)
if m: result[m.group(1)] = m.group(2).split(' #',1)[0].strip()
return result
def check(root: Path) -> tuple[list[str],list[str]]:
errors=[]; warnings=[]
needed=['README.md','code4history.md','AGENTS.md','USERINFO.md','topic.md','STATE.md',
'DISCUSSION_TO_TOPIC.md','MIGRATION.md','INTERACTION.md','tools/interaction.py',
'upload/readme.md','todo/readme.md','requests/readme.md','handoff/readme.md',
'.agents/skills/history-writing-workflow-c4h/SKILL.md',
'.agents/skills/source-intake-c4h/SKILL.md',
'.agents/skills/discussion-to-topic-c4h/SKILL.md']
for file in needed:
if not (root/file).is_file(): errors.append(f'Missing required file: {file}')
for obsolete in ('agent.md', 'AGENT.md', '.agents/skills/history-writing-workflow/SKILL.md'):
if (root/obsolete).exists(): errors.append(f'Retired entry still present: {obsolete}')
for folder in root.rglob('*'):
if not folder.is_dir() or any(x in folder.parts for x in ('.git','__pycache__','.pytest_cache')): continue
if not (folder/'readme.md').is_file(): errors.append(f'Missing readme.md: {folder.relative_to(root)}')
seen={}; records=[]
for d in CARD_DIRS+CARD_SINGLE_DIRS:
p=root/d
if not p.exists(): errors.append(f'Missing card folder: {d}'); continue
for f in sorted(p.glob('*.md')):
if f.name in {'readme.md','research-log.md','decisions.md','bibliography.md','source-policy.md'}: continue
# In governance root, only numbered D cards count; ignore other docs.
m=ID_PREFIX.match(f.name)
if not m: continue
head=parse_header(f.read_text(encoding='utf-8'))
ident=head.get('id','').strip('"\'')
if not ID_FORMAT.fullmatch(ident):
errors.append(f'{f.relative_to(root)}: missing/invalid frontmatter id'); continue
if not f.name.startswith(ident+'-') and f.name != ident+'.md':
errors.append(f'{f.relative_to(root)}: filename and id do not match')
if ident in seen: errors.append(f'Duplicate ID {ident}: {seen[ident]}, {f.relative_to(root)}')
seen[ident]=str(f.relative_to(root)); records.append((f,ident,head))
for f,ident,head in records:
for key in REL_FIELDS:
if key not in head: continue
raw=head[key].strip()
if raw in ('','[]','null'): continue
if not (raw.startswith('[') and raw.endswith(']')):
errors.append(f'{ident}: {key} must be a one-line [ID, ID] array'); continue
for token in raw[1:-1].split(','):
ref=token.strip(' "\'')
if not ref: continue
if not ID_FORMAT.fullmatch(ref): errors.append(f'{ident}: malformed linked ID {ref}');continue
if ref not in seen: errors.append(f'{ident}: broken {key} link to {ref}')
if ident.startswith('EV-'):
if not head.get('source_ids','').strip(' []'): warnings.append(f'{ident}: no source_ids yet')
if not head.get('source_locator','').strip(' "'): warnings.append(f'{ident}: missing source_locator')
if head.get('verification','')=='checked' and not head.get('source_locator','').strip(' "'):
errors.append(f'{ident}: verified evidence lacks source_locator')
if ident.startswith('C-') and not head.get('evidence_ids','').strip(' []'):
warnings.append(f'{ident}: claim has no evidence_ids yet')
for skill_name in ('history-writing-workflow-c4h','source-intake-c4h','discussion-to-topic-c4h'):
skill=root/'.agents/skills'/skill_name/'SKILL.md'
if skill.is_file():
head=parse_header(skill.read_text(encoding='utf-8'))
if head.get('name')!=skill_name or not head.get('description'):
errors.append(f'Skill frontmatter must include name: {skill_name} and description')
return errors,warnings
def main() -> None:
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('--strict',action='store_true',help='treat incomplete-card warnings as errors')
args=ap.parse_args()
errors,warnings=check(ROOT)
print(f'Code4History validation: {len(errors)} errors; {len(warnings)} warnings')
for msg in errors: print('ERROR:',msg)
for msg in warnings: print('WARN: ',msg)
if errors or (warnings and args.strict): raise SystemExit(1)
if __name__=='__main__': main()