#!/usr/bin/env python3
"""Static package checks. These cannot establish browser or screen-reader behavior."""
from pathlib import Path
from html.parser import HTMLParser
from urllib.parse import urlsplit,unquote
import ast,csv,json,re,sys,xml.etree.ElementTree as ET
ROOT=Path(__file__).resolve().parents[1]
class Page(HTMLParser):
def __init__(self,text):
super().__init__();self.ids=[];self.refs=[];self.images=[];self.headings=[];self.lang=None;self.main=False;self.feed(text)
def handle_starttag(self,tag,attrs):
a=dict(attrs)
if 'id'in a:self.ids.append(a['id'])
for k in ('href','src'):
if k in a:self.refs.append(a[k])
if tag=='html':self.lang=a.get('lang')
if tag=='main':self.main=True
if tag=='img':self.images.append(a)
if re.fullmatch('h[1-6]',tag):self.headings.append(tag)
def run():
errors=[];checks=0;refs=0
def check(value,msg):
nonlocal checks
checks+=1
if not value:errors.append(msg)
pages={p:Page(p.read_text()) for p in ROOT.glob('*.html')}
for path,p in pages.items():
check(p.lang=='en',f'{path.name}: English language missing');check(p.main,f'{path.name}: main landmark missing')
check(p.headings.count('h1')==1,f'{path.name}: expected one page h1');check(len(p.ids)==len(set(p.ids)),f'{path.name}: duplicate IDs')
for img in p.images:check(bool(img.get('alt','').strip()),f'{path.name}: missing image text alternative')
for ref in p.refs:
if not ref or urlsplit(ref).scheme:continue
refs+=1;parts=urlsplit(ref);target=(path.parent/unquote(parts.path)).resolve() if parts.path else path
check(target.exists(),f'{path.name}: missing {ref}')
if parts.fragment and target in pages:check(unquote(parts.fragment)in pages[target].ids,f'{path.name}: missing anchor {ref}')
svgfiles=sorted((ROOT/'assets').glob('*.svg'));check(len(svgfiles)==9,'Expected 9 SVGs')
for path in svgfiles:
root=ET.parse(path).getroot();ns={'s':'http://www.w3.org/2000/svg'}
check(root.get('role')=='img',f'{path.name}: SVG role missing')
check(root.find('s:title',ns)is not None,f'{path.name}: title missing');check(root.find('s:desc',ns)is not None,f'{path.name}: description missing')
lessons=sorted((ROOT/'lessons').glob('*.md'));word_counts={p.name:len(re.findall(r"\b[\w’-]+\b",p.read_text())) for p in lessons}
check(len(lessons)==12,'Expected 12 lessons');check(sum(word_counts.values())>=9000,'Expected substantial lesson narrative')
for name,count in word_counts.items():check(count>=600,f'{name}: unusually short teaching narrative')
wb=(ROOT/'WORKBOOK.md').read_text();check(len(re.findall(r'^### \d+[ABC] ',wb,re.M))==36,'Expected 36 distinct exercises')
assess=(ROOT/'ASSESSMENTS.md').read_text();check(len(re.findall(r'^[ABC][1-6]\. ',assess,re.M))==18,'Expected 18 checkpoint questions')
sol=(ROOT/'SOLUTIONS.md').read_text()
for i in range(1,13):
for letter in 'ABC':check(bool(re.search(rf'^{i}{letter}\. ',sol,re.M)),f'Missing answer {i}{letter}')
for letter in 'ABC':
for i in range(1,7):check(bool(re.search(rf'^{letter}{i}\. ',sol,re.M)),f'Missing checkpoint answer {letter}{i}')
for path in ROOT.rglob('*.py'):
try:ast.parse(path.read_text());check(True,'')
except SyntaxError as e:check(False,f'{path.relative_to(ROOT)}: {e}')
values=json.loads((ROOT/'outputs/chart_values.json').read_text());check(sum(values['shipments_histogram']['counts'])==240,'Histogram count mismatch')
check(values['trial_effect']['ci_low']<values['trial_effect']['difference']<values['trial_effect']['ci_high']<0,'Trial chart ordering')
check(values['trial_effect']['ci_low']<-2<values['trial_effect']['ci_high'],'Threshold missing from interval')
report={'checks':checks,'html_pages':len(pages),'local_references':refs,'svg_visuals':len(svgfiles),'lessons':len(lessons),'lesson_word_counts':word_counts,'lesson_words_total':sum(word_counts.values()),'exercises':36,'checkpoint_questions':18,'errors':errors,'scope':'Static source, links, SVG descriptions and content counts only; no browser/accessibility behavior assertion.'}
(ROOT/'outputs/package_checks.json').write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2));return bool(errors)
if __name__=='__main__':raise SystemExit(run())