40-validation/validate-artifact-manifests.py

#!/usr/bin/env python3
import argparse, json, sys
from pathlib import Path
from evidence_lifecycle import seal_evidence, write_evidence

REQUIRED_TOP = {"manifestVersion","artifact","realizes","implements","documentation","ownership","quality"}
REQUIRED_ARTIFACT = {"id","type","name","status"}
REQUIRED_REALIZES = {"capabilities","referenceBuildingBlocks","solutionBuildingBlocks","requirements"}

def validate_test_coverage(manifest, repository_root, errors):
    use_cases = manifest.get("implements", {}).get("useCases", [])
    coverage = manifest.get("quality", {}).get("testCoverage", {})
    for use_case in use_cases:
        use_case_id = use_case.get("id", "unknown")
        entries = coverage.get(use_case_id, [])
        if not entries:
            errors.append(f"use case {use_case_id} has no human-readable testCoverage")
            continue
        categories = set()
        for entry in entries:
            category = entry.get("category")
            purpose = entry.get("purpose", "").strip()
            relative = entry.get("path")
            if not category or not purpose or not relative:
                errors.append(f"use case {use_case_id} has incomplete testCoverage entry: {entry}")
                continue
            categories.add(category)
            if not (repository_root / relative).exists():
                errors.append(f"use case {use_case_id} missing covered test path: {relative}")
        required_categories = set(use_case.get("requiredTestCategories", []))
        missing = required_categories - categories
        if missing:
            errors.append(f"use case {use_case_id} missing required test categories: {sorted(missing)}")


ALLOWED_CODE_AREA_CLASSES = {
    "domain-logic", "security-critical", "application-logic", "adapter",
    "transport", "declaration-only", "generated", "composition-root"
}
ALLOWED_VERIFICATION_STATUS = {
    "directly-verified", "indirectly-verified", "not-applicable", "verification-gap"
}

def validate_verification_coverage(manifest, repository_root, errors):
    for area in manifest.get("verification", {}).get("codeAreas", []):
        relative = area.get("path")
        classification = area.get("classification")
        status = area.get("status")
        prefix = f"code area {relative or 'unknown'}"
        if not relative or not (repository_root / relative).exists():
            errors.append(f"{prefix} path does not exist")
        if classification not in ALLOWED_CODE_AREA_CLASSES:
            errors.append(f"{prefix} has invalid classification: {classification}")
        if status not in ALLOWED_VERIFICATION_STATUS:
            errors.append(f"{prefix} has invalid verification status: {status}")
        evidence = area.get("evidence", [])
        if status in {"directly-verified", "indirectly-verified"} and not evidence:
            errors.append(f"{prefix} requires verification evidence")
        for item in evidence:
            p = item.get("path")
            if not item.get("category") or not item.get("purpose", "").strip() or not p:
                errors.append(f"{prefix} has incomplete evidence: {item}")
            elif not (repository_root / p).exists():
                errors.append(f"{prefix} evidence path does not exist: {p}")
        if classification == "declaration-only" and status == "directly-verified":
            errors.append(f"{prefix} must not require an artificial direct test")
        if classification in {"domain-logic", "security-critical", "application-logic"} and status == "not-applicable":
            errors.append(f"{prefix} critical logic cannot be not-applicable")
        if status in {"indirectly-verified", "not-applicable", "verification-gap"} and not area.get("reason"):
            errors.append(f"{prefix} status {status} requires a reason")
        if status == "verification-gap" and not area.get("severity"):
            errors.append(f"{prefix} verification gap requires severity")


def validate_quality_model(manifest, repository_root, errors):
    quality = manifest.get("qualityModel")
    if quality is None:
        return
    goals = quality.get("goals", [])
    if not goals:
        errors.append("qualityModel requires at least one goal")
    goal_ids=set()
    for goal in goals:
        gid=goal.get("id")
        if not gid or gid in goal_ids:
            errors.append(f"invalid or duplicate quality goal id: {gid}")
        goal_ids.add(gid)
        if not goal.get("attribute") or not goal.get("statement", "").strip():
            errors.append(f"quality goal {gid} requires attribute and statement")
    for evidence in quality.get("evidence", []):
        if not (repository_root / evidence).exists():
            errors.append(f"quality evidence path does not exist: {evidence}")
    for gap in quality.get("gaps", []):
        if not gap.get("id") or not gap.get("severity") or not gap.get("statement", "").strip():
            errors.append(f"incomplete quality gap: {gap}")

def fail(msg):
    print(f"ERROR: {msg}", file=sys.stderr)
    return 1


def validate_human_readable_contracts(manifest, repository_root, errors):
    documentation = manifest.get("documentation", {})
    for relative in documentation.get("humanReadableContracts", []):
        path = repository_root / relative
        if not path.exists():
            errors.append(f"missing human-readable contract: {relative}")

    for use_case in manifest.get("implements", {}).get("useCases", []):
        for field in ("code", "documentation"):
            relative = use_case.get(field)
            if not relative or not (repository_root / relative).exists():
                errors.append(f"use case {use_case.get('id','unknown')} missing {field}: {relative}")
        for relative in use_case.get("tests", []):
            if not (repository_root / relative).exists():
                errors.append(f"use case {use_case.get('id','unknown')} missing test: {relative}")

def main():
    ap=argparse.ArgumentParser()
    ap.add_argument('repository', type=Path)
    ap.add_argument('--solution-architecture', type=Path)
    ap.add_argument('--enterprise-architecture', type=Path)
    ap.add_argument('--evidence-output', type=Path)
    ap.add_argument('--repository-roots', type=Path)
    ap.add_argument('--work-item', default='manual')
    ap.add_argument('--chain', default='RC-0002')
    args=ap.parse_args()
    files=sorted(args.repository.rglob('artifact-manifest.json'))
    if not files:
        return fail('no artifact-manifest.json found')
    ids=set(); errors=[]
    for path in files:
        try: data=json.loads(path.read_text(encoding='utf-8'))
        except Exception as exc:
            errors.append(f'{path}: invalid JSON: {exc}'); continue
        missing=REQUIRED_TOP-data.keys()
        if missing: errors.append(f'{path}: missing top fields {sorted(missing)}'); continue
        if data.get('manifestVersion')!='1': errors.append(f'{path}: unsupported manifestVersion')
        if REQUIRED_ARTIFACT-data['artifact'].keys(): errors.append(f'{path}: incomplete artifact')
        if REQUIRED_REALIZES-data['realizes'].keys(): errors.append(f'{path}: incomplete realizes')
        aid=data['artifact'].get('id')
        if aid in ids: errors.append(f'{path}: duplicate artifact.id {aid}')
        ids.add(aid)
        module=args.repository/data['ownership'].get('modulePath','')
        if not module.exists(): errors.append(f'{path}: modulePath does not exist: {module}')
        for docroot,key in [(args.solution_architecture,'solutionArchitecture'),(args.enterprise_architecture,'enterpriseArchitecture')]:
            if docroot:
                for rel in data.get('documentation',{}).get(key,[]):
                    if not (docroot/rel).exists(): errors.append(f'{path}: missing documentation reference {key}:{rel}')
        for rel in data.get('quality',{}).get('tests',[]):
            if not (args.repository/rel).exists(): errors.append(f'{path}: missing test path {rel}')
        validate_human_readable_contracts(data, args.repository, errors)
        validate_test_coverage(data, args.repository, errors)
        validate_verification_coverage(data, args.repository, errors)
        validate_quality_model(data, args.repository, errors)
    if errors:
        for e in errors: print('ERROR:',e,file=sys.stderr)
        return 1
    output=f'OK: validated {len(files)} artifact manifests'
    print(output)
    if args.evidence_output:
        if not args.repository_roots:
            return fail('--repository-roots is required with --evidence-output')
        payload=seal_evidence(
            {
                'evidenceType':'artifact-manifest-validation',
                'validator':'engineering-tools:40-validation/validate-artifact-manifests.py',
                'repository':'engineering-platform',
                'result':'passed',
                'exitCode':0,
                'output':output,
            },
            args.repository_roots,
            'engineering-tools:40-validation/validate-artifact-manifests.py',
            [
                'engineering-platform:10-backend/internal/artifacts/artifact-manifest.json',
                'runtime:test/installations/engineering-platform/web/src/platform-shell/artifact-manifest.json',
            ],
            work_item=args.work_item, chain=args.chain,
        )
        write_evidence(args.evidence_output,payload)
    return 0
if __name__=='__main__': raise SystemExit(main())