852600df02
消融默认关,关掉时解释阶段一并跳过;选诊断实验用信息价值减成本。 eval/diagnosis/research.json 为第三块配置。vocab 字段说明的出口文字随提示词一起改,见下一次提交的 prompts/ 与同文件改动(本次含在内)。 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
283 lines
14 KiB
TypeScript
283 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { spawnSync } from "node:child_process";
|
||
import { createHash } from "node:crypto";
|
||
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { dirname, join } from "node:path";
|
||
import { test, type TestContext } from "node:test";
|
||
import { checkoutVersion, compareVersions, createRepository, saveVersion, type FileChange } from "../src/workspace/index.ts";
|
||
|
||
// workspace 的测试(任务书 06 第五部分),用真实 git,仓库建在每个测试自己的临时目录里。
|
||
// 行号、目录树编号等预期值都是手算或按 git 公开的对象格式独立算的,不调实现。
|
||
|
||
function tempDir(t: TestContext, name: string): string {
|
||
const dir = mkdtempSync(join(tmpdir(), `inforsi-workspace-${name}-`));
|
||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||
return dir;
|
||
}
|
||
|
||
function newRepo(t: TestContext): string {
|
||
const repo = join(tempDir(t, "repo"), "versions.git");
|
||
createRepository(repo);
|
||
return repo;
|
||
}
|
||
|
||
/** 按 { 相对路径: 内容 } 建一个目录;路径以 * 结尾的文件设成可执行。 */
|
||
function makeDir(t: TestContext, files: Record<string, string | Buffer>): string {
|
||
const dir = tempDir(t, "code");
|
||
for (const [name, content] of Object.entries(files)) {
|
||
const path = join(dir, name.replace(/\*$/, ""));
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
writeFileSync(path, content);
|
||
chmodSync(path, name.endsWith("*") ? 0o755 : 0o644);
|
||
}
|
||
return dir;
|
||
}
|
||
|
||
function save(repo: string, dir: string, excluded: string[]): string {
|
||
const outcome = saveVersion(repo, dir, excluded);
|
||
assert.ok(outcome.saved, JSON.stringify(outcome));
|
||
return outcome.version;
|
||
}
|
||
|
||
function checkout(t: TestContext, repo: string, version: string): string {
|
||
const dir = tempDir(t, "out");
|
||
checkoutVersion(repo, version, dir);
|
||
return dir;
|
||
}
|
||
|
||
/** 测试自己调 git 查仓库,同样不读用户配置。 */
|
||
function gitIn(repo: string, args: string[], input: string): { status: number | null; stdout: string; stderr: string } {
|
||
return spawnSync("git", args, {
|
||
encoding: "utf8",
|
||
input,
|
||
env: { ...process.env, GIT_DIR: repo, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" },
|
||
});
|
||
}
|
||
|
||
function sha1(data: Buffer): Buffer {
|
||
return createHash("sha1").update(data).digest();
|
||
}
|
||
|
||
/** 按 git 公开的对象格式独立算一个只含文件、不含子目录的目录树编号。 */
|
||
function flatTreeId(files: Array<[name: string, mode: string, content: string]>): string {
|
||
const entries = [...files]
|
||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||
.map(([name, mode, content]) => {
|
||
const blob = Buffer.from(content);
|
||
const blobId = sha1(Buffer.concat([Buffer.from(`blob ${blob.length}\0`), blob]));
|
||
return Buffer.concat([Buffer.from(`${mode} ${name}\0`), blobId]);
|
||
});
|
||
const body = Buffer.concat(entries);
|
||
return sha1(Buffer.concat([Buffer.from(`tree ${body.length}\0`), body])).toString("hex");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 存版本与取出
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test("同内容同号:存两次、复制一份改了文件时间再存,都是同一个号,且等于独立算出的目录树编号", (t) => {
|
||
const repo = newRepo(t);
|
||
const dir = makeDir(t, { "run.sh*": "#!/bin/sh\necho hi\n", "main.py": "print(1)\n" });
|
||
const first = save(repo, dir, []);
|
||
assert.equal(save(repo, dir, []), first);
|
||
const copy = join(tempDir(t, "copy"), "code");
|
||
cpSync(dir, copy, { recursive: true });
|
||
utimesSync(join(copy, "main.py"), new Date("2001-01-01"), new Date("2001-01-01"));
|
||
assert.equal(save(repo, copy, []), first);
|
||
assert.equal(first, flatTreeId([["main.py", "100644", "print(1)\n"], ["run.sh", "100755", "#!/bin/sh\necho hi\n"]]));
|
||
});
|
||
|
||
test("取出再存回来,号不变:内容逐字节、可执行位都还原,取出的目录里没有 .git", (t) => {
|
||
const repo = newRepo(t);
|
||
const binary = Buffer.from([0, 1, 2, 255, 13, 10]);
|
||
const dir = makeDir(t, { "a.py": "x = 1\r\ny = 2\n", "lib/b.py*": "#!/usr/bin/env python\n", "lib/deep/data.bin": binary, "empty.txt": "" });
|
||
const version = save(repo, dir, []);
|
||
const out = checkout(t, repo, version);
|
||
assert.deepEqual(readFileSync(join(out, "a.py")), Buffer.from("x = 1\r\ny = 2\n"));
|
||
assert.deepEqual(readFileSync(join(out, "lib/deep/data.bin")), binary);
|
||
assert.equal(readFileSync(join(out, "empty.txt"), "utf8"), "");
|
||
assert.equal(statSync(join(out, "lib/b.py")).mode & 0o100, 0o100);
|
||
assert.equal(statSync(join(out, "a.py")).mode & 0o100, 0);
|
||
assert.equal(existsSync(join(out, ".git")), false);
|
||
assert.equal(save(repo, out, []), version);
|
||
});
|
||
|
||
test(".gitignore 不起作用:写了 *.json,cfg.json 照样存进去", (t) => {
|
||
const repo = newRepo(t);
|
||
const dir = makeDir(t, { ".gitignore": "*.json\n", "cfg.json": "{}\n", "main.py": "pass\n" });
|
||
const out = checkout(t, repo, save(repo, dir, []));
|
||
assert.equal(readFileSync(join(out, "cfg.json"), "utf8"), "{}\n");
|
||
assert.equal(readFileSync(join(out, ".gitignore"), "utf8"), "*.json\n");
|
||
});
|
||
|
||
test("树里的 .gitattributes 不起作用:取出时不丢文件、不改换行符", (t) => {
|
||
const repo = newRepo(t);
|
||
const dir = makeDir(t, { ".gitattributes": "*.py export-ignore\n*.txt text eol=crlf\n", "a.py": "pass\n", "b.txt": "x\n" });
|
||
const out = checkout(t, repo, save(repo, dir, []));
|
||
assert.equal(readFileSync(join(out, "a.py"), "utf8"), "pass\n");
|
||
assert.equal(readFileSync(join(out, "b.txt"), "utf8"), "x\n");
|
||
});
|
||
|
||
test("不读用户配置:HOME 里有改行为的 git 配置和 attributes,存、取、比较的结果都不变", (t) => {
|
||
const files = { "a.py": "x = 1\r\ny = 2\n", "run.sh*": "#!/bin/sh\n", "数据.py": "1\n" };
|
||
const changed = { "a.py": "x = 1\r\ny = 3\n", "run.sh*": "#!/bin/sh\n", "数据.py": "2\n" };
|
||
const repo = newRepo(t);
|
||
const cleanFrom = save(repo, makeDir(t, files), []);
|
||
const cleanTo = save(repo, makeDir(t, changed), []);
|
||
const clean = compareVersions(repo, cleanFrom, cleanTo);
|
||
|
||
// 只在这个测试里把 HOME 换成一份"有敌意"的配置;git 子进程继承 process.env。
|
||
const home = tempDir(t, "home");
|
||
writeFileSync(
|
||
join(home, ".gitconfig"),
|
||
"[core]\n\tautocrlf = true\n\tfileMode = false\n\tabbrev = 20\n\tquotePath = false\n[diff]\n\tnoprefix = true\n\talgorithm = histogram\n\tcontext = 10\n[color]\n\tui = always\n",
|
||
);
|
||
mkdirSync(join(home, ".config", "git"), { recursive: true });
|
||
writeFileSync(join(home, ".config", "git", "attributes"), "*.py binary\n* text eol=crlf\n");
|
||
const saved = { HOME: process.env.HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME };
|
||
process.env.HOME = home;
|
||
delete process.env.XDG_CONFIG_HOME;
|
||
t.after(() => {
|
||
process.env.HOME = saved.HOME;
|
||
if (saved.XDG_CONFIG_HOME !== undefined) process.env.XDG_CONFIG_HOME = saved.XDG_CONFIG_HOME;
|
||
});
|
||
// 先确认这份配置对不加保护的 git 确实起作用,测试才有意义。
|
||
assert.equal(spawnSync("git", ["config", "--global", "core.autocrlf"], { encoding: "utf8" }).stdout, "true\n");
|
||
|
||
const hostileRepo = newRepo(t);
|
||
const from = save(hostileRepo, makeDir(t, files), []);
|
||
const to = save(hostileRepo, makeDir(t, changed), []);
|
||
assert.equal(from, cleanFrom);
|
||
assert.equal(to, cleanTo);
|
||
const out = checkout(t, hostileRepo, from);
|
||
assert.deepEqual(readFileSync(join(out, "a.py")), Buffer.from("x = 1\r\ny = 2\n"));
|
||
const hostile = compareVersions(hostileRepo, from, to);
|
||
assert.deepEqual(hostile, clean);
|
||
assert.match(hostile.diff, /^diff --git a\/a\.py b\/a\.py$/m);
|
||
assert.doesNotMatch(hostile.diff, /\x1b/);
|
||
// 编号缩写取 git 的默认长度,不是配置里的 20 位;非 ASCII 路径照默认加引号转义。
|
||
assert.match(hostile.diff, /^index [0-9a-f]{7}\.\.[0-9a-f]{7} 100644$/m);
|
||
assert.match(hostile.diff, /^diff --git "a\/\\346/m);
|
||
});
|
||
|
||
test("不存清单:清单里的名字在顶层和子目录里都跳过", (t) => {
|
||
const repo = newRepo(t);
|
||
const dir = makeDir(t, {
|
||
"main.py": "pass\n",
|
||
"solution.json": "{}",
|
||
"__pycache__/main.cpython-313.pyc": "x",
|
||
".git/HEAD": "ref: refs/heads/main\n",
|
||
"lib/util.py": "pass\n",
|
||
"lib/__pycache__/util.cpython-313.pyc": "x",
|
||
"lib/metrics.json": "{}",
|
||
});
|
||
const excluded = ["__pycache__", ".git", "solution.json", "metrics.json"];
|
||
const listed = gitIn(repo, ["ls-tree", "-r", "--name-only", save(repo, dir, excluded)], "");
|
||
assert.equal(listed.stdout, "lib/util.py\nmain.py\n");
|
||
});
|
||
|
||
test("存不了:符号链接、命名管道交回存不了和那个路径", (t) => {
|
||
const repo = newRepo(t);
|
||
const linked = makeDir(t, { "main.py": "pass\n", "lib/real.py": "pass\n" });
|
||
symlinkSync("real.py", join(linked, "lib", "link.py"));
|
||
const outcome = saveVersion(repo, linked, []);
|
||
assert.equal(outcome.saved, false);
|
||
assert.ok(!outcome.saved);
|
||
assert.equal(outcome.path, "lib/link.py");
|
||
assert.match(outcome.reason, /符号链接/);
|
||
|
||
const piped = makeDir(t, { "main.py": "pass\n" });
|
||
assert.equal(spawnSync("mkfifo", [join(piped, "pipe")]).status, 0);
|
||
assert.deepEqual(saveVersion(repo, piped, []), { saved: false, path: "pipe", reason: "既不是普通文件也不是目录,代码版本只存普通文件和目录" });
|
||
// 在清单里的名字不看类型,直接跳过。
|
||
assert.ok(saveVersion(repo, piped, ["pipe"]).saved);
|
||
});
|
||
|
||
test("引用防清理:git gc --prune=now 之后版本照样能取出,没挂引用的对象被清掉", (t) => {
|
||
const repo = newRepo(t);
|
||
const version = save(repo, makeDir(t, { "main.py": "keep me\n" }), []);
|
||
const orphan = gitIn(repo, ["hash-object", "-w", "--stdin"], "nobody points at me\n").stdout.trim();
|
||
assert.equal(gitIn(repo, ["cat-file", "-e", orphan], "").status, 0);
|
||
assert.equal(gitIn(repo, ["gc", "--prune=now", "--quiet"], "").status, 0);
|
||
assert.notEqual(gitIn(repo, ["cat-file", "-e", orphan], "").status, 0);
|
||
const out = checkout(t, repo, version);
|
||
assert.equal(readFileSync(join(out, "main.py"), "utf8"), "keep me\n");
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 比较两个版本
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test("比较:新增、删除、修改、只改可执行位、二进制,各一个文件", (t) => {
|
||
const repo = newRepo(t);
|
||
const from = save(
|
||
repo,
|
||
makeDir(t, { "gone.py": "a\nb\n", "edit.py": "one\ntwo\n", "tool.sh": "#!/bin/sh\n", "data.bin": Buffer.from([0, 1, 2]), "same.py": "x\n" }),
|
||
[],
|
||
);
|
||
const to = save(
|
||
repo,
|
||
makeDir(t, { "new/added.py": "p\nq\n", "edit.py": "one\nTWO\n", "tool.sh*": "#!/bin/sh\n", "data.bin": Buffer.from([0, 1, 3]), "same.py": "x\n" }),
|
||
[],
|
||
);
|
||
const expected: FileChange[] = [
|
||
{ path: "data.bin", change: "modified", modeOnly: false, binary: true },
|
||
{ path: "edit.py", change: "modified", modeOnly: false, binary: false, hunks: [{ from: { start: 2, end: 2 }, to: { start: 2, end: 2 } }] },
|
||
{ path: "gone.py", change: "deleted", modeOnly: false, binary: false, hunks: [{ from: { start: 1, end: 2 }, to: { after: 0 } }] },
|
||
{ path: "new/added.py", change: "added", modeOnly: false, binary: false, hunks: [{ from: { after: 0 }, to: { start: 1, end: 2 } }] },
|
||
{ path: "tool.sh", change: "modified", modeOnly: true, binary: false, hunks: [] },
|
||
];
|
||
const result = compareVersions(repo, from, to);
|
||
assert.deepEqual(result.files, expected);
|
||
assert.match(result.diff, /^diff --git a\/tool\.sh b\/tool\.sh\nold mode 100644\nnew mode 100755$/m);
|
||
assert.match(result.diff, /^Binary files a\/data\.bin and b\/data\.bin differ$/m);
|
||
assert.deepEqual(compareVersions(repo, to, to), { files: [], diff: "" });
|
||
});
|
||
|
||
test("比较的行号:改一行、加几行、只删不加,新旧两边的范围都对(预期值手算)", (t) => {
|
||
const repo = newRepo(t);
|
||
// 旧:a b c d e f g h i j(第 1–10 行)。
|
||
// 新:第 2 行 b 改成 B;e(旧第 5 行)后面加 X Y Z;删掉 h(旧第 8 行)。
|
||
// 新文件是 a B c d e X Y Z f g i j:B 在新第 2 行;X Y Z 在新第 6–8 行;
|
||
// h 删掉后,原来在它前面的 g 是新第 10 行,所以写成"在新第 10 行之后"。
|
||
const from = save(repo, makeDir(t, { "f.txt": "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n" }), []);
|
||
const to = save(repo, makeDir(t, { "f.txt": "a\nB\nc\nd\ne\nX\nY\nZ\nf\ng\ni\nj\n" }), []);
|
||
const result = compareVersions(repo, from, to);
|
||
assert.deepEqual(result.files, [
|
||
{
|
||
path: "f.txt",
|
||
change: "modified",
|
||
modeOnly: false,
|
||
binary: false,
|
||
hunks: [
|
||
{ from: { start: 2, end: 2 }, to: { start: 2, end: 2 } },
|
||
{ from: { after: 5 }, to: { start: 6, end: 8 } },
|
||
{ from: { start: 8, end: 8 }, to: { after: 10 } },
|
||
],
|
||
},
|
||
]);
|
||
// 给模型看的差异原文带 3 行上下文:三处改动相隔不到 6 行,合成一块。
|
||
assert.match(result.diff, /^@@ -1,10 \+1,12 @@\n a\n-b\n\+B\n c\n d\n e\n\+X\n\+Y\n\+Z\n f\n g\n-h\n i\n j\n$/m);
|
||
});
|
||
|
||
test("诊断程序(任务书 18):基线代码取出后放进只读的原始产物、改成诊断程序再存,产物被跳过,比较只看程序的改动,取出再存号不变", (t) => {
|
||
const repo = newRepo(t);
|
||
const baseline = save(repo, makeDir(t, { "initial.py": "a\nb\n", "evaluate.py": "e\n" }), []);
|
||
const workDir = checkout(t, repo, baseline);
|
||
// 原始产物放进实验员工作目录,设成只读(0444),与 tasks 的 placeBaselineArtifact 一样。
|
||
mkdirSync(join(workDir, "baseline-artifact"));
|
||
writeFileSync(join(workDir, "baseline-artifact", "solution.json"), "{}");
|
||
chmodSync(join(workDir, "baseline-artifact", "solution.json"), 0o444);
|
||
writeFileSync(join(workDir, "initial.py"), "a\nb\nexperiment\n");
|
||
const excluded = ["__pycache__", "solution.json", "baseline-artifact"];
|
||
const program = save(repo, workDir, excluded);
|
||
assert.notEqual(program, baseline);
|
||
const comparison = compareVersions(repo, baseline, program);
|
||
assert.deepEqual(comparison.files.map((f) => f.path), ["initial.py"]);
|
||
assert.deepEqual((comparison.files[0] as Extract<FileChange, { binary: false }>).hunks, [{ from: { after: 2 }, to: { start: 3, end: 3 } }]);
|
||
const again = checkout(t, repo, program);
|
||
assert.equal(existsSync(join(again, "baseline-artifact")), false);
|
||
assert.equal(save(repo, again, excluded), program);
|
||
});
|