142 lines
7.7 KiB
Python
142 lines
7.7 KiB
Python
"""Check requirement provenance and handoff completeness; no service or network access."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DOCS = ROOT / 'docs/requirements'
|
|
|
|
|
|
def read(path):
|
|
return json.loads(path.read_text(encoding='utf-8'))
|
|
|
|
|
|
def check(handoff=None, base_ref=None):
|
|
errors = []
|
|
baseline = read(DOCS / 'baseline.json')
|
|
rows = baseline['requirements']
|
|
ids = [r['id'] for r in rows]
|
|
known = set(ids)
|
|
evidence = {}
|
|
for path in sorted((DOCS / 'sources').glob('comment-evidence*.json')):
|
|
for comment in read(path):
|
|
old = evidence.get(comment['id'])
|
|
if old and old != comment:
|
|
errors.append(f'同一来源评论出现不同记录:{comment["id"]}')
|
|
evidence[comment['id']] = comment
|
|
if len(ids) != len(known):
|
|
errors.append('重复的需求编号')
|
|
text = (DOCS / 'current.md').read_text(encoding='utf-8')
|
|
headings = re.findall(r'^## ((?:CG|VIS|AM|UX|GOV|DEL)-\d{3}) · ', text, re.M)
|
|
if headings != ids:
|
|
errors.append('完整正文与结构化需求编号不一致')
|
|
for row in rows:
|
|
key = row['id']
|
|
if not re.fullmatch(r'(?:CG|VIS|AM|UX|GOV|DEL)-\d{3}', key):
|
|
errors.append(f'非法编号:{key}')
|
|
if not row.get('topic') or not row.get('conclusion') or not row.get('source_comment_ids'):
|
|
errors.append(f'缺少要求或来源:{key}')
|
|
if row.get('conclusion','') not in text or row.get('boundary','') not in text:
|
|
errors.append(f'完整正文缺少要求原意或边界:{key}')
|
|
for source in row.get('source_comment_ids', []):
|
|
if source not in evidence or source not in text:
|
|
errors.append(f'来源缺失:{key}/{source}')
|
|
if row.get('source_document') and not (DOCS / row['source_document']).is_file():
|
|
errors.append(f'来源文档缺失:{key}')
|
|
for name, expected in read(DOCS / 'source-manifest.json').items():
|
|
path = DOCS / 'sources' / name
|
|
if path.parent != DOCS / 'sources' or not path.is_file():
|
|
errors.append(f'来源文件缺失或路径异常:{name}')
|
|
elif hashlib.sha256(path.read_bytes()).hexdigest() != expected:
|
|
errors.append(f'历史来源已改变:{name}')
|
|
coverage = read(DOCS / 'coverage.json')
|
|
coverage_ids = [r['requirement_id'] for r in coverage['items']]
|
|
if coverage.get('baseline_version') != baseline['version']:
|
|
errors.append('对照表版本过期')
|
|
if len(coverage_ids) != len(set(coverage_ids)) or set(coverage_ids) != known:
|
|
errors.append('对照表遗漏、重复或包含不存在的要求')
|
|
for row in coverage['items']:
|
|
if not row.get('status') or not row.get('next_step') or not isinstance(row.get('evidence'), list):
|
|
errors.append(f'对照项缺少状态、证据栏或承接:{row.get("requirement_id")}')
|
|
changes = read(DOCS / 'changes.json')['entries']
|
|
if not changes or changes[-1].get('version') != baseline['version']:
|
|
errors.append('当前版本没有变更记录')
|
|
for change in changes:
|
|
source = evidence.get(change.get('approval_comment_id'), {})
|
|
if source.get('author_type') != 'member' or source.get('author_id') != baseline['owner_id']:
|
|
errors.append('需求变更未关联白栖知的原始评论')
|
|
if change.get('approved_by') != baseline['owner_id'] or not change.get('summary') or not change.get('impact'):
|
|
errors.append('变更记录缺少批准者、说明或影响')
|
|
if not change.get('affected_ids') or not set(change['affected_ids']) <= known:
|
|
errors.append('变更影响编号缺失或无效')
|
|
if base_ref:
|
|
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_./-]*', base_ref):
|
|
errors.append('非法比较引用')
|
|
else:
|
|
valid=subprocess.run(['git','rev-parse','--verify',base_ref+'^{commit}'],cwd=ROOT,capture_output=True)
|
|
if valid.returncode:
|
|
errors.append('比较引用不存在;不能跳过版本检查')
|
|
else:
|
|
def previous(name):
|
|
result=subprocess.run(['git','show',f'{base_ref}:docs/requirements/{name}'],cwd=ROOT,capture_output=True,text=True)
|
|
return json.loads(result.stdout) if not result.returncode else None
|
|
old=previous('baseline.json')
|
|
if old:
|
|
old_changes=previous('changes.json')['entries']
|
|
if changes[:len(old_changes)] != old_changes:
|
|
errors.append('历史变更记录不能改写或删除')
|
|
previous_manifest=previous('source-manifest.json')
|
|
current_manifest=read(DOCS / 'source-manifest.json')
|
|
if any(current_manifest.get(k)!=v for k,v in previous_manifest.items()):
|
|
errors.append('历史来源必须保留;后续来源以新文件追加')
|
|
if baseline != old:
|
|
if baseline['version'] == old['version']:
|
|
errors.append('需求有变但版本未更新')
|
|
if len(changes)<=len(old_changes):
|
|
errors.append('需求有变但没有追加用户授权记录')
|
|
old_approvals={c['approval_comment_id'] for c in old_changes}
|
|
if changes[-1]['approval_comment_id'] in old_approvals:
|
|
errors.append('新需求变更不能重复使用旧批准评论')
|
|
if handoff:
|
|
data = read(Path(handoff))
|
|
for key in ['baseline_version','read_commit','requirement_ids','deliverable','acceptance','deferred','requirement_changes','verification']:
|
|
if key not in data:
|
|
errors.append(f'交接缺少:{key}')
|
|
if data.get('baseline_version') != baseline['version']:
|
|
errors.append('交接使用过期基准')
|
|
if not re.fullmatch(r'[a-f0-9]{40}', data.get('read_commit','')):
|
|
errors.append('交接未记录实际仓库提交')
|
|
refs=data.get('requirement_ids', [])
|
|
if not refs or not set(refs) <= known or len(refs) != len(set(refs)):
|
|
errors.append('交接要求编号缺失、重复或无效')
|
|
for key in ['acceptance','deferred','requirement_changes','verification']:
|
|
if not isinstance(data.get(key), list):
|
|
errors.append(f'交接字段应为列表:{key}')
|
|
if not data.get('deliverable') or not data.get('acceptance') or not data.get('verification'):
|
|
errors.append('交接缺少交付、验收或验证说明')
|
|
for change in data.get('requirement_changes', []):
|
|
source = evidence.get(change.get('approval_comment_id'), {})
|
|
if source.get('author_type') != 'member' or source.get('author_id') != baseline['owner_id']:
|
|
errors.append('交接需求变更缺少用户来源;先更新正式变更记录')
|
|
return errors, len(ids)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--handoff', help='JSON交接文件')
|
|
parser.add_argument('--base-ref', help='与已获取的目标分支比较版本及历史来源')
|
|
args=parser.parse_args()
|
|
try:
|
|
errors, count=check(args.handoff, args.base_ref)
|
|
except (KeyError, TypeError, ValueError, OSError) as exc:
|
|
print(f'需求材料无法验证:{exc}', file=sys.stderr)
|
|
sys.exit(1)
|
|
if errors:
|
|
print('\n'.join(errors),file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f'需求结构检查通过:{count}项;来源和覆盖完整。仍须人工核对授权语义与实际体验。')
|