108 lines
5.6 KiB
Python
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()
|