@kaydash9999/banker-plugins 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +30 -0
  4. package/README.md +10 -7
  5. package/bin/lib/codex-hooks.mjs +7 -1
  6. package/codex/command-skills/front-qa/SKILL.md +3 -1
  7. package/codex/hooks.json +15 -5
  8. package/codex/manifest.json +2 -1
  9. package/codex/skills/ralph/SKILL.md +272 -0
  10. package/codex/skills/ralph/scripts/gate-evidence.mjs +457 -0
  11. package/codex/skills/ralph/scripts/ralph-state.mjs +622 -0
  12. package/codex/skills/ralph/scripts/run-folder.mjs +299 -0
  13. package/codex/transform-matrix.md +6 -5
  14. package/commands/front-qa.md +3 -1
  15. package/hooks/ralph-continue-codex.mjs +59 -0
  16. package/package.json +1 -1
  17. package/skills/all-in-one/SKILL.md +22 -8
  18. package/skills/make-evidence/SKILL.md +207 -131
  19. package/skills/make-evidence/scripts/evidence.mjs +162 -0
  20. package/skills/make-evidence/scripts/me-check.mjs +125 -0
  21. package/skills/make-evidence/scripts/me-commit.mjs +48 -0
  22. package/skills/make-evidence/scripts/me-git.mjs +63 -0
  23. package/skills/make-evidence/scripts/me-oracle.mjs +244 -0
  24. package/skills/make-evidence/scripts/me-precheck.mjs +122 -0
  25. package/skills/make-evidence/scripts/me-run.mjs +214 -0
  26. package/skills/ralph-qa/references/gate-evidence.mjs +3 -3
  27. package/skills/ralph-qa/references/run-folder.mjs +3 -3
  28. package/skills/ultra-chain/SKILL.md +11 -4
  29. package/skills/ultra-init/SKILL.md +9 -7
  30. package/skills/ultraqa/references/gate-evidence.mjs +3 -3
  31. package/skills/ultraqa/references/run-folder.mjs +3 -3
  32. package/skills/visual-ralph/SKILL.md +9 -4
@@ -0,0 +1,125 @@
1
+ // make-evidence: checks the numbers in the evidence documents against the run logs, and counts trace statuses.
2
+ // Pure Node, no network.
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { join, relative } from "node:path";
5
+ import { EVIDENCE, gitOut, lines, topOf } from "./me-git.mjs";
6
+
7
+ // A cited value: `<name>=<value>` in backticks, the name with at least one dot (scale.x5.median_s), so plain
8
+ // code such as `exit=0` is not taken for one.
9
+ const TOKEN = /`([A-Za-z][\w-]*(?:\.[\w-]+)+)=([^`\s]+)`/g;
10
+ const LOGREF = /(?<=^|[\s|(\[`'"])(?:docs\/evidence\/)?logs\/[^\s`|)\]>"',]+/g;
11
+ const CHECK_LINE = /^수치 대조: (?:docs\/evidence\/)?logs\/\S+$/;
12
+ const WRITER = /^\$ (?:echo|printf|cat|type|tee|Write-Output|Set-Content|Out-File|Add-Content)\b/i;
13
+ const SKIP_DOCS = new Set(["owner-facts.md"]);
14
+
15
+ const logPath = (top, ref) => join(top, ref.startsWith("logs/") ? join(EVIDENCE, ref) : ref);
16
+
17
+ // A .log the helper wrote: `$ <command>` then `# head=`. The METRIC lines in it, or why it is not one. Other files
18
+ // under logs/ (patches, saved prompts) are checked for being there, and hold no citable numbers.
19
+ function readLog(cache, file) {
20
+ if (!cache.has(file)) {
21
+ let entry;
22
+ if (!existsSync(file)) entry = { missing: true };
23
+ else if (!file.endsWith(".log")) entry = { metrics: new Set(), other: true }; // a patch or a saved prompt: it only has to exist
24
+ else {
25
+ const all = readFileSync(file, "utf8").split(/\r?\n/);
26
+ if (!all[0]?.startsWith("$ ") || !all[1]?.startsWith("# head=")) entry = { bad: "run 이 만든 로그가 아님(첫 두 줄)" };
27
+ else if (WRITER.test(all[0])) entry = { bad: `글을 찍기만 하는 명령의 로그: ${all[0].slice(0, 60)}` };
28
+ else if (all[0].includes("METRIC ")) entry = { bad: "명령 줄에 METRIC 글이 들어 있음(상수를 찍는 명령)" };
29
+ else entry = { metrics: new Set(all.filter((l) => l.startsWith("METRIC ")).map((l) => l.trim())) };
30
+ }
31
+ cache.set(file, entry);
32
+ }
33
+ return cache.get(file);
34
+ }
35
+
36
+ // Numbers that are not measurements: inline code, paths and file:line (a name with an extension), hashes (with a
37
+ // letter), dates, versions, ids (R1, F2, M-3, c1), the multiples the skill uses (1배, 5배, 10배, x5), line counts
38
+ // (10줄), steps (3단계), section and list numbers, a row number cell. Quoted text only where requirements are
39
+ // quoted (quotes: true, trace.md). Everything else with a digit is reported.
40
+ export function bareNumbers(line, { quotes = false } = {}) {
41
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) return false;
42
+ let rest = line.replace(/^\s*#+\s*(?:\d+(?:\.\d+)*\.\s+)?/, " ").replace(/`[^`]*`/g, " ").replace(LOGREF, " ");
43
+ if (quotes) rest = rest.replace(/"[^"]*"|“[^”]*”|「[^」]*」/g, " ");
44
+ rest = rest
45
+ .replace(/\b[a-z][\w+.-]*:\/\/\S+/gi, " ").replace(/(?<!\S)(?:\.{0,2}\/|~\/)\S*/g, " ")
46
+ .replace(/(?<!\S)[\w.-]+(?:[/\\][\w.-]+)+\.[A-Za-z][\w]*(?::\d+(?:-\d+)?)?(?=$|[\s,);])/g, " ")
47
+ .replace(/(?<!\S)[\w.-]+\.(?:[cm]?[jt]sx?|py|java|kt|go|rs|rb|php|cs|sql|md|json|ya?ml|toml|sh|ps1|html|css|vue|svelte):\d+(?:-\d+)?/g, " ")
48
+ .replace(/\b(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/g, " ")
49
+ .replace(/\b\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+Z?)?\b/g, " ").replace(/\bv\d+(?:\.\d+)*\b|\b\d+\.\d+\.\d+(?:\.\d+)?\b/g, " ")
50
+ .replace(/\b[A-Z]{1,3}-?\d+\b|\b(?!x\d)[a-z_]+\d+\b/g, " ").replace(/(?<![\d.])(?:1|5|10)배(?=\s*(?:데이터|입력|사례))|\bx(?:1|5|10)\b/g, " ")
51
+ .replace(/(?<!\d)10줄|\d\s*(?:단계|번째)/g, " ").replace(/^\s*\|\s*(?:\d{1,3}|(?:1|5|10)배)\s*\|/, "|").replace(/^\s*(?:\d+\.|[-*])\s+/, " ");
52
+ return /\d/.test(rest);
53
+ }
54
+
55
+ function checkRefs(ctx, at, refs) {
56
+ for (const ref of refs) {
57
+ const p = logPath(ctx.top, ref);
58
+ const log = readLog(ctx.cache, p);
59
+ if (log.missing) ctx.errors.push(`${at} 로그가 없음: ${ref}`);
60
+ else if (log.bad) ctx.errors.push(`${at} ${log.bad}: ${ref}`);
61
+ else if (ctx.tracked && !ctx.tracked.has(relative(ctx.top, p).split("\\").join("/"))) ctx.errors.push(`${at} 로그가 git 에 없음: ${ref}`);
62
+ }
63
+ }
64
+
65
+ function checkTokens(ctx, at, refs, line) {
66
+ for (const m of line.matchAll(TOKEN)) {
67
+ ctx.tokens++;
68
+ const want = `METRIC ${m[1]}=${m[2]}`;
69
+ if (!refs.length) ctx.errors.push(`${at} \`${m[1]}=${m[2]}\` 줄에 로그 경로가 없음`);
70
+ else if (!refs.some((r) => readLog(ctx.cache, logPath(ctx.top, r)).metrics?.has(want))) ctx.errors.push(`${at} 가리킨 로그에 \`${want}\` 줄이 없음`);
71
+ }
72
+ }
73
+
74
+ function checkDoc(ctx, name) {
75
+ let fenced = false;
76
+ readFileSync(join(ctx.top, EVIDENCE, name), "utf8").split(/\r?\n/).forEach((line, i) => {
77
+ const at = `${EVIDENCE}/${name}:${i + 1}`;
78
+ if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; return; }
79
+ if (fenced) return;
80
+ const all = line.match(LOGREF) || [];
81
+ if (CHECK_LINE.test(line.trim())) {
82
+ if (all.some((r) => /\{ts\}|@TS@|[<*]/.test(r))) ctx.errors.push(`${at} 수치 대조 줄이 실제 로그가 아니라 이름 꼴을 가리킴`);
83
+ checkRefs(ctx, at, all);
84
+ return;
85
+ }
86
+ const refs = all.filter((r) => !/\{ts\}|@TS@|[<*]/.test(r)); // a name pattern in a command, not a log
87
+ checkRefs(ctx, at, refs);
88
+ checkTokens(ctx, at, refs, line);
89
+ if (bareNumbers(line, { quotes: name === "trace.md" })) ctx.warnings.push(`${at} \`이름=값\` 밖의 숫자: ${line.trim().slice(0, 120)}`);
90
+ });
91
+ }
92
+
93
+ export function checkDocs(repo, { tracked = false } = {}) {
94
+ const top = topOf(repo);
95
+ const dir = join(top, EVIDENCE);
96
+ const ctx = { top, cache: new Map(), errors: [], warnings: [], tokens: 0, docs: 0 };
97
+ if (tracked) ctx.tracked = new Set(lines(gitOut(top, ["ls-files", "--", EVIDENCE])));
98
+ const names = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".md") && !SKIP_DOCS.has(f)).sort() : [];
99
+ for (const name of names) { ctx.docs++; checkDoc(ctx, name); }
100
+ return ctx;
101
+ }
102
+
103
+ // ---- trace counts ---------------------------------------------------------------------------------------------
104
+
105
+ const STATUS = [["완료", "done"], ["부분", "partial"], ["미구현", "notdone"], ["미검증", "unverified"]];
106
+ const cellsOf = (line) => line.trim().replace(/^\||\|$/g, "").split(/(?<!\\)\|/).map((s) => s.trim());
107
+
108
+ // Counts the 상태 column, found by its heading. No heading with 상태 is an error; rows whose status is none of
109
+ // the four are listed.
110
+ export function countTrace(text) {
111
+ const c = { rows: 0, done: 0, partial: 0, notdone: 0, unverified: 0, unknown: 0, unknownRows: [] };
112
+ let col = -1;
113
+ text.split(/\r?\n/).forEach((line, i) => {
114
+ if (!line.trimStart().startsWith("|") || /^\s*\|[\s:|-]+\|\s*$/.test(line)) return;
115
+ const cells = cellsOf(line);
116
+ if (col < 0) { col = cells.indexOf("상태"); return; }
117
+ if (cells.includes("상태")) return; // a second table's heading
118
+ c.rows++;
119
+ const hit = STATUS.find(([k]) => (cells[col] || "").startsWith(k));
120
+ if (hit) c[hit[1]]++;
121
+ else { c.unknown++; c.unknownRows.push(i + 1); }
122
+ });
123
+ if (col < 0) throw new Error("trace 표에 `상태` 칸이 있는 머리 줄이 없음");
124
+ return c;
125
+ }
@@ -0,0 +1,48 @@
1
+ // make-evidence: commits only the evidence paths. Ignored files are forced in only for the run logs and patches
2
+ // under docs/evidence/logs; .env files, big files and text that still looks like a secret stop the commit.
3
+ // Commit hooks run. Pure Node, no network.
4
+ import { readFileSync, statSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { EVIDENCE, OWN, git, gitOut, headOf, lines, topOf } from "./me-git.mjs";
7
+ import { secretLines } from "./me-run.mjs";
8
+
9
+ const MAX_FILE = 5 * 1024 * 1024;
10
+ const FORCE = new RegExp(`^${EVIDENCE}/logs/[^/]+\\.(log|patch|md)$`);
11
+ const NEVER = /(^|\/)(\.env[^/]*|node_modules|__pycache__|\.venv)(\/|$)/;
12
+ const under = (f, paths) => paths.some((p) => f === p || f.startsWith(`${p}/`));
13
+
14
+ function candidates(top, paths) {
15
+ const normal = lines(gitOut(top, ["ls-files", "--modified", "--others", "--deleted", "--exclude-standard", "--", ...paths]));
16
+ const ignored = lines(gitOut(top, ["ls-files", "--others", "--ignored", "--exclude-standard", "--", ...paths]));
17
+ return { normal: [...new Set(normal)], forced: ignored.filter((f) => FORCE.test(f)) };
18
+ }
19
+
20
+ // accept: exact file:line places the user confirmed are not secrets.
21
+ function refusals(top, files, accept = []) {
22
+ const out = [];
23
+ for (const f of files) {
24
+ if (NEVER.test(f)) { out.push(`넣지 않는 파일: ${f}`); continue; }
25
+ let st;
26
+ try { st = statSync(join(top, f)); } catch { continue; } // a deletion
27
+ if (st.size > MAX_FILE) { out.push(`5MB 넘는 파일: ${f}`); continue; }
28
+ const hits = secretLines(readFileSync(join(top, f), "utf8")).filter((n) => !accept.includes(`${f}:${n}`));
29
+ for (const n of hits.slice(0, 5)) out.push(`비밀값 꼴이 남음: ${f}:${n} (값을 가리거나, 비밀값이 아니면 --accept ${f}:${n})`);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export function commitEvidence(repo, message, paths = OWN, accept = []) {
35
+ const top = topOf(repo);
36
+ const foreign = lines(gitOut(top, ["diff", "--cached", "--name-only"])).filter((f) => !under(f, paths));
37
+ if (foreign.length) return { refused: [`증거 밖의 스테이징된 변경이 있음: ${foreign.join(", ")}`] };
38
+ const { normal, forced } = candidates(top, paths);
39
+ if (!normal.length && !forced.length) return { refused: [`커밋할 것이 없음: ${paths.join(", ")}`] };
40
+ const refused = refusals(top, [...normal, ...forced], accept);
41
+ if (refused.length) return { refused };
42
+ if (normal.length) gitOut(top, ["add", "-A", "--", ...normal]);
43
+ if (forced.length) gitOut(top, ["add", "-f", "--", ...forced]);
44
+ const r = git(top, ["commit", "-m", message, "--", ...new Set([...normal, ...forced])]);
45
+ if (r.status !== 0) return { refused: [`git commit 실패(훅은 우회하지 않음): ${String(r.stdout + r.stderr).trim().split("\n").slice(-3).join(" / ")}`] };
46
+ const files = lines(gitOut(top, ["show", "--name-only", "--format=", "HEAD"]));
47
+ return { sha: headOf(top), files, outside: files.filter((f) => !under(f, paths)) };
48
+ }
@@ -0,0 +1,63 @@
1
+ // make-evidence: git calls, the evidence paths and a fingerprint of the user's work tree. Pure Node, no network.
2
+ import { spawnSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+
8
+ export const EVIDENCE = "docs/evidence";
9
+ export const ORACLE = "tests/oracle";
10
+ export const OWN = [EVIDENCE, ORACLE];
11
+ export const EXCLUDE = OWN.map((p) => `:(exclude)${p}`);
12
+ export const KINDS = ["constraint", "objective", "permission"];
13
+ export const MAX_TEXT = 4 * 1024 * 1024;
14
+
15
+ export class Usage extends Error {}
16
+
17
+ // Programs start from the home folder (Windows looks for a program in the working folder first); git gets the
18
+ // folder through -C.
19
+ export const runDir = () => (existsSync(homedir()) ? homedir() : tmpdir());
20
+
21
+ export function git(cwd, args, extra = {}) {
22
+ const r = spawnSync("git", ["-C", resolve(cwd), ...args], { cwd: runDir(), encoding: "utf8", maxBuffer: 256 * 1024 * 1024, windowsHide: true, ...extra });
23
+ if (r.error) throw new Error(`git 을 실행하지 못함: ${r.error.message}`);
24
+ return r;
25
+ }
26
+
27
+ export function gitOut(cwd, args) {
28
+ const r = git(cwd, args);
29
+ if (r.status !== 0) throw new Error(`git ${args.filter((a) => !a.startsWith(":(")).join(" ")} 실패: ${String(r.stderr).trim().split("\n").pop()}`);
30
+ return r.stdout;
31
+ }
32
+
33
+ export const lines = (s) => String(s).split(/\r?\n/).filter(Boolean);
34
+ export const topOf = (dir) => gitOut(dir, ["rev-parse", "--show-toplevel"]).trim();
35
+ export const headOf = (dir) => gitOut(dir, ["rev-parse", "HEAD"]).trim();
36
+
37
+ // What git sees changed outside the evidence paths (tracked and untracked).
38
+ export const otherStatus = (repo) => gitOut(repo, ["--no-optional-locks", "status", "--porcelain", "--untracked-files=all", "--", ".", ...EXCLUDE]);
39
+
40
+ export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
41
+
42
+ function fileHash(file) {
43
+ try {
44
+ const st = lstatSync(file);
45
+ return st.isFile() ? sha256(readFileSync(file)) : `${st.isSymbolicLink() ? "link" : "other"}`;
46
+ } catch {
47
+ return "gone";
48
+ }
49
+ }
50
+
51
+ // The user's work tree outside the evidence paths: the status lines, the content of tracked changes and of every
52
+ // untracked file. Two equal prints mean nothing there changed (ignored files are not covered).
53
+ export function treePrint(top) {
54
+ const status = otherStatus(top);
55
+ const diff = gitOut(top, ["--no-optional-locks", "diff", "HEAD", "--binary", "--", ".", ...EXCLUDE]);
56
+ const untracked = lines(gitOut(top, ["ls-files", "--others", "--exclude-standard", "--", ".", ...EXCLUDE]));
57
+ return sha256(`${status}\0${diff}\0${untracked.map((f) => `${f}:${fileHash(join(top, f))}`).join("\n")}`);
58
+ }
59
+
60
+ export function stamp(d = new Date()) {
61
+ const p = (n, w = 2) => String(n).padStart(w, "0");
62
+ return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}-${p(d.getUTCMilliseconds(), 3)}Z`;
63
+ }
@@ -0,0 +1,244 @@
1
+ // make-evidence: the oracle writer's room, and the seeded bugs run in a separate worktree of HEAD. The user's work
2
+ // tree is never written. Pure Node, no network.
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { EVIDENCE, EXCLUDE, KINDS, ORACLE, Usage, git, gitOut, headOf, lines, sha256, topOf, treePrint } from "./me-git.mjs";
7
+ import { claimLog, runLogged } from "./me-run.mjs";
8
+
9
+ // Inside means below, not equal; a path on another Windows drive is outside (relative() returns it absolute).
10
+ export const inside = (top, p) => { const r = relative(top, p); return r !== "" && !r.startsWith("..") && !isAbsolute(r); };
11
+ const slash = (p) => p.split("\\").join("/");
12
+
13
+ function walk(dir, base = dir) {
14
+ const out = [];
15
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
16
+ const p = join(dir, e.name);
17
+ if (e.isDirectory()) out.push(...walk(p, base));
18
+ else out.push(slash(relative(base, p)));
19
+ }
20
+ return out;
21
+ }
22
+
23
+ // ---- room -----------------------------------------------------------------------------------------------------
24
+
25
+ // The room is <base>/room; its note (the repository path and the input list) is <base>/room.json, outside the
26
+ // room, so the oracle writer does not learn where the repository is.
27
+ const noteOf = (roomDir) => join(dirname(roomDir), "room.json");
28
+
29
+ export function makeRoom(repo, req = ["README.md"]) {
30
+ const top = topOf(repo);
31
+ const files = [...req, `${EVIDENCE}/owner-facts.md`];
32
+ const dir = join(mkdtempSync(join(tmpdir(), "make-evidence-room-")), "room");
33
+ if (inside(top, dir)) throw new Error(`임시 폴더가 저장소 안에 있음: ${dir}`);
34
+ mkdirSync(dir);
35
+ for (const f of files) {
36
+ const src = join(top, f);
37
+ if (!existsSync(src) || !lstatSync(src).isFile()) throw new Error(`방에 넣을 파일이 없음: ${f}`);
38
+ mkdirSync(dirname(join(dir, f)), { recursive: true });
39
+ cpSync(src, join(dir, f));
40
+ }
41
+ mkdirSync(join(dir, ORACLE), { recursive: true });
42
+ writeFileSync(noteOf(dir), JSON.stringify({ repo: top, files }, null, 2));
43
+ return { dir, files };
44
+ }
45
+
46
+ // What the room holds beside the oracle: only the input files, unchanged.
47
+ function roomProblems(roomDir, top, files) {
48
+ const problems = [];
49
+ for (const f of walk(roomDir)) {
50
+ if (f.startsWith(`${ORACLE}/`)) continue;
51
+ if (!files.includes(f)) problems.push(`방에 허용하지 않은 파일: ${f}`);
52
+ else if (!readFileSync(join(roomDir, f)).equals(readFileSync(join(top, f)))) problems.push(`입력 파일이 바뀜: ${f}`);
53
+ }
54
+ return problems;
55
+ }
56
+
57
+ const isFilled = (dir) => existsSync(dir) && readdirSync(dir).length > 0;
58
+
59
+ export function collectRoom(roomDir, repo, { replace = false } = {}) {
60
+ const note = JSON.parse(readFileSync(noteOf(roomDir), "utf8"));
61
+ const top = repo ? topOf(repo) : note.repo;
62
+ const problems = roomProblems(roomDir, top, note.files);
63
+ const made = existsSync(join(roomDir, ORACLE)) ? walk(join(roomDir, ORACLE)) : [];
64
+ if (!made.length) problems.push(`${ORACLE}/ 에 파일이 없음`);
65
+ const dest = join(top, ORACLE);
66
+ if (!replace && isFilled(dest)) problems.push(`${ORACLE}/ 가 이미 있음(판정기 빈틈을 고친 새 판정기일 때만 --replace)`);
67
+ const previous = lines(gitOut(top, ["log", "-1", "--format=%h %ad %s", "--date=iso", "--", ORACLE]))[0] || null;
68
+ if (problems.length) return { problems, made, previous };
69
+ rmSync(dest, { recursive: true, force: true });
70
+ cpSync(join(roomDir, ORACLE), dest, { recursive: true });
71
+ return { problems, made, previous };
72
+ }
73
+
74
+ // ---- worktree and links ---------------------------------------------------------------------------------------
75
+
76
+ const META = "meta.json";
77
+ const BUILD_DIR = /^(dist|build|out|target|\.next|\.nuxt|\.output|bin|obj|coverage|lib)$/i;
78
+ const metaOf = (wt) => JSON.parse(readFileSync(join(dirname(wt), META), "utf8"));
79
+
80
+ function checkLink(top, p) {
81
+ const src = resolve(top, p);
82
+ const rel = relative(top, src);
83
+ if (!inside(top, src)) throw new Usage(`--link 는 저장소 안의 경로만: ${p}`);
84
+ if (!existsSync(src)) throw new Usage(`--link 경로가 없음: ${p}`);
85
+ if (BUILD_DIR.test(basename(src))) throw new Usage(`--link 로 빌드 결과를 잇지 않음(버그를 넣은 빌드가 사용자 폴더에 써짐): ${p}. 작업 트리 안에서 새로 빌드함`);
86
+ if (/^\.env/i.test(basename(src))) throw new Usage(`--link 로 .env 를 잇지 않음: ${p}`);
87
+ if (lines(gitOut(top, ["ls-files", "--", rel])).length) throw new Usage(`--link 는 추적되지 않는 폴더만(추적되는 파일은 작업 트리에 이미 있음): ${p}`);
88
+ return rel;
89
+ }
90
+
91
+ // A linked folder's print (files, bytes, latest change) and the links in it that lead back into the main tree:
92
+ // symbolic links resolving there (workspace packages), .pth and egg-link files naming it (editable installs).
93
+ export function scanLinked(top, root) {
94
+ const s = { files: 0, bytes: 0, mtime: 0, toRepo: [] };
95
+ const real = realpathSync(top);
96
+ const visit = (dir) => {
97
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
98
+ const p = join(dir, e.name);
99
+ const st = lstatSync(p);
100
+ s.files++; s.bytes += st.size; s.mtime = Math.max(s.mtime, st.mtimeMs);
101
+ if (e.isSymbolicLink()) {
102
+ let to; try { to = realpathSync(p); } catch { to = resolve(dir, readlinkSync(p)); }
103
+ if (inside(real, to) && !inside(realpathSync(root), to)) s.toRepo.push(slash(relative(top, p)));
104
+ } else if (e.isDirectory()) visit(p);
105
+ else if (/\.(pth|egg-link)$|^__editable__/.test(e.name) && st.size < 65536 && readFileSync(p, "utf8").includes(real)) s.toRepo.push(slash(relative(top, p)));
106
+ }
107
+ };
108
+ visit(root);
109
+ return s;
110
+ }
111
+
112
+ export function addWorktree(repo, links = []) {
113
+ const top = topOf(repo);
114
+ const rels = links.map((p) => checkLink(top, p));
115
+ const base = mkdtempSync(join(tmpdir(), "make-evidence-wt-"));
116
+ const wt = join(base, "wt");
117
+ gitOut(top, ["worktree", "add", "--detach", wt, "HEAD"]);
118
+ writeFileSync(join(base, META), JSON.stringify({ repo: top, links: rels }));
119
+ // The oracle and the evidence scripts as they are now, committed or not, so the run uses them.
120
+ for (const p of [ORACLE, `${EVIDENCE}/scripts`]) {
121
+ if (!existsSync(join(top, p))) continue;
122
+ rmSync(join(wt, p), { recursive: true, force: true });
123
+ cpSync(join(top, p), join(wt, p), { recursive: true });
124
+ }
125
+ for (const rel of rels) {
126
+ mkdirSync(dirname(join(wt, rel)), { recursive: true });
127
+ symlinkSync(join(top, rel), join(wt, rel), "junction");
128
+ }
129
+ return wt;
130
+ }
131
+
132
+ export function removeWorktree(wt) {
133
+ const { repo, links } = metaOf(wt);
134
+ for (const rel of links) {
135
+ const at = join(wt, rel);
136
+ try { if (lstatSync(at).isSymbolicLink()) rmSync(at); } catch { /* not there */ } // the link, never its target
137
+ }
138
+ git(repo, ["worktree", "remove", "--force", wt]);
139
+ git(repo, ["worktree", "prune"]);
140
+ rmSync(dirname(wt), { recursive: true, force: true });
141
+ }
142
+
143
+ // ---- oracle lines and the verdict -----------------------------------------------------------------------------
144
+
145
+ const ORACLE_LINE = /^ORACLE (constraint|objective|permission) (PASS|FAIL|ERROR)\s+(\S+)/;
146
+
147
+ export function oracleResults(text) {
148
+ const r = Object.fromEntries(KINDS.map((k) => [k, { pass: new Set(), fail: new Set(), error: new Set() }]));
149
+ for (const line of text.split(/\r?\n/)) {
150
+ const m = line.trim().match(ORACLE_LINE);
151
+ if (m) r[m[1]][m[2].toLowerCase()].add(m[3]);
152
+ }
153
+ return r;
154
+ }
155
+
156
+ const casesOf = (r) => new Set(KINDS.flatMap((k) => [...r[k].pass, ...r[k].fail, ...r[k].error].map((c) => `${k}:${c}`)));
157
+ const sameSet = (a, b) => a.size === b.size && [...a].every((x) => b.has(x));
158
+
159
+ // caught: a FAIL of the bug's kind the baseline did not have, in a run that judged the same cases as the baseline
160
+ // and gave no ERROR. Anything that does not show the oracle judging the implementation is undecidable.
161
+ export function judge(text, kind, baseText, timedOut) {
162
+ const cur = oracleResults(text), base = oracleResults(baseText);
163
+ const collateral = KINDS.filter((k) => k !== kind).reduce((n, k) => n + [...cur[k].fail].filter((c) => !base[k].fail.has(c)).length, 0);
164
+ const undecided = (reason) => ({ result: "undecidable", reason, collateral });
165
+ if (timedOut) return undecided("timeout");
166
+ if (KINDS.some((k) => cur[k].error.size)) return undecided("oracle-error");
167
+ if (!base[kind].pass.size) return undecided("baseline-no-pass");
168
+ if (!sameSet(casesOf(cur), casesOf(base))) return undecided("cases-differ");
169
+ const caught = [...cur[kind].fail].some((c) => !base[kind].fail.has(c));
170
+ return { result: caught ? "caught" : "missed", reason: caught ? "new-fail" : "no-new-fail", collateral };
171
+ }
172
+
173
+ export function oracleSha(wt) {
174
+ const dir = join(wt, ORACLE);
175
+ return existsSync(dir) ? sha256(walk(dir).sort().map((f) => `${f}:${sha256(readFileSync(join(dir, f)))}`).join("\n")).slice(0, 16) : "none";
176
+ }
177
+
178
+ // ---- the bug run ----------------------------------------------------------------------------------------------
179
+
180
+ const metricOf = (text, name) => (text.match(new RegExp(`^METRIC ${name.replace(/\./g, "\\.")}=(\\S+)$`, "m")) || [])[1];
181
+
182
+ function checkBugArgs(name, kind, baseline) {
183
+ if (!/^[\w-]+$/.test(name || "")) throw new Usage("--name 은 영문, 숫자, - 와 _ 만");
184
+ if (name === "baseline") return;
185
+ if (!KINDS.includes(kind)) throw new Usage(`--kind 는 ${KINDS.join(", ")} 가운데 하나`);
186
+ if (!baseline || !existsSync(baseline)) throw new Usage("--baseline <기준선 로그> 가 필요함(먼저 --name baseline 으로 돌림)");
187
+ }
188
+
189
+ // The baseline must be of this HEAD and this oracle.
190
+ function checkBaseline(wt, baseText) {
191
+ const head = (baseText.match(/^# head=(\S+)/m) || [])[1];
192
+ if (head !== headOf(wt)) throw new Usage(`기준선의 HEAD(${head})가 지금 HEAD 와 다름. 기준선을 다시 돌림`);
193
+ const sha = metricOf(baseText, "bug.baseline.oracle_sha");
194
+ if (sha !== oracleSha(wt)) throw new Usage("기준선의 판정기가 지금 판정기와 다름. 기준선을 다시 돌림");
195
+ }
196
+
197
+ function verdictMetrics(name, kind, run, text, baseText, linked) {
198
+ const m = [`bug.${name}.exit=${run.exit}`];
199
+ if (name === "baseline") {
200
+ const r = oracleResults(text);
201
+ for (const k of KINDS) m.push(`baseline.${k}.pass=${r[k].pass.size}`, `baseline.${k}.fail=${r[k].fail.size}`, `baseline.${k}.error=${r[k].error.size}`);
202
+ return m;
203
+ }
204
+ let v = judge(text, kind, baseText, run.code === 124);
205
+ if (linked.toRepo.length) v = { ...v, result: "undecidable", reason: "link-to-main" };
206
+ m.push(`bug.${name}.kind=${kind}`, `bug.${name}.result=${v.result}`, `bug.${name}.reason=${v.reason}`, `bug.${name}.collateral=${v.collateral}`);
207
+ return m;
208
+ }
209
+
210
+ function linkedPrints(top, links) {
211
+ const all = links.map((rel) => scanLinked(top, join(top, rel)));
212
+ return { key: JSON.stringify(all.map((s) => [s.files, s.bytes, s.mtime])), toRepo: all.flatMap((s) => s.toRepo) };
213
+ }
214
+
215
+ async function bugBody({ wt, name, kind, baseText, timeoutS, argv, shell }) {
216
+ const { repo: top, links } = metaOf(wt);
217
+ const logs = join(top, EVIDENCE, "logs");
218
+ const diff = gitOut(wt, ["diff", "--", ".", ...EXCLUDE]);
219
+ if (name === "baseline" ? diff.trim() : !diff.trim()) throw new Error(name === "baseline" ? "기준선인데 작업 트리에 바뀐 것이 있음" : "작업 트리에 넣은 버그가 없음(추적되는 파일을 고침)");
220
+ if (name !== "baseline") checkBaseline(wt, baseText);
221
+ const before = { tree: treePrint(top), linked: linkedPrints(top, links) };
222
+ const patch = name === "baseline" ? null : claimLog(join(logs, `verify-bug-${name}-{ts}.patch`));
223
+ if (patch) writeFileSync(patch, diff);
224
+ const run = await runLogged({ log: join(logs, `verify-bug-${name}-{ts}.log`), argv, cwd: wt, timeoutS, shell });
225
+ const metrics = verdictMetrics(name, kind, run, readFileSync(run.file, "utf8"), baseText, before.linked);
226
+ metrics.push(`bug.${name}.oracle_sha=${oracleSha(wt)}`, `bug.${name}.links_to_repo=${before.linked.toRepo.length}`);
227
+ return { top, links, run, patch, metrics, before };
228
+ }
229
+
230
+ export async function runBug({ wt, name, kind, baseline, timeoutS = 300, argv, shell = false }) {
231
+ checkBugArgs(name, kind, baseline);
232
+ if (!argv.length) throw new Usage("판정기 명령이 없음(-- 뒤에 씀)");
233
+ let r;
234
+ try {
235
+ r = await bugBody({ wt, name, kind, baseText: baseline ? readFileSync(baseline, "utf8") : "", timeoutS, argv, shell });
236
+ } finally {
237
+ removeWorktree(wt);
238
+ }
239
+ const treeSame = treePrint(r.top) === r.before.tree;
240
+ const linkedSame = linkedPrints(r.top, r.links).key === r.before.linked.key;
241
+ r.metrics.push(`bug.${name}.tree_unchanged=${treeSame ? "yes" : "no"}`, `bug.${name}.links_unchanged=${linkedSame ? "yes" : "no"}`);
242
+ writeFileSync(r.run.file, `${readFileSync(r.run.file, "utf8")}${r.metrics.map((m) => `METRIC ${m}`).join("\n")}\n`);
243
+ return { log: r.run.file, patch: r.patch, metrics: r.metrics, unchanged: treeSame && linkedSame, toRepo: r.before.linked.toRepo };
244
+ }
@@ -0,0 +1,122 @@
1
+ // make-evidence: what the user must decide before anything runs. Staged changes, changes outside the evidence
2
+ // paths, and hosts that are not this machine in config files and in this process's environment. Only names,
3
+ // places and hosts are shown, never a value. Pure Node, no network.
4
+ import { readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { join, relative } from "node:path";
6
+ import { EVIDENCE, EXCLUDE, MAX_TEXT, git, gitOut, headOf, lines, otherStatus, topOf } from "./me-git.mjs";
7
+
8
+
9
+ const CONFIG_NAME = /^(\.env(\..+)?|.+\.env|(docker-)?compose.*\.ya?ml|application.*\.(ya?ml|properties)|appsettings.*\.json|database\.ya?ml|settings.*\.(py|json|ya?ml|toml)|config.*\.(json|ya?ml|toml|ini|js|mjs|cjs|ts)|.*\.properties|knexfile\..+|ormconfig\..+|schema\.prisma|alembic\.ini)$/i;
10
+ const SKIP_DIR = new Set([".git", "node_modules", ".venv", "venv", "vendor", "dist", "build", "target", ".next", "__pycache__", ".tox", ".mypy_cache"]);
11
+ const URL_HOST = /\b[a-z][\w+.-]*:\/\/(?:[^\s@/]+@)?(\[[^\]]+\]|[^\s/:'"?#,;)]+)/gi;
12
+ const KEY_HOST = /\b[\w.-]*(?:host|hostname|server|addr|address|endpoint|data source)\b["']?\s*[=:]\s*["']?([^\s"',;)]+)/gi;
13
+ const ENV_KEY = /(?:URL|URI|DSN|HOST|HOSTS|ADDR|ADDRESS|ENDPOINT|SERVER|SERVERS|BROKERS|NODES)$/i;
14
+ // Values that name a host without a URL: connection strings (Server=..., Host=..., Data Source=...).
15
+ const VALUE_HOST = /(?:^|;)\s*(?:server|host|data source|address|addr)\s*=/i;
16
+ // A secret's value is never read for a host: it could be printed.
17
+ const ENV_SECRET = /PASS|PWD|SECRET|TOKEN|CREDENTIAL|PRIVATE|_KEY$/i;
18
+ const ENV_SKIP = /^(HOSTNAME|HOST|HOSTTYPE|DISPLAY|.*_?PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|BANKER_.*|DBUS_.*|XDG_.*)$/i;
19
+ // Names that database and messaging libraries read from the environment without the project naming them.
20
+ // Spring Boot and Quarkus take any setting from the environment, ASP.NET Core takes ConnectionStrings__*, Azure App
21
+ // Service sets *CONNSTR_*.
22
+ const ENV_DATA = /^(PG[A-Z]*|MYSQL_.*|POSTGRES.*|SPRING_.*|QUARKUS_.*|ConnectionStrings__.*|(?:SQL|SQLAZURE|MYSQL|POSTGRESQL|CUSTOM)CONNSTR_.*|DATABASE_.*|DB_.*|.*_DATABASE_URL|MONGO.*|REDIS.*|AMQP_.*|RABBITMQ_.*|KAFKA_.*|CELERY_.*|ELASTIC.*|OPENSEARCH_.*|SMTP_.*|MAIL_.*|AWS_ENDPOINT.*|S3_.*)$/i;
23
+ const PLACEHOLDER = /^(\$|\{|<|%|\*|example\.|.*\.example$|.*\.invalid$|your[-_])/i;
24
+ const MAX_DEPTH = 4;
25
+
26
+ // Files that commonly hold where a service connects, up to four folders down; a linked file is read through.
27
+ function configFiles(top) {
28
+ const out = [];
29
+ const descend = (p, name, depth) => depth < MAX_DEPTH && !SKIP_DIR.has(name) && relative(top, p) !== EVIDENCE;
30
+ const keep = (p) => { try { const st = statSync(p); return st.isFile() && st.size <= MAX_TEXT; } catch { return false; } }; // a broken link
31
+ const walk = (dir, depth) => {
32
+ let entries;
33
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
34
+ for (const e of entries) {
35
+ const p = join(dir, e.name);
36
+ if (e.isDirectory()) { if (descend(p, e.name, depth)) walk(p, depth + 1); }
37
+ else if (CONFIG_NAME.test(e.name) && keep(p)) out.push(p);
38
+ }
39
+ };
40
+ walk(top, 0);
41
+ return out;
42
+ }
43
+
44
+ // Service names in compose files: those name containers on this machine.
45
+ function composeServices(files) {
46
+ const names = new Set();
47
+ for (const f of files.filter((p) => /compose.*\.ya?ml$/i.test(p))) {
48
+ let inServices = false;
49
+ for (const line of readFileSync(f, "utf8").split(/\r?\n/)) {
50
+ if (/^services:\s*$/.test(line)) { inServices = true; continue; }
51
+ if (/^\S/.test(line)) inServices = false;
52
+ const m = inServices && line.match(/^ {2}([\w.-]+):\s*$/);
53
+ if (m) names.add(m[1]);
54
+ }
55
+ }
56
+ return names;
57
+ }
58
+
59
+ export function isLocalHost(h, services = new Set()) {
60
+ const host = h.toLowerCase().replace(/^\[|\]$/g, "");
61
+ if (["localhost", "::1", "0.0.0.0", "host.docker.internal"].includes(host) || host.startsWith("127.")) return true;
62
+ return services.has(host);
63
+ }
64
+
65
+ function hostsIn(text, services) {
66
+ const found = [];
67
+ for (const re of [URL_HOST, KEY_HOST]) {
68
+ for (const m of text.matchAll(re)) {
69
+ const host = m[1].replace(/^\[|\]$/g, "").replace(/:\d+$/, "");
70
+ if (host && !PLACEHOLDER.test(host) && !/^\d+$|[/=]/.test(host) && !isLocalHost(host, services)) found.push(host);
71
+ }
72
+ }
73
+ return found;
74
+ }
75
+
76
+ // Environment variables that may say where the service connects. Asked about: those the project's tracked files
77
+ // name (one git grep for all), and those database and messaging libraries read on their own (PGHOST,
78
+ // SPRING_DATASOURCE_URL, REDIS_URL...). The rest are only noted: the shell's settings for other tools.
79
+ function envCandidates(top, env) {
80
+ const candidate = (k) => ENV_KEY.test(k) || ENV_DATA.test(k) || /:\/\//.test(env[k]) || VALUE_HOST.test(env[k]);
81
+ const keys = Object.keys(env).filter((k) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k) && env[k] && !ENV_SKIP.test(k) && !ENV_SECRET.test(k) && candidate(k));
82
+ if (!keys.length) return [];
83
+ // the evidence paths are left out: an earlier precheck log names these variables itself
84
+ const named = new Set(lines(git(top, ["grep", "-I", "-o", "-h", "-w", "-F", ...keys.flatMap((k) => ["-e", k]), "--", ".", ...EXCLUDE]).stdout));
85
+ return keys.map((k) => ({ k, ask: named.has(k) || ENV_DATA.test(k) }));
86
+ }
87
+
88
+ export function foreignHosts(top, env = process.env) {
89
+ const files = configFiles(top);
90
+ const services = composeServices(files);
91
+ const found = [];
92
+ for (const f of files) {
93
+ readFileSync(f, "utf8").split(/\r?\n/).forEach((line, i) => {
94
+ if (/^\s*(#|\/\/)/.test(line)) return;
95
+ for (const host of hostsIn(line, services)) found.push({ where: `${relative(top, f).split("\\").join("/")}:${i + 1}`, host });
96
+ });
97
+ }
98
+ for (const { k, ask } of envCandidates(top, env)) {
99
+ const v = env[k];
100
+ const text = /:\/\//.test(v) || VALUE_HOST.test(v) ? v : /HOST|SERVER|ADDR|BROKER|NODE/i.test(k) ? v.split(",").map((h) => `host=${h}`).join(" ") : `${k}=${v}`;
101
+ for (const host of hostsIn(text, services)) found.push({ where: `env ${k}`, host, note: !ask });
102
+ }
103
+ return found;
104
+ }
105
+
106
+ export function precheck(repo, env = process.env) {
107
+ const top = topOf(repo);
108
+ const staged = lines(gitOut(top, ["diff", "--cached", "--name-only"]));
109
+ const dirty = lines(otherStatus(top));
110
+ const ignored = ["logs/x.log", "logs/x.patch"].filter((p) => git(top, ["check-ignore", "-q", `${EVIDENCE}/${p}`]).status === 0);
111
+ const all = foreignHosts(top, env);
112
+ const hosts = all.filter((h) => !h.note);
113
+ const out = [`head ${headOf(top)}`];
114
+ for (const f of staged) out.push(`STOP staged ${f}`);
115
+ for (const l of dirty) out.push(`ASK dirty ${l}`);
116
+ // an environment variable's host is not printed: this output goes into a log that is committed
117
+ for (const h of hosts) out.push(h.where.startsWith("env ") ? `ASK host ${h.where} (이 컴퓨터 밖 주소)` : `ASK host ${h.where} ${h.host}`);
118
+ for (const k of new Set(all.filter((x) => x.note).map((x) => x.where))) out.push(`NOTE host ${k} (이 컴퓨터 밖 주소, 저장소가 부르지 않는 환경 변수. 서비스가 읽는다면 묻기)`);
119
+ for (const p of ignored) out.push(`NOTE ignored ${EVIDENCE}/${p} (commit 이 logs 아래의 .log 와 .patch 만 강제로 넣음)`);
120
+ out.push("NOTE precheck 는 흔한 설정 꼴만 봄. 시작 스크립트와 설정 로더가 읽는 곳도 직접 확인");
121
+ return { out, decide: staged.length + dirty.length + hosts.length > 0 };
122
+ }