#!/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()