feat: 任务书 06,workspace 模块与 task.json 的"不存"清单

workspace 用一个裸仓库存代码版本,版本号是目录树编号,不造提交,每个版本挂 refs/versions/ 引用防清理。
存版本自己遍历目录、hash-object --no-filters 存,不用 git add;符号链接等特殊文件交回"存不了"。
取出用 ls-tree 加 cat-file 原样写出:实测 git archive 会按树里的 .gitattributes 丢文件、改换行符。
git 子进程不读系统和用户的配置,另关掉系统和用户的 attributes 文件:实测只关配置时 ~/.config/git/attributes 仍会被读。
比较交回文件级改动、新旧两边行号范围的块,以及带上下文的 diff 原文。
tasks:task.json 加必填的 unversionedNames,protectedFiles 删掉 solution.json。
全新上下文审阅一轮,采纳一条:注明块可以为空(空文件增删、只改可执行位)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 23:43:01 -04:00
parent a1a2feb135
commit 6850c0a1d4
8 changed files with 500 additions and 5 deletions
+6 -1
View File
@@ -16,6 +16,7 @@
- 任务书 03(ledger):src/ledger 5 个文件、584 行,`npm test` 226 条通过(ledger 34 条)。一张只追加的表,两条禁改禁删触发器,8 处唯一约束(阶段开、阶段关、运行登记、确认启动或确认没启动、运行结果、模型调用开始、结束、消融结算),写入时只查四种拒收,提案的作者自评拆成封存另存。按第一版写过可见性和通用唯一键,按新版全部清掉了。全新上下文的审阅做了三轮。结构.md 第八部分补了 ledger 对外内容。 - 任务书 03(ledger):src/ledger 5 个文件、584 行,`npm test` 226 条通过(ledger 34 条)。一张只追加的表,两条禁改禁删触发器,8 处唯一约束(阶段开、阶段关、运行登记、确认启动或确认没启动、运行结果、模型调用开始、结束、消融结算),写入时只查四种拒收,提案的作者自评拆成封存另存。按第一版写过可见性和通用唯一键,按新版全部清掉了。全新上下文的审阅做了三轮。结构.md 第八部分补了 ledger 对外内容。
- 任务书 04(config):src/config 4 个文件、453 行,`npm test` 246 条通过(config 19 条)。流程用联合类型(完整流程 / 朴素循环),规则名取自 rules 登记表,config 改为依赖 rules。ledger 顺手两处:结算按轮次唯一、笔记去掉版本号,表结构版本号升到 2,全新上下文审阅一轮无问题。结构.md 第八部分补了 config 对外内容。 - 任务书 04(config):src/config 4 个文件、453 行,`npm test` 246 条通过(config 19 条)。流程用联合类型(完整流程 / 朴素循环),规则名取自 rules 登记表,config 改为依赖 rules。ledger 顺手两处:结算按轮次唯一、笔记去掉版本号,表结构版本号升到 2,全新上下文审阅一轮无问题。结构.md 第八部分补了 config 对外内容。
- 任务书 05(tasks 与 runner):src/runner 2 个文件、271 行,src/tasks 6 个文件、691 行,`npm test` 274 条通过(runner 12 条、tasks 16 条,真实 Docker,整套约 12 秒)。任务目录搬到 `tasks/circle_packing/`,新增 task.json;`VERDICTS` 删掉"判不了"。基线从头到尾判为算数,分数 0.9597642169962064,两次逐位相同。全新上下文审阅两轮:第一轮发现候选能伪造计量结果(把超时记成正常退出)、运行目录里旧 metrics.json 会被当成本次产物,都已修(计量 shell 以容器内 root 跑、命令用 setpriv 降权、结果用 docker cp 取;harness 开跑前删旧输出);第二轮只审这次修复,另采纳一条:容器加 no-new-privileges,禁止候选靠 setuid 程序重新拿到 root。结构.md 第八部分补了 runner、tasks。 - 任务书 05(tasks 与 runner):src/runner 2 个文件、271 行,src/tasks 6 个文件、691 行,`npm test` 274 条通过(runner 12 条、tasks 16 条,真实 Docker,整套约 12 秒)。任务目录搬到 `tasks/circle_packing/`,新增 task.json;`VERDICTS` 删掉"判不了"。基线从头到尾判为算数,分数 0.9597642169962064,两次逐位相同。全新上下文审阅两轮:第一轮发现候选能伪造计量结果(把超时记成正常退出)、运行目录里旧 metrics.json 会被当成本次产物,都已修(计量 shell 以容器内 root 跑、命令用 setpriv 降权、结果用 docker cp 取;harness 开跑前删旧输出);第二轮只审这次修复,另采纳一条:容器加 no-new-privileges,禁止候选靠 setuid 程序重新拿到 root。结构.md 第八部分补了 runner、tasks。
- 任务书 06(workspace):src/workspace 2 个文件、205 行,`npm test` 284 条通过(workspace 10 条,真实 git)。版本号是目录树编号,存版本自己列文件加 hash-object,取出用 ls-tree 加 cat-file(实测 `git archive` 会按树里的 `.gitattributes` 丢文件、改换行符),git 子进程另关掉系统和用户的 attributes 文件(实测只关配置时 `~/.config/git/attributes` 仍会被读)。tasks 的 task.json 加必填的 `unversionedNames`,`protectedFiles` 删掉 solution.json。全新上下文审阅一轮:两处偏离都实测成立;采纳一条(写明空文件增删、只改可执行位没有块),其余不改。结构.md 第八部分补了 workspace。
## 关键决定(用户原话见各提交说明) ## 关键决定(用户原话见各提交说明)
@@ -70,6 +71,10 @@
- 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。 - 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。
- 给 submissions:研究者登记的假设专用指标编号不能和常规辅助指标重名(`metricDefinitions` 里的 7 个),否则候选自报的值会顶替宿主测的值(任务书 05 审阅意见)。 - 给 submissions:研究者登记的假设专用指标编号不能和常规辅助指标重名(`metricDefinitions` 里的 7 个),否则候选自报的值会顶替宿主测的值(任务书 05 审阅意见)。
- 给 flow:容器名要在本机所有研究之间唯一(例如带研究编号);`docker run` 因重名失败时 runner 按"启动了"处理。日志目录每次运行一个新的。 - 给 flow:容器名要在本机所有研究之间唯一(例如带研究编号);`docker run` 因重名失败时 runner 按"启动了"处理。日志目录每次运行一个新的。
- 给以后算"没有归属的改动"的模块:workspace 比较结果里块可以为空(空文件增删、只改可执行位),要按整个文件算。
- 给 flow:取出时目标目录必须是空的,workspace 不查。
- 空目录不进版本(git 只记文件);候选依赖空目录时正式运行会失败。未见真实证据,只列为未处理的可能情况。
- `tasks/circle_packing/TASK.md` 没告诉实验员 `unversionedNames` 里的东西不会存进版本(任务书 06 范围外,未改)。
- 给以后换任务镜像:镜像里必须有 `setpriv`(runner 用它把命令降到当前用户);容器里不要用 `python -I`,它会忽略 `PYTHON_CPU_COUNT`。 - 给以后换任务镜像:镜像里必须有 `setpriv`(runner 用它把命令降到当前用户);容器里不要用 `python -I`,它会忽略 `PYTHON_CPU_COUNT`。
## 工作方式(用户 2026-10-01 定) ## 工作方式(用户 2026-10-01 定)
@@ -83,4 +88,4 @@
## 下一步 ## 下一步
任务书 06(workspace,`任务书/06-workspace.md`)已写好,等用户另开新 Agent 执行;完成后监督者核对。 任务书 06(workspace)已完成,等监督者核对。
+14 -2
View File
@@ -461,10 +461,10 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
**任务事实** **任务事实**
- `TASK_KINDS`、`TaskKind`:任务种类名单,现在只有 `circle_packing`。 - `TASK_KINDS`、`TaskKind`:任务种类名单,现在只有 `circle_packing`。
- `TaskFacts`(字段定义):task.json 的字段:任务种类、最终指标的编号和方向、ε、已知最好(没有写 null)、单次时限、核数、Docker 运行时、不许改的文件名单。全部必填,不认识的字段报错。 - `TaskFacts`(字段定义):task.json 的字段:任务种类、最终指标的编号和方向、ε、已知最好(没有写 null)、单次时限、核数、Docker 运行时、不许改的文件名单、存代码版本时跳过的名字(`unversionedNames`,只写名字不带 `/`,在任何一层都跳过;填圆是 `__pycache__`、`.git`、`solution.json`、`metrics.json`)。全部必填,不认识的字段报错。
- `checkTaskFacts(value)`:严格检查一份已解析的 task.json,一次列出全部错误,每条写成"字段路径:原因"。 - `checkTaskFacts(value)`:严格检查一份已解析的 task.json,一次列出全部错误,每条写成"字段路径:原因"。
- `Task`(类型):读进来的一个任务:task.json 的内容、任务目录(Dockerfile 所在,给 runner 建镜像)、基线目录、TASK.md 全文。 - `Task`(类型):读进来的一个任务:task.json 的内容、任务目录(Dockerfile 所在,给 runner 建镜像)、基线目录、TASK.md 全文。
- `loadTask(taskDir)`:读任务目录,检查 task.json,交回 `Task`。 - `loadTask(taskDir)`:读任务目录,检查 task.json,交回 `Task`;"不存"清单在 `task.facts.unversionedNames` 里,flow 存版本时把它交给 workspace。
- `metricDefinitions(task)`:最终指标和常规辅助指标的定义(墙钟、CPU 秒、峰值内存、最小间隙、原分数、原违反量、扣掉的分);辅助指标的定义写在填圆的代码里。 - `metricDefinitions(task)`:最终指标和常规辅助指标的定义(墙钟、CPU 秒、峰值内存、最小间隙、原分数、原违反量、扣掉的分);辅助指标的定义写在填圆的代码里。
- `protectedChanges(task, changedFiles)`:改动过的文件里属于"不许改"名单的那些。 - `protectedChanges(task, changedFiles)`:改动过的文件里属于"不许改"名单的那些。
@@ -481,3 +481,15 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录
- `prepareJudging(task, runDir, judgeDir, imageDigest, formal, hypothesisMetrics)`:判分第一步。正式运行超时、从外面被杀、退出码非 0,或者没有产物,直接交回不算数的判分结果;否则把产物和宿主保管的校验器复制进判分目录,交回校验器的运行定义。 - `prepareJudging(task, runDir, judgeDir, imageDigest, formal, hypothesisMetrics)`:判分第一步。正式运行超时、从外面被杀、退出码非 0,或者没有产物,直接交回不算数的判分结果;否则把产物和宿主保管的校验器复制进判分目录,交回校验器的运行定义。
- `judge(task, runDir, judgeDir, formal, validator, hypothesisMetrics)`:判分第二步。读校验器写的判定和候选写的指标文件,加上正式运行的结果,交回 `JudgedResult`:校验器判合法(含合法化后合法)算数;判不合法、或"缺了整次作废"的假设专用指标测不到,不算数。合法化的三个数既进 `extra`,也是常规辅助指标的取值;测不到的指标记测不到并附原因。校验器自己没正常跑完、判定文件认不出、修后分数高于原分数时报错停下,不当成候选的失败。 - `judge(task, runDir, judgeDir, formal, validator, hypothesisMetrics)`:判分第二步。读校验器写的判定和候选写的指标文件,加上正式运行的结果,交回 `JudgedResult`:校验器判合法(含合法化后合法)算数;判不合法、或"缺了整次作废"的假设专用指标测不到,不算数。合法化的三个数既进 `extra`,也是常规辅助指标的取值;测不到的指标记测不到并附原因。校验器自己没正常跑完、判定文件认不出、修后分数高于原分数时报错停下,不当成候选的失败。
- `JudgingStep`(类型):判分第一步的结果,已判定或要跑校验器。 - `JudgingStep`(类型):判分第一步的结果,已判定或要跑校验器。
### workspace
下面是 `src/workspace/index.ts` 导出的全部内容。一份研究一个裸仓库,位置由调用方给;版本号是目录树编号(tree id),不造提交,每个版本挂一个 `refs/versions/<版本号>` 引用防 `git gc` 清理。"上一版是谁""现在的基线是哪个版本"都由 flow 记进账本,git 里不记。只用 git 的底层命令,每个 git 子进程都不读系统和用户的配置与 attributes 文件,`GIT_DIR` 只放进子进程的环境。git 命令失败直接报错,写明命令和 git 的报错原文,不重试。
- `createRepository(repo)`:在给定位置建一个空的裸仓库。
- `saveVersion(repo, dir, excludedNames)`:把目录现在的内容存成一个版本,交回版本号;自己遍历目录列文件(不用 `git add`,`.gitignore` 不起作用),名字在清单里的文件或目录在任何一层都跳过,空目录不进版本,可执行位记成 100755、其余 100644。遇到符号链接、设备文件这类既不是普通文件也不是目录的东西,交回"存不了"和那个路径。同样的内容永远得到同一个版本号。
- `checkoutVersion(repo, version, dir)`:把一个版本的文件原样写进调用方建好的空目录,还原可执行位;目录里没有 `.git`。
- `compareVersions(repo, from, to)`:比较两个版本,交回改动过的文件(路径;新增、删除还是修改;是否只改了可执行位;是否二进制;非二进制文件每一块改动在新旧两边的行号范围),以及给模型看的带 3 行上下文的 `git diff` 原文,不加颜色。
- `SaveOutcome`(类型):存好了和版本号,或者存不了、路径和原因。
- `LineRange`、`Hunk`(类型):一块改动在一侧是"第几行到第几行";这一侧没有行时写成"在第几行之后"(0 表示文件开头),同 `git diff -U0` 的写法。
- `FileChange`、`Comparison`(类型):一个改动过的文件,二进制文件不拆块;比较的全部结果。块可以为空:空文件的新增、删除和只改可执行位都没有块,按整个文件改了算。
+5
View File
@@ -37,6 +37,11 @@ export const TaskFacts = Type.Object(
protectedFiles: Type.Array(Type.String({ minLength: 1 }), { protectedFiles: Type.Array(Type.String({ minLength: 1 }), {
description: "不许改的文件,路径相对候选代码目录(方法.md 第十部分第 2 条)", description: "不许改的文件,路径相对候选代码目录(方法.md 第十部分第 2 条)",
}), }),
unversionedNames: Type.Array(Type.String({ pattern: "^[^/]+$" }), {
description:
"存代码版本时跳过的文件名或目录名,在任何一层都跳过:运行时自动生成的缓存、运行输出等不是代码的东西。" +
"只写名字,不带 /",
}),
}, },
CLOSED, CLOSED,
); );
+13
View File
@@ -0,0 +1,13 @@
// workspace:用 git 存代码版本、取出、比较两个版本(docs/结构.md 第二部分)。
// 仓库位置由调用方给;不碰账本,版本之间的关系由 flow 记进账本。
export {
checkoutVersion,
compareVersions,
createRepository,
saveVersion,
type Comparison,
type FileChange,
type Hunk,
type LineRange,
type SaveOutcome,
} from "./workspace.ts";
+192
View File
@@ -0,0 +1,192 @@
import { spawnSync } from "node:child_process";
import { lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
// 用 git 存代码版本、取出、比较(docs/结构.md 第二部分 workspace 一行)。
// 一份研究一个裸仓库;版本号是目录树编号(tree id),不造提交,"上一版是谁"由账本记。
// 只用 git 的底层命令:存版本自己列文件,不用 git add(它按 .gitignore 悄悄漏存);
// 取出自己按清单写文件,不用 git archive(它按树里的 .gitattributes 丢文件、改换行符)。
/** 存版本的结果:版本号,或者存不了、是哪个路径、为什么。 */
export type SaveOutcome = { saved: true; version: string } | { saved: false; path: string; reason: string };
/**
* 一个差异块在一侧的行号范围,行号从 1 起。
* 这一侧有行时是第 start 到第 end 行(含两端);
* 这一侧没有行时(只删不加的块在新版本一侧、只加不删的块在旧版本一侧)写成"在第 after 行之后",
* 与 `git diff -U0` 块头里行数为 0 时的写法相同,after 为 0 表示在文件开头。
*/
export type LineRange = { start: number; end: number } | { after: number };
/** 一个差异块:不带上下文的一段连续改动,旧版本(from)和新版本(to)两侧的行号范围。 */
export interface Hunk {
from: LineRange;
to: LineRange;
}
interface ChangedFile {
/** 相对代码根目录的路径,用 / 分隔。 */
path: string;
change: "added" | "deleted" | "modified";
/** 内容没变,只改了可执行位。 */
modeOnly: boolean;
}
/**
* 一个改动过的文件。二进制文件不拆块,当作整个文件改了。
* 块可以为空:空文件的新增、删除和只改可执行位都没有块,也按整个文件改了算。
*/
export type FileChange = (ChangedFile & { binary: true }) | (ChangedFile & { binary: false; hunks: Hunk[] });
/** 比较两个版本的结果:改动过的文件(按路径排序),以及给模型看的带上下文的差异原文。 */
export interface Comparison {
files: FileChange[];
diff: string;
}
const EXECUTABLE = "100755";
const REGULAR = "100644";
/** diff-tree 原始格式里的状态字母。只存普通文件、不查改名,所以只有这三种。 */
const CHANGES: Readonly<Record<string, ChangedFile["change"]>> = { A: "added", D: "deleted", M: "modified" };
/** 在 repo 位置建一个空的裸仓库。 */
export function createRepository(repo: string): void {
git(repo, ["init", "--bare", "--quiet"], "", null);
}
/**
* 把 dir 现在的内容存成一个版本。名字在 excludedNames 里的文件或目录,在任何一层都跳过。
* 遇到既不是普通文件也不是目录的东西(符号链接、设备文件等)交回存不了:这个目录是实验员交的,
* 不替它猜怎么存。空目录不进版本(git 的目录树只记文件)。
* 同样的内容永远得到同一个版本号,所以中断后重存一次得到的号和账本里的一样。
*/
export function saveVersion(repo: string, dir: string, excludedNames: readonly string[]): SaveOutcome {
const files: Array<{ path: string; mode: string }> = [];
const refused = listFiles(dir, "", new Set(excludedNames), files);
if (refused !== null) return { saved: false, ...refused };
// --no-filters:原样存字节,不按任何配置或 .gitattributes 转换换行符、走过滤器。
const paths = files.map((file) => `${join(dir, file.path)}\n`).join("");
const ids = text(git(repo, ["hash-object", "-w", "--no-filters", "--stdin-paths"], paths, null)).split("\n");
// 每次用自己的临时索引文件,不碰仓库里的索引。
const scratch = mkdtempSync(join(tmpdir(), "inforsi-index-"));
const index = join(scratch, "index");
try {
const entries = files.map((file, i) => `${file.mode} ${ids[i]}\t${file.path}\0`).join("");
git(repo, ["update-index", "-z", "--index-info"], entries, index);
const version = text(git(repo, ["write-tree"], "", index)).trim();
// 挂一个引用:没有引用指着的对象,git gc 会把它清掉。
git(repo, ["update-ref", `refs/versions/${version}`, version], "", null);
return { saved: true, version };
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}
/** 遍历 root 下的 rel 目录,把普通文件收进 files;遇到存不了的东西就停,交回它的路径和原因。 */
function listFiles(
root: string,
rel: string,
excluded: ReadonlySet<string>,
files: Array<{ path: string; mode: string }>,
): { path: string; reason: string } | null {
for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
if (excluded.has(entry.name)) continue;
const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
if (entry.isDirectory()) {
const refused = listFiles(root, path, excluded, files);
if (refused !== null) return refused;
} else if (entry.isFile()) {
// 可执行位看文件主人的那一位,与 git 自己的判断相同。
const executable = (lstatSync(join(root, path)).mode & 0o100) !== 0;
files.push({ path, mode: executable ? EXECUTABLE : REGULAR });
} else {
const kind = entry.isSymbolicLink() ? "符号链接" : "既不是普通文件也不是目录";
return { path, reason: `${kind},代码版本只存普通文件和目录` };
}
}
return null;
}
/** 把一个版本的文件写进 dir(调用方建好的空目录),还原可执行位。dir 里不会有 .git。 */
export function checkoutVersion(repo: string, version: string, dir: string): void {
// 每条是"模式 类型 编号<TAB>路径",以 NUL 结尾。
const records = text(git(repo, ["ls-tree", "-r", "-z", version], "", null)).split("\0").slice(0, -1);
for (const record of records) {
const tab = record.indexOf("\t");
const [mode, , id] = record.slice(0, tab).split(" ") as [string, string, string];
const target = join(dir, record.slice(tab + 1));
mkdirSync(dirname(target), { recursive: true });
// cat-file 交回存进去的原始字节,不经过任何 .gitattributes 或配置的转换。
writeFileSync(target, git(repo, ["cat-file", "blob", id], "", null), { mode: mode === EXECUTABLE ? 0o755 : 0o644 });
}
}
/**
* 比较 from 和 to 两个版本。
* 文件清单来自 `git diff-tree -r -z` 的原始格式;块来自 `-U0` 的补丁,它和清单按同样的路径顺序
* 每个文件一段,按位置对上,不必解析补丁里加了引号的路径。
*/
export function compareVersions(repo: string, from: string, to: string): Comparison {
const range = ["--no-renames", from, to];
const raw = text(git(repo, ["diff-tree", "-r", "-z", ...range], "", null)).split("\0").slice(0, -1);
const sections = text(git(repo, ["diff-tree", "-r", "-p", "-U0", "--no-color", ...range], "", null))
.split(/^diff --git /m)
.slice(1);
const files: FileChange[] = [];
for (let i = 0; i < raw.length; i += 2) {
// 原始格式:":旧模式 新模式 旧编号 新编号 状态",NUL,路径。
const [, , oldId, newId, status] = (raw[i] as string).split(" ") as [string, string, string, string, string];
const section = sections[i / 2] as string;
const file = { path: raw[i + 1] as string, change: CHANGES[status] as ChangedFile["change"], modeOnly: oldId === newId };
// 补丁里内容行都以 +、-、空格开头,以这几个字开头的只能是 git 写的说明行。
if (/^Binary files /m.test(section)) files.push({ ...file, binary: true });
else files.push({ ...file, binary: false, hunks: hunks(section) });
}
const diff = text(git(repo, ["diff-tree", "-r", "-p", "--no-color", ...range], "", null));
return { files, diff };
}
/** 从一个文件的 -U0 补丁里读出全部块头 `@@ -旧起始[,旧行数] +新起始[,新行数] @@`,行数省略时是 1。 */
function hunks(section: string): Hunk[] {
return [...section.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)].map((m) => ({
from: lineRange(Number(m[1]), Number(m[2] ?? 1)),
to: lineRange(Number(m[3]), Number(m[4] ?? 1)),
}));
}
function lineRange(start: number, count: number): LineRange {
return count === 0 ? { after: start } : { start, end: start + count - 1 };
}
function text(output: Buffer): string {
return output.toString("utf8");
}
/**
* 在 repo 仓库上跑一条 git 命令,交回标准输出。失败就报错,写明命令和 git 的报错原文。
* - GIT_DIR 只放进子进程的环境,不写进 process.env:插件进程的环境会被实验员的 shell 继承。
* - 不读系统和用户的配置,也不读系统和用户的 attributes 文件:那里的换行符转换、比较算法、
* 过滤器、"按二进制处理"会悄悄改掉存进去的内容或比较的结果。
* core.attributesFile 不设时 git 会去读 ~/.config/git/attributes,所以用环境变量把它指到 /dev/null。
*/
function git(repo: string, args: string[], input: string, indexFile: string | null): Buffer {
const env: NodeJS.ProcessEnv = {
...process.env,
GIT_DIR: repo,
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_ATTR_NOSYSTEM: "1",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "core.attributesFile",
GIT_CONFIG_VALUE_0: "/dev/null",
};
if (indexFile !== null) env.GIT_INDEX_FILE = indexFile;
const result = spawnSync("git", args, { env, input, maxBuffer: Infinity });
if (result.error !== undefined) throw new Error(`git ${args.join(" ")} 起不来:${result.error.message}`, { cause: result.error });
if (result.status !== 0) {
throw new Error(`git ${args.join(" ")}(GIT_DIR=${repo})失败,退出码 ${result.status},信号 ${result.signal}:\n${text(result.stderr)}`);
}
return result.stdout;
}
+2 -1
View File
@@ -4,5 +4,6 @@
"epsilon": 0.01, "epsilon": 0.01,
"knownBest": 2.635983, "knownBest": 2.635983,
"run": { "wallSeconds": 120, "cores": 1, "runtime": "runc" }, "run": { "wallSeconds": 120, "cores": 1, "runtime": "runc" },
"protectedFiles": ["harness.py", "validate.py", "check-candidate.py", "metrics-spec.json", "solution.json", "evaluate.py", "LICENSE", "SOURCES.md"] "protectedFiles": ["harness.py", "validate.py", "check-candidate.py", "metrics-spec.json", "evaluate.py", "LICENSE", "SOURCES.md"],
"unversionedNames": ["__pycache__", ".git", "solution.json", "metrics.json"]
} }
+6 -1
View File
@@ -148,6 +148,7 @@ test("读任务:task.json 的值、Dockerfile 所在目录、TASK.md 全文;
assert.equal(task.facts.epsilon, 0.01); assert.equal(task.facts.epsilon, 0.01);
assert.equal(task.facts.knownBest, 2.635983); assert.equal(task.facts.knownBest, 2.635983);
assert.deepEqual(task.facts.run, { wallSeconds: 120, cores: 1, runtime: "runc" }); assert.deepEqual(task.facts.run, { wallSeconds: 120, cores: 1, runtime: "runc" });
assert.deepEqual(task.facts.unversionedNames, ["__pycache__", ".git", "solution.json", "metrics.json"]);
assert.ok(existsSync(join(task.dir, "Dockerfile"))); assert.ok(existsSync(join(task.dir, "Dockerfile")));
assert.match(task.guide, /^# 填圆任务/); assert.match(task.guide, /^# 填圆任务/);
const ids = metricDefinitions(task).map((d) => `${d.layer}:${d.id}`); const ids = metricDefinitions(task).map((d) => `${d.layer}:${d.id}`);
@@ -173,12 +174,14 @@ test("task.json:缺字段、多字段、类型不对,一次全部列出,
extra: true, extra: true,
} as Record<string, unknown>; } as Record<string, unknown>;
delete bad.protectedFiles; delete bad.protectedFiles;
delete bad.unversionedNames;
assert.throws( assert.throws(
() => checkTaskFacts(bad), () => checkTaskFacts(bad),
(error: Error) => { (error: Error) => {
const lines = error.message.split("\n").slice(1); const lines = error.message.split("\n").slice(1);
assert.match(error.message, /^task\.json 不合格,共 6 处/); assert.match(error.message, /^task\.json 不合格,共 7 处/);
assert.ok(lines.some((l) => l.startsWith("/protectedFiles:缺少")), error.message); assert.ok(lines.some((l) => l.startsWith("/protectedFiles:缺少")), error.message);
assert.ok(lines.some((l) => l.startsWith("/unversionedNames:缺少")), error.message);
assert.ok(lines.some((l) => l.startsWith("/extra:不认识的字段")), error.message); assert.ok(lines.some((l) => l.startsWith("/extra:不认识的字段")), error.message);
assert.ok(lines.some((l) => l.startsWith("/epsilon:")), error.message); assert.ok(lines.some((l) => l.startsWith("/epsilon:")), error.message);
assert.ok(lines.some((l) => l.startsWith("/knownBest:应为数字,没有就写 null")), error.message); assert.ok(lines.some((l) => l.startsWith("/knownBest:应为数字,没有就写 null")), error.message);
@@ -188,6 +191,8 @@ test("task.json:缺字段、多字段、类型不对,一次全部列出,
}, },
); );
assert.deepEqual(checkTaskFacts({ ...good, knownBest: null }).knownBest, null); assert.deepEqual(checkTaskFacts({ ...good, knownBest: null }).knownBest, null);
// "不存"清单只写名字:带 / 的写法在任何一层都对不上,报错。
assert.throws(() => checkTaskFacts({ ...good, unversionedNames: ["lib/__pycache__"] }), /\/unversionedNames\/0:格式不对/);
}); });
test("读任务:task.json 不是合法 JSON 时报错带文件路径", (t) => { test("读任务:task.json 不是合法 JSON 时报错带文件路径", (t) => {
+262
View File
@@ -0,0 +1,262 @@
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);
});