/** * lib/provenance.mjs — 도입 소스 출처(IMPORTED_SOURCES.json) 검사. 릴리스 build·register --approve·HAL 승인이 공유하는 순수 규칙(0.2.0 검토 수정, 차단 항목 11). * * 깨끗한 출처(clean provenance)란: 각 소스가 40자리 고정 커밋(commit)을 가지고, 파일 목록이 비어 있지 않으며(각 항목에 from·to·origHash), * upstream 기록이 있으면 완전하고(version·branch·headCommit) headCommit 이 commit 과 같고 workingTreeDirty 가 정확히 false 인 것. * - `--allow-dirty` 로 참조 저장소의 작업 트리 변경을 함께 가져온 기록(workingTreeDirty=true)은 "원본 저장소에 아직 없는 코드" 를 담고 있어 릴리스로 승인할 수 없다. * 원본 저장소에 커밋되면 pin 을 올려 `scripts/import-sources.mjs` 를 다시 실행해 깨끗한 기록을 만든다. * - upstream 칸이 없는 기록은 0.1.x 의 import-sources 가 만든 것이다(그 스크립트는 깨끗한 고정 커밋 체크아웃에서만 가져왔다) → legacy 로 표시하고 받아들인다. * 모르는 형태·빈 목록은 모두 "불완전" 으로 보아 거절한다(fail-closed). * @param {object} manifest IMPORTED_SOURCES.json 내용 * @returns {{ ok: boolean, problems: string[], sources: { name: string, commit: string|null, upstream: object|null, legacy: boolean }[] }} */ export function checkProvenance(manifest) { const problems = []; const sources = []; if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.sources)) return { ok: false, problems: ['IMPORTED_SOURCES: sources 목록이 없습니다(불완전한 기록)'], sources }; if (!manifest.sources.length) return { ok: false, problems: ['IMPORTED_SOURCES: 도입 소스가 하나도 기록되지 않았습니다(불완전한 기록)'], sources }; for (const s of manifest.sources) { const name = (s && typeof s.name === 'string' && s.name) || '(이름 없음)'; const up = s && s.upstream && typeof s.upstream === 'object' ? s.upstream : null; const summary = { name, commit: s && typeof s.commit === 'string' ? s.commit : null, upstream: up ? { version: up.version ?? null, branch: up.branch ?? null, headCommit: up.headCommit ?? null, workingTreeDirty: up.workingTreeDirty } : null, legacy: !up }; sources.push(summary); if (!s || typeof s !== 'object') { problems.push(`${name}: 기록 형식이 올바르지 않습니다`); continue; } if (!/^[0-9a-f]{40}$/.test(String(s.commit || ''))) problems.push(`${name}: 고정 커밋(commit)이 40자리 커밋 해시가 아닙니다 (${String(s.commit || '없음').slice(0, 12)})`); if (!Array.isArray(s.files) || !s.files.length) problems.push(`${name}: 가져온 파일 목록이 비어 있습니다(불완전한 기록)`); else if (s.files.some((f) => !f || typeof f.from !== 'string' || typeof f.to !== 'string' || typeof f.origHash !== 'string')) problems.push(`${name}: 파일 기록에 from·to·origHash 가 빠진 항목이 있습니다`); if (!up) continue; // 0.1.x 기록: 깨끗한 고정 커밋 체크아웃만 받던 스크립트가 만든 것(legacy) if (up.workingTreeDirty !== false) problems.push(`${name}: upstream.workingTreeDirty=${JSON.stringify(up.workingTreeDirty)} — 원본 저장소에 커밋되지 않은 작업 트리 변경을 함께 가져온 기록은 릴리스로 승인할 수 없습니다. 원본에 커밋한 뒤 pin 을 올려 scripts/import-sources.mjs 를 다시 실행하세요`); if (typeof up.version !== 'string' || !up.version) problems.push(`${name}: upstream.version 이 없습니다(불완전한 기록)`); if (typeof up.branch !== 'string' || !up.branch) problems.push(`${name}: upstream.branch 가 없습니다(불완전한 기록)`); if (!/^[0-9a-f]{40}$/.test(String(up.headCommit || ''))) problems.push(`${name}: upstream.headCommit 이 없거나 형식이 틀립니다(불완전한 기록)`); else if (up.headCommit !== s.commit) problems.push(`${name}: upstream.headCommit(${String(up.headCommit).slice(0, 7)}) ≠ 고정 커밋 commit(${String(s.commit).slice(0, 7)}) — 기록이 어긋났습니다. scripts/import-sources.mjs 를 다시 실행하세요`); } return { ok: problems.length === 0, problems, sources }; }