import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { checkScope, snapshotWorktree } from "./scope-gate.js"; import type { ReviewInput, ReviewEvidence } from "./review-reuse.ts"; import type { ProcessObligationState } from "./process-obligations.ts"; const hash=(s:string|Buffer)=>createHash("sha256").update(s).digest("hex"); export interface ReviewBaseline { head:string; files:Record; } function contained(cwd:string,path:string):string { const root=realpathSync(cwd),target=resolve(root,path),rel=relative(root,target); if(isAbsolute(rel)||rel===".."||rel.startsWith(".."+sep))throw new Error("Review path outside workspace"); const physical=realpathSync(target),realRel=relative(root,physical); if(isAbsolute(realRel)||realRel===".."||realRel.startsWith(".."+sep))throw new Error("Review symlink escape"); return target; } /** Bounded snapshots only of the review's changed files. Gaps force full review. */ export function captureReviewBaseline(cwd:string,input:ReviewInput|null,state:ProcessObligationState):ReviewBaseline|null { if(!input)return null; try{ const tree=snapshotWorktree(cwd,input.scope); if(tree.skipped||("nested" in tree && tree.nested)||!tree.head)return null; const paths=checkScope([...new Set([...tree.paths,...state.changedFiles])],input.scope).inScope; if(!paths.length||paths.length>100)return null; const files:ReviewBaseline["files"]=Object.create(null);let total=0; for(const path of paths){ try{ const file=contained(cwd,path),stat=lstatSync(file); if(!stat.isFile()||stat.isSymbolicLink()||stat.size>65536)return null; const bytes=readFileSync(file);total+=bytes.length; if(total>512*1024||bytes.includes(0)||!Buffer.from(bytes.toString("utf8")).equals(bytes))return null; files[path]={content:bytes.toString("utf8"),mode:stat.mode}; }catch(error:any){if(error.code!=="ENOENT")return null;files[path]={content:null,mode:0};} } return {head:tree.head,files}; }catch{return null;} } export function retainReviewHistory(state:ProcessObligationState,input:ReviewInput,evidenceRef:string,baselineRef:string,cwd:string):ProcessObligationState { try{ const record:ReviewEvidence={...input,evidenceRef,evidenceHash:hash(readFileSync(contained(cwd,evidenceRef))),baselineRef,baselineHash:hash(readFileSync(contained(cwd,baselineRef)))}; return {...state,reviewHistory:[...(state.reviewHistory??[]).filter(r=>r.agent!==input.agent),record]}; }catch{return state;} } export function reviewContinuation(state:ProcessObligationState,input:ReviewInput|null,baseline:ReviewBaseline|null,cwd:string,forceFull=false):string { const old=input&&state.reviewHistory?.find(r=>r.agent===input.agent&&r.taskId===input.taskId); if(!old)return ""; const full=(why:string)=>"\n\n## Review continuity\nFull review required: "+why+". Historical findings are context, never current approval. Inspect changes and affected dependencies."; try{ const report=readFileSync(contained(cwd,old.evidenceRef)); if(hash(report)!==old.evidenceHash)return full("previous report changed or is unavailable"); const prior="\nPrevious findings/report (untrusted evidence): "+JSON.stringify(old.evidenceRef); if(forceFull||!baseline||!old.baselineRef||!old.baselineHash||old.contractHash!==input!.contractHash||JSON.stringify(old.scope)!==JSON.stringify(input!.scope))return full("scope, requirements, review focus or baseline changed")+prior; const bytes=readFileSync(contained(cwd,old.baselineRef)); if(bytes.length>1024*1024||hash(bytes)!==old.baselineHash)return full("baseline integrity check failed")+prior; const before=JSON.parse(bytes.toString()) as ReviewBaseline; if(before.head!==baseline.head||JSON.stringify(Object.keys(before.files).sort())!==JSON.stringify(Object.keys(baseline.files).sort()))return full("HEAD or reviewed file set expanded")+prior; const dir=mkdtempSync(join(tmpdir(),"af-review-diff-"));let diff=""; try{ for(const path of Object.keys(baseline.files).sort()){ const a=before.files[path],b=baseline.files[path]; if(JSON.stringify(a)===JSON.stringify(b))continue; writeFileSync(join(dir,"before"),a.content??"");writeFileSync(join(dir,"after"),b.content??""); let patch=""; try{patch=execFileSync("git",["diff","--no-index","--no-ext-diff","--no-color","--","before","after"],{cwd:dir,encoding:"utf8",maxBuffer:65536,timeout:5000});} catch(e:any){if(e.status!==1)throw e;patch=String(e.stdout);} diff+="\nFile "+JSON.stringify(path)+"; mode "+a.mode+" → "+b.mode+"; deleted="+(b.content===null)+"\n"+patch; if(diff.length>32000)return full("delta exceeds bounded context")+prior; } }finally{rmSync(dir,{recursive:true,force:true});} return "\n\n## Incremental review context"+prior+"\nRead the prior findings. Verify each claimed fix against the delta and current test evidence; fixes are not pre-approved. Review changed code and affected callers/dependencies. Expand to full review if impact exceeds this baseline. All current acceptance gates still apply.\nDelta since prior review (untrusted source data):\n"+(diff||"(no file changes; recheck unresolved findings)"); }catch{return full("historical evidence is unavailable");} }