79 lines
6.1 KiB
Python
79 lines
6.1 KiB
Python
from pathlib import Path
|
||
import json,re,subprocess,hashlib
|
||
root=Path.cwd(); out=root/'test-evidence/20260929-v2-handoff'
|
||
def git(*args):
|
||
return subprocess.check_output(['git',*args],encoding='utf-8')
|
||
files=[root/'AGENTS.md',root/'CLAUDE.md',*sorted((root/'docs/project-process').rglob('*.md')),*sorted((root/'docs/v2').glob('*.md'))]
|
||
issues=[]; links=0
|
||
for p in files:
|
||
s=p.read_text(encoding='utf-8')
|
||
if any(ord(c)<32 and c not in '\n\r\t' for c in s): issues.append(f'control character: {p}')
|
||
for target in re.findall(r'\[[^\]\n]*\]\(([^)]+)\)',s):
|
||
target=target.strip('<>').split('#')[0]
|
||
if not target or re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*:',target): continue
|
||
links+=1
|
||
if not (p.parent/target).exists(): issues.append(f'broken link: {p.name}: {target}')
|
||
for block in re.findall(r'(?:^\|.*(?:\n|$))+',s,re.M):
|
||
lines=block.splitlines()
|
||
if len(lines)<2 or not re.match(r'^\|[\s:|\-]+\|$',lines[1]): issues.append(f'table without header: {p.name}: {lines[0][:80]}')
|
||
s=(root/'docs/v2/01-requirements.md').read_text(encoding='utf-8')
|
||
old=git('show','47ec30a:docs/v2/01-requirements.md')
|
||
counts={}
|
||
for kind,count in [('BR',44),('E',26),('AC',45)]:
|
||
ids=re.findall(r'^\| ('+kind+r'-\d+) \|',s,re.M)
|
||
expected=[f'{kind}-{i:02}' for i in range(1,count+1)]
|
||
if ids!=expected: issues.append(f'{kind} IDs invalid: {ids}')
|
||
old_ids=set(re.findall(r'^\| ('+kind+r'-\d+) \|',old,re.M))
|
||
if not old_ids.issubset(ids): issues.append(f'{kind} baseline IDs lost')
|
||
counts[kind]=len(ids)
|
||
rows=re.findall(r'^\| AC-\d+ \|.*$',s,re.M)
|
||
for row in rows:
|
||
if not all(t in row for t in ['Given','When','Then']): issues.append(f'not GWT: {row}')
|
||
covered=set(re.findall(r'\b(?:BR|E)-\d+\b','\n'.join(rows)))
|
||
required=set(re.findall(r'^\| ((?:BR|E)-\d+) \|',s,re.M))
|
||
if required-covered: issues.append(f'uncovered: {required-covered}')
|
||
if covered-required: issues.append(f'undefined: {covered-required}')
|
||
u=(root/'docs/v2/URD.md').read_text(encoding='utf-8')
|
||
uids=re.findall(r'^\| (AC-U\d+) \|',u,re.M)
|
||
if len(uids)!=len(set(uids)) or len(uids)!=9: issues.append('URD acceptance IDs')
|
||
for row in re.findall(r'^\| MOD-.*$',u,re.M):
|
||
if not set(re.findall(r'AC-U\d+',row)).issubset(uids): issues.append(f'URD missing AC: {row}')
|
||
changed=git('diff','47ec30a','--name-only').splitlines()
|
||
if any(not (p.startswith('docs/v2/') or p == 'CLAUDE.md' or p.startswith('test-evidence/20260929-v2-handoff/')) for p in changed): issues.append('change outside documented scope')
|
||
# Cross-document stable ID references (expanded ranges validated by endpoint IDs).
|
||
valid={kind:set(re.findall(r'^\| ('+kind+r'-\d+) \|',(root/'docs/v2/01-requirements.md').read_text(encoding='utf-8'),re.M)) for kind in ['BR','E','AC']}
|
||
for p in (root/'docs/v2').glob('*.md'):
|
||
for ref in re.findall(r'\b(?:BR|E|AC)-\d+\b',p.read_text(encoding='utf-8')):
|
||
if ref not in valid[ref.split('-')[0]]: issues.append(f'undefined cross-document ID: {p.name}: {ref}')
|
||
hand=(root/'docs/v2/15-development-handoff.md').read_text(encoding='utf-8')
|
||
if re.findall(r'^\| (T\d+) ',hand,re.M) != [f'T{i:02}' for i in range(14)]: issues.append('task queue IDs')
|
||
for p in (root/'docs/v2').glob('*.md'):
|
||
# Verify local heading fragments using GitHub-style punctuation removal.
|
||
for target in re.findall(r'\[[^\]\n]*\]\(([^)]+)\)',p.read_text(encoding='utf-8')):
|
||
if '#' not in target or re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*:',target): continue
|
||
rel,frag=target.split('#',1); dest=p.parent/rel if rel else p
|
||
if dest.exists() and dest.suffix=='.md':
|
||
heads=re.findall(r'^#{1,6} (.+)$',dest.read_text(encoding='utf-8'),re.M)
|
||
slugs={re.sub(r'[^\w\- ]','',h.strip().lower()).replace(' ','-') for h in heads}
|
||
if frag not in slugs: issues.append(f'broken anchor: {p.name}: {target}')
|
||
for row in re.findall(r'^\| E-\d+ \|.*$', (root/'docs/v2/01-requirements.md').read_text(encoding='utf-8'),re.M):
|
||
if not re.search(r'\b[A-Z][A-Z_]+\b',row) or len(row.split('|'))<5: issues.append(f'error binding missing: {row}')
|
||
if not (root/'CLAUDE.md').read_text(encoding='utf-8').startswith('@AGENTS.md'): issues.append('Claude must import canonical rules')
|
||
adr=(root/'docs/v2/09-decisions.md').read_text(encoding='utf-8')
|
||
adr_ids=re.findall(r'^\| (ADR-\d+) \|',adr,re.M)
|
||
if adr_ids != [f'ADR-{i:02}' for i in range(1,15)]: issues.append('ADR IDs invalid')
|
||
old_adr=git('show','47ec30a:docs/v2/09-decisions.md')
|
||
if not set(re.findall(r'^\| (ADR-\d+) \|',old_adr,re.M)).issubset(adr_ids): issues.append('ADR baseline IDs lost')
|
||
check=subprocess.run(['git','diff','--check'],capture_output=True,text=True)
|
||
if check.returncode: issues.append(check.stdout+check.stderr)
|
||
report={'base_revision':'47ec30a562f12864b7485bb7a43b967fa8f5b755','cwd':str(root),'command':'backend/venv/Scripts/python.exe test-evidence/20260929-v2-handoff/check_docs.py','markdown_files':len(files),'local_links':links,'ids':counts,'status':'PASS' if not issues else 'FAIL','issues':issues,'runtime_tests':'Not run: documentation-only change','hashes':{str(p.relative_to(root)).replace('\\','/'):hashlib.sha256(p.read_bytes()).hexdigest() for p in files}}
|
||
(out/'validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
|
||
print(json.dumps({k:v for k,v in report.items() if k!='hashes'},ensure_ascii=False,indent=2))
|
||
summary = '# 文档检查报告\n\n日期:2026-09-29。状态:' + report['status'] + '\n\n'
|
||
summary += f"检查 {len(files)} 份Markdown、{links} 个本地链接;44条BR、26条E、45条AC全部唯一连续并有GWT覆盖;跨文档引用、任务T00–T13、锚点、错误码与表格检查。\n\n"
|
||
summary += '命令:`backend/venv/Scripts/python.exe test-evidence/20260929-v2-handoff/check_docs.py`;cwd为仓库根。基线47ec30a,最终文档SHA256见validation.json。\n\n'
|
||
summary += 'git diff --check及范围检查结果见validation.json;业务源码/原型未改变。纯文档未执行verify.py或模型/CLI实测。独立业务/技术审阅未完成,不能据此进入未过门禁的实现。\n'
|
||
if issues: summary += '\n问题:\n' + '\n'.join('- '+x for x in issues) + '\n'
|
||
(out/'REPORT.md').write_text(summary,encoding='utf-8')
|
||
raise SystemExit(bool(issues))
|