40-validation/validate-build-contract.py

#!/usr/bin/env python3
from __future__ import annotations
import argparse, hashlib, json, os, shutil, subprocess, sys, tempfile
from pathlib import Path
from evidence_lifecycle import seal_evidence

VALIDATOR_REF='engineering-tools:40-validation/validate-build-contract.py'

def run(cmd,cwd):
    p=subprocess.run(cmd,cwd=cwd,text=True,capture_output=True)
    if p.returncode:
        raise RuntimeError(f"command failed ({' '.join(cmd)}): {p.stderr.strip() or p.stdout.strip()}")
    return p

def sha256(path):
    h=hashlib.sha256()
    with path.open('rb') as f:
        for c in iter(lambda:f.read(1024*1024),b''): h.update(c)
    return h.hexdigest()

def tree_digest(root):
    h=hashlib.sha256()
    ignored={'node_modules','.git'}
    for p in sorted(x for x in root.rglob('*') if x.is_file() and not any(part in ignored for part in x.parts)):
        h.update(p.relative_to(root).as_posix().encode()); h.update(b'\0'); h.update(sha256(p).encode()); h.update(b'\n')
    return h.hexdigest()

def atomic_json(path,data):
    path.parent.mkdir(parents=True,exist_ok=True)
    tmp=path.with_suffix(path.suffix+'.tmp'); tmp.write_text(json.dumps(data,indent=2,ensure_ascii=False)+'\n'); os.replace(tmp,path)

def main():
    ap=argparse.ArgumentParser()
    ap.add_argument('repository_roots',type=Path)
    ap.add_argument('runtime_root',type=Path)
    ap.add_argument('--work-item',default='KT-0013')
    args=ap.parse_args()
    roots=args.repository_roots.resolve(); ep=roots/'engineering-platform'; runtime=args.runtime_root.resolve()
    backend=ep/'10-backend'; frontend=ep/'20-frontend'
    required=[
      roots/'enterprise-architecture/85-reference-artifacts/reference-model/build-contract-model.md',
      roots/'solution-architecture/90-deployment/MVP-001-build-contract.md',
      ep/'00-overview/BUILD_ARCHITECTURE.md',ep/'00-overview/BUILD_CONTRACT.md']
    missing=[str(p) for p in required if not p.is_file()]
    if missing: raise RuntimeError('missing build-contract artifacts: '+', '.join(missing))
    phases={}
    run(['go','test','./...'],backend); phases['goTest']='PASSED'
    run(['go','vet','./...'],backend); phases['goVet']='PASSED'
    with tempfile.TemporaryDirectory(prefix='kt13-build-') as td:
        binary=Path(td)/'engineering-platform'
        run(['go','build','-trimpath','-o',str(binary),'./cmd/engineering-platform'],backend); phases['backendBuild']='PASSED'
        package=json.loads((frontend/'package.json').read_text())
        if 'test' in package.get('scripts',{}): run(['npm','test'],frontend)
        phases['frontendTest']='PASSED'
        if 'build' in package.get('scripts',{}):
            run(['npm','run','build'],frontend); mode='COMPILED'; front_digest=tree_digest(frontend/os.environ.get('EP_FRONTEND_BUILD_DIR','dist'))
        else: mode='SOURCE_MODULES'; front_digest=tree_digest(frontend)
        phases['frontendBuild']=mode
        out=runtime/'test/builds/engineering-platform'; out.mkdir(parents=True,exist_ok=True)
        shutil.copy2(binary,out/'engineering-platform')
        manifest={'schemaVersion':'1.0','evidenceId':'engineering-platform-build','workItem':args.work_item,'chain':'RC-0003','status':'SUCCESS','sourceRepository':'engineering-platform','backend':{'path':'engineering-platform','sha256':sha256(out/'engineering-platform')},'frontend':{'mode':mode,'sha256':front_digest},'phases':phases}
        atomic_json(out/'build-manifest.json',manifest)
    evidence=seal_evidence(manifest,roots,VALIDATOR_REF,['runtime:test/builds/engineering-platform/build-manifest.json','runtime:test/builds/engineering-platform/engineering-platform'],work_item=args.work_item,chain='RC-0003')
    atomic_json(runtime/'test/reports/validation/build-contract.json',evidence)
    print(json.dumps({'status':'PASSED','chain':'RC-0003','phases':phases},indent=2))
if __name__=='__main__':
    try: main()
    except Exception as e: print(f'ERROR: {e}',file=sys.stderr); raise SystemExit(1)