← Files DXIRARCHIVED FILE

scripts/decision-materials.mjs

7.21 KB · Nov 7, 2026 · 06:12 UTC

↓ Download file

import o from"node:fs";import u from"node:path";import{createHash as U,randomUUID as P}from"node:crypto";import{renderDocument as B}from"./document-template.mjs";const g=l=>U("sha256").update(l).digest("hex"),p=(l,n)=>{if(!l)throw new Error(n)},x=l=>String(l).replace(/[&<>"']/g,n=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[n]),j=l=>{const n=String(l??"");return p(!/(?:\b[A-Za-z]:[\\/]|(?:^|\s)\/(?:Users|home|tmp|var|workspace)\/|bearer\s+[\w.-]+|sk-[\w-]{12,}|[?&](?:token|signature|key|credential|password|secret|X-Amz-[^=]*)=)/i.test(n),"Keep private paths and credentials out of decision materials."),n};function A(l,n){p(typeof n=="string"&&!/[\\:\u0000-\u001f?#%]/.test(n)&&n.split("/").every(i=>i&&i!=="."&&i!==".."),"Unsafe material path.");let c=l;for(const i of n.split("/")){c=u.join(c,i);try{o.mkdirSync(c)}catch(r){if(r.code!=="EEXIST")throw r}const t=o.lstatSync(c);p(t.isDirectory()&&!t.isSymbolicLink(),"Material folders must not be links.")}return c}function Z(l,n,c,i){const t=u.join(A(l,u.posix.dirname(n)),u.posix.basename(n));if(o.existsSync(t)){const d=o.lstatSync(t);p(d.isFile()&&!d.isSymbolicLink(),"Material files must not be links."),p(o.readFileSync(t).equals(c),"Existing decision bytes changed; retain them and use a new version.")}else{const d=u.join(A(l,`${i}/material-staging`),`${P()}.partial`),h=o.openSync(d,"wx");try{o.writeFileSync(h,c),o.fsyncSync(h)}finally{o.closeSync(h)}try{o.linkSync(d,t)}finally{o.unlinkSync(d)}}const r=o.readFileSync(t);return p(r.equals(c),"Material readback failed."),{relative_path:n,sha256:g(r),size_bytes:r.length}}function K({base:l,task:n,receipts:c}){p(Array.isArray(c)&&c.length>0&&c.length<=100,"Supply saved material receipts.");const i=c.map(t=>{let r=t.status==="host_reported"&&t.files?.length>0;for(const d of t.files??[]){const h=d.relative_path;p(typeof h=="string"&&h.startsWith(`${n}/`)&&!/[\\:\u0000-\u001f?#%]/.test(h)&&h.split("/").every(f=>f&&f!=="."&&f!==".."),"Evidence must stay inside its task.");try{let f=l;for(const $ of h.split("/"))f=u.join(f,$),p(!o.lstatSync(f).isSymbolicLink(),"Evidence cannot use links.");const y=o.readFileSync(f);r=r&&y.length===d.size_bytes&&g(y)===d.sha256}catch{r=!1}}return{...t,status:r?"host_reported":"missing",files:r?t.files:[]}});return{verified_local:i.every(t=>t.status==="host_reported"),uploaded:!1,material_receipts:i}}function R({base:l,task:n,support:c,stage:i,input:t}){p(i&&/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(i.id)&&typeof i.label=="string","A stable decision stage is required."),p(Array.isArray(t.entries)&&t.entries.length>0&&t.entries.length<=100,"Supply 1-100 actual reviewed decisions."),j(JSON.stringify(t));const r=g(JSON.stringify(t)),d=(e,a)=>Z(l,e,a,c),h=A(l,`${c}/materials`),f=u.join(h,`${r}.json`),y=Buffer.from(JSON.stringify(t));o.existsSync(f)?p(!o.lstatSync(f).isSymbolicLink()&&o.readFileSync(f).equals(y),"Material recovery input changed."):o.writeFileSync(f,y,{flag:"wx"});const $=`${n}/근거/원문`,v=[],F=[];for(const e of t.entries){const a=e.review;p(a&&/^[a-f0-9]{64}$/.test(a.decision_version)&&["passed","held","rejected","failed"].includes(a.status)&&Array.isArray(a.captures),"Read the server review before saving its material version.");const M=[];for(const s of a.captures)if(s.status==="read"){p(g(s.text)===s.sha256,"Captured source content differs from the reviewed version.");const m=d(`${$}/${s.sha256}.txt`,Buffer.from(s.text));v.push({...m,...Object.fromEntries(["source_id","url","observed_at","status","limitation"].map(q=>[q,s[q]])),target:a.target,decision_version:a.decision_version}),M.push({path:m.relative_path,label:`${s.source_id} · ${s.observed_at}`})}else v.push({target:a.target,decision_version:a.decision_version,source_id:s.source_id,status:"unavailable",limitation:s.limitation});const _=e.candidate,w=[{type:"paragraph",text:`목적: ${j(e.origin?.completion_criteria||e.purpose||"원과제 기준 확인 필요")}`},{type:"paragraph",text:`별도 의미 검토: ${{passed:"통과",held:"보류",rejected:"반려",failed:"실패"}[a.status]} · ${a.summary}`},{type:"paragraph",text:`변경 내용: ${j(e.change_reason||"이 버전의 판단과 원문을 보존했습니다. 과거에 작성된 자료로 소급하지 않습니다.")}`},{type:"table",columns:["요건","검토 결과","설명"],rows:a.checks.map(s=>[e.criteria?.find(m=>m.id===s.criterion_id)?.requirement||s.criterion_id,s.status,s.explanation])}];if(_){w.push({type:"paragraph",text:`판단안: ${_.rationale||"미작성"} · 다음 행동: ${_.next_action||"추가 확인"}`});for(const s of _.judgments??[])w.push({type:"table",columns:["확인 사실 / 원문 위치","판단 설명","추론","반대 근거","미확인 / 다음 행동"],rows:[[s.facts.map(m=>`${m.fact}
${m.location}
${m.excerpt}`).join(`
`),s.explanation,s.inference,s.counterevidence,[...s.unknowns,s.next_action].join(`
`)]]})}a.unknowns.length&&w.push({type:"list",items:a.unknowns}),F.push({heading:_?.name||"목적과 선정 기준",blocks:w,links:M})}const z=`${i.relative_dir}/근거/${r}/근거목록.json`,D=d(z,Buffer.from(JSON.stringify({version:r,stage_id:i.id,evidence:v,decisions:t.entries.map(e=>({target:e.review.target,decision_version:e.review.decision_version,reviewed_at:e.review.reviewed_at,policy:e.review.policy,origin:e.origin,criteria:e.criteria,status:e.review.status,summary:e.review.summary,checks:e.review.checks,unknowns:e.review.unknowns}))},null,2))),k=`${i.relative_dir}/설명자료/${r}/검토안내.html`;let S=B({layout:"document",language:"ko",title:`${i.label} — 판단 설명`,sections:F.map(({links:e,...a})=>a)});const O=[...new Map(v.filter(e=>e.relative_path).map(e=>[e.relative_path,{path:e.relative_path,label:e.source_id}])).values(),{path:z,label:"버전별 근거 목록"}].map(e=>`<li><a href="${x(u.posix.relative(u.posix.dirname(k),e.path))}">${x(e.label)}</a></li>`).join("");S=S.replace("</main>",`<section><h2>보존된 근거</h2><ul>${O}</ul><p>로컬 보관 자료입니다. 서버 업로드 또는 사업상 완료를 뜻하지 않습니다.</p></section></main>`);const J=d(k,Buffer.from(S));for(const e of v.filter(a=>a.relative_path))p(g(o.readFileSync(u.join(l,e.relative_path)))===e.sha256,"Evidence link or content changed.");const b=`${n}/설명자료/검토안내-${r.slice(0,16)}.html`,N=B({layout:"document",language:"ko",title:"단계별 검토 안내",sections:[{heading:i.label,blocks:[{type:"paragraph",text:"이 자료는 실제 수행한 판단 단계의 보존본입니다. 미수행 단계나 고객 활동의 근거를 만들지 않습니다."}]}]}).replace("</main>",`<p><a href="${x(u.posix.relative(u.posix.dirname(b),k))}">단계 설명자료와 근거 보기</a></p></main>`);d(b,Buffer.from(N));const L=[{...D,role:"evidence"},{...J,role:"explanation"},...new Map(v.filter(e=>e.relative_path).map(e=>[e.relative_path,{relative_path:e.relative_path,sha256:e.sha256,size_bytes:e.size_bytes,role:"evidence"}])).values()],E={version:r,stage_id:i.id,relative_dir:i.relative_dir,overview_path:b,material_receipts:t.entries.map(e=>({stage_id:i.id,target:e.review.target,decision_version:e.review.decision_version,status:"host_reported",files:L})),verified_local:!0,uploaded:!1};return d(`${c}/materials/${r}-receipt.json`,Buffer.from(JSON.stringify(E,null,2))),E}export{R as saveDecisionMaterials,K as verifyDecisionMaterials};

SHA-256: c8d22e4b719b35443b56a64d2b98bb053b1c24e677ae62d196bd8fa5878adec7