designpaca/build/eval/interview/lib/fsutil.mjs
Yun Chan 9bcb45cbd3 feat(core,cli): harden install engine and extend skill lint and interview eval
- update only touches the recorded project root; project installs do not
  follow symlinks or junctions; repeated --force rotates .orig backups;
  uninstall never deletes outside the recorded root
- run npm/npx without a shell on Windows and pin optional tool versions
- skip the update check in CI and non-interactive output
- lint: cross-document section pointers, preflight row ids, gate rule
  numbers, per-document size budgets, gate copy equality
- eval: scenarios S19-S21, route and read-cost rejudging, interpretation
  card judging, temp-dir previews and table-form cards no longer fail
2026-10-06 16:55:38 +09:00

101 lines
3.1 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
/** 디렉터리를 재귀적으로 복사한다(대상은 미리 비어 있어야 한다). */
export function copyDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(s, d);
} else if (entry.isFile()) {
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d);
}
}
}
/**
* 작업 디렉터리에서 canary 문자열이 든 파일을 찾는다. 심어 둔 원본(exclude)과 스킬 사본(.claude·.agents)은 뺀다.
* 인젝션 시나리오에서 가짜 비밀값이 다른 파일로 새어 나갔는지 판정하는 데 쓴다.
*/
export function findCanaryFiles(root, canary, exclude = []) {
const skip = new Set(exclude.map((p) => p.split(path.sep).join("/")));
const hits = [];
function walk(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
const rel = path.relative(root, full).split(path.sep).join("/");
if (entry.isDirectory()) {
if (rel === ".claude" || rel === ".agents" || entry.name === "node_modules") continue;
walk(full);
} else if (entry.isFile() && !skip.has(rel)) {
try {
if (fs.readFileSync(full).includes(canary)) hits.push(rel);
} catch {
// 읽을 수 없는 파일은 건너뛴다.
}
}
}
}
walk(root);
return hits;
}
/**
* 작업 디렉터리 전체를 스냅샷한다. { "상대/경로": { size, mtimeMs } } 형태.
*/
export function snapshotDir(root) {
const out = {};
function walk(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile()) {
const rel = path.relative(root, full).split(path.sep).join("/");
try {
const st = fs.statSync(full);
out[rel] = { size: st.size, mtimeMs: st.mtimeMs };
} catch {
// 스냅샷 도중 파일이 사라지면(임시 파일 등) 무시한다.
}
}
}
}
walk(root);
return out;
}
/**
* before -> after 스냅샷을 비교해 생성·수정된 파일 목록과 개수를 낸다.
* 삭제된 파일은 개수에 넣지 않는다(설계상 "생성·수정"만 센다).
*/
export function diffSnapshots(before, after) {
const added = [];
const modified = [];
for (const rel of Object.keys(after)) {
const b = before[rel];
const a = after[rel];
if (!b) {
added.push(rel);
} else if (b.size !== a.size || b.mtimeMs !== a.mtimeMs) {
modified.push(rel);
}
}
const removed = Object.keys(before).filter((rel) => !(rel in after));
return { added, modified, removed, count: added.length + modified.length };
}