Files
maestro/test/checks.test.ts
T
wangjia 59ce391ecc feat(models+gate): 模型按项目可配置(默认 opus-4.8) + diff 声明外文件硬闸
① 模型可配置(项目维度,默认 opus-4.8)
- 新增 projects.models(JSON,按角色 executor/planner/reviewer/conflict
  覆盖,值可为字符串=全复杂度统一 或 {easy,medium,hard} 分档)
- models.ts 重构 resolveModel:优先级 项目级 models > 旧 project.model
  (仅 executor/planner) > env > 默认 DEFAULT_MODEL(opus-4.8)
- 取消内置 fable/sonnet 分档默认:所有角色默认 opus-4.8(彻底回避 fable-5
  不可用问题,需要时项目级显式配置即可);回退链改 opus→sonnet→fable
- API PATCH /projects 透传 models;sanitizeModels 落库校验

② diff 声明外文件闸(task.scopeFiles)
- 新增 tasks.scope_files(JSON glob/路径数组)
- checks.ts: globToRegExp/matchesAnyGlob + scopeFileGate(改动文件越界=硬闸,
  空声明跳过,git 出错不拦截);pipeline runApproveGates 接入
- planner 拆解新增每子任务 files 字段:prompt 要求 + parseDecompose 解析 +
  ingest 落 scopeFiles,自动填充声明范围
- executor prompt 注入「声明文件范围约束」,让 agent 知边界(gate 才公平)

迁移:projects.models / tasks.scope_files 走 ensureColumn 幂等迁移(旧库补列)
测试:models 默认/配置/优先级、scope glob/gate、planner files 解析、
      store 持久化往返、迁移补列 —— 237 通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:06:32 +08:00

148 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { parseChecks, runChecks, diffSizeGate, globToRegExp, matchesAnyGlob, scopeFileGate } from '../src/executor/checks.js';
import type { Project } from '../src/model/types.js';
function withTmpData(cb: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = mkdtempSync(join(tmpdir(), 'maestro-checks-'));
const prev = process.env.MAESTRO_DATA_DIR;
process.env.MAESTRO_DATA_DIR = dir;
const done = () => {
if (prev === undefined) delete process.env.MAESTRO_DATA_DIR;
else process.env.MAESTRO_DATA_DIR = prev;
rmSync(dir, { recursive: true, force: true });
};
try {
const r = cb(dir);
if (r instanceof Promise) return r.finally(done);
done();
} catch (e) { done(); throw e; }
}
function proj(checks: string | null): Project {
return { checks, repoPath: '/tmp/x', defaultBranch: 'main' } as Project;
}
test('parseChecks:合法 JSON 取 lint/typecheck/build,非法/空 → {}', () => {
assert.deepEqual(parseChecks('{"lint":"npm run lint","build":"npm run build"}'), { lint: 'npm run lint', build: 'npm run build' });
assert.deepEqual(parseChecks('{"other":"x","typecheck":" tsc "}'), { typecheck: 'tsc' });
assert.deepEqual(parseChecks(null), {});
assert.deepEqual(parseChecks('not json'), {});
assert.deepEqual(parseChecks('{"lint":""}'), {}); // 空命令跳过
});
test('runChecks:无 checks → 通过', async () => {
await withTmpData(async (dir) => {
const r = await runChecks(proj(null), dir, 'run_a');
assert.equal(r.ok, true);
assert.equal(r.logRef, null);
});
});
test('runChecks:各项 exit0 → 通过;任一非0 → 失败并报哪项挂', async () => {
await withTmpData(async (dir) => {
const pass = await runChecks(proj('{"lint":"true","typecheck":"true"}'), dir, 'run_b');
assert.equal(pass.ok, true);
assert.equal(pass.failed, null);
const fail = await runChecks(proj('{"lint":"true","typecheck":"false","build":"true"}'), dir, 'run_c');
assert.equal(fail.ok, false);
assert.equal(fail.failed, 'typecheck');
assert.match(fail.error!, /typecheck 检查失败/);
});
});
test('diffSizeGate:超文件/行阈值 → 拦截;阈值内 → 通过', async () => {
const repo = mkdtempSync(join(tmpdir(), 'maestro-gitdiff-'));
const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString();
g(['init', '-q', '-b', 'main']);
g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']);
writeFileSync(join(repo, 'base.txt'), 'base\n');
g(['add', '-A']); g(['commit', '-qm', 'base']);
g(['checkout', '-q', '-b', 'feature']);
// 改动:3 个文件、若干行
for (const f of ['a.txt', 'b.txt', 'c.txt']) writeFileSync(join(repo, f), 'x\ny\nz\n');
g(['add', '-A']); g(['commit', '-qm', 'feat']);
const prevF = process.env.MAESTRO_DIFF_MAX_FILES;
const prevL = process.env.MAESTRO_DIFF_MAX_LINES;
try {
process.env.MAESTRO_DIFF_MAX_FILES = '2'; // 3 文件 > 2 → 拦截
process.env.MAESTRO_DIFF_MAX_LINES = '0';
const over = await diffSizeGate(repo, repo, 'feature', 'main');
assert.equal(over.ok, false);
assert.equal(over.files, 3);
assert.match(over.error!, /文件数 3 > 2/);
process.env.MAESTRO_DIFF_MAX_FILES = '0'; // 0=不限
process.env.MAESTRO_DIFF_MAX_LINES = '0';
const under = await diffSizeGate(repo, repo, 'feature', 'main');
assert.equal(under.ok, true);
assert.equal(under.files, 3);
} finally {
if (prevF === undefined) delete process.env.MAESTRO_DIFF_MAX_FILES; else process.env.MAESTRO_DIFF_MAX_FILES = prevF;
if (prevL === undefined) delete process.env.MAESTRO_DIFF_MAX_LINES; else process.env.MAESTRO_DIFF_MAX_LINES = prevL;
rmSync(repo, { recursive: true, force: true });
}
});
test('diffSizeGategit 出错 → 不拦截(测不出体量)', async () => {
const r = await diffSizeGate('/nonexistent-repo-xyz', '/tmp', 'a', 'b');
assert.equal(r.ok, true);
});
test('globToRegExp / matchesAnyGlob* 单层、** 跨层、? 单字符、目录式', () => {
assert.ok(globToRegExp('src/*.ts').test('src/a.ts'));
assert.ok(!globToRegExp('src/*.ts').test('src/sub/a.ts')); // * 不跨 /
assert.ok(globToRegExp('src/**').test('src/sub/deep/a.ts')); // ** 跨 /
assert.ok(globToRegExp('src/').test('src/anything/x.ts')); // 目录式 → 其下全部
assert.ok(globToRegExp('a?.ts').test('ab.ts'));
assert.ok(!globToRegExp('a?.ts').test('abc.ts'));
assert.ok(globToRegExp('file.name.js').test('file.name.js')); // . 字面量
assert.ok(!globToRegExp('file.name.js').test('fileXname.js'));
assert.ok(matchesAnyGlob('lib/x.ts', ['src/**', 'lib/*.ts']));
assert.ok(!matchesAnyGlob('test/x.ts', ['src/**', 'lib/*.ts']));
});
test('scopeFileGate:改动越界文件 → 拦截;范围内 → 通过;空声明 → 跳过', async () => {
const repo = mkdtempSync(join(tmpdir(), 'maestro-scope-'));
const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString();
g(['init', '-q', '-b', 'main']);
g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']);
writeFileSync(join(repo, 'base.txt'), 'base\n');
g(['add', '-A']); g(['commit', '-qm', 'base']);
g(['checkout', '-q', '-b', 'feature']);
execFileSync('mkdir', ['-p', join(repo, 'src')]);
writeFileSync(join(repo, 'src', 'a.ts'), 'a\n');
writeFileSync(join(repo, 'other.txt'), 'o\n'); // 越界文件
g(['add', '-A']); g(['commit', '-qm', 'feat']);
try {
// 声明只允许 src/**,但改了 other.txt → 拦截
const over = await scopeFileGate(repo, 'feature', 'main', ['src/**']);
assert.equal(over.ok, false);
assert.deepEqual(over.outside, ['other.txt']);
assert.match(over.error!, /声明范围外/);
// 声明覆盖全部改动 → 通过
const ok = await scopeFileGate(repo, 'feature', 'main', ['src/**', 'other.txt']);
assert.equal(ok.ok, true);
assert.deepEqual(ok.outside, []);
// 空声明 → 跳过(不限范围)
const skip = await scopeFileGate(repo, 'feature', 'main', []);
assert.equal(skip.ok, true);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
test('scopeFileGategit 出错 → 不拦截', async () => {
const r = await scopeFileGate('/nonexistent-repo-xyz', 'a', 'b', ['src/**']);
assert.equal(r.ok, true);
});