Files
maestro/test/checks.test.ts
T
wangjia fa4ba3db28 feat(rule13): 执行前分歧重评估闸——main 撞声明范围→needs_attention 重评估
通用规则13的(B)半边:sync main 之前先跑 reevalScopeGate,
以 git diff HEAD...origin/main 取 main 自分支点以来改动的文件,
与任务 scopeFiles 求交集;非空即 emit needs-reeval →
daemon markReeval 直接转 needs_attention(不重试/不计失败/run收尾cancelled)。

- checks.ts: + reevalScopeGate / ReevalGateFn / ReevalGateResult
- pipeline.ts: createWorktree 后、syncMain 前插入重评估闸 + reevalGate 依赖注入
- protocol.ts: + needs-reeval OutboxRecord/Payload 事件
- ingest.ts: + case 'needs-reeval' → store.markReeval
- store.ts: + markReeval(executing→needs_attention,不进重试链)
- status.ts: executing 合法转移 + needs_attention
- 测试 +5:reevalScopeGate(命中/未命中/空/无前移/git错) · pipeline(命中emit/未命中续跑) · store.markReeval;全套 242 绿
- docs: optimization-plan.html 规则13 标  已实现

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

200 lines
8.9 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, reevalScopeGate } 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);
});
test('reevalScopeGate(规则13):main 动了声明范围内文件 → overlap 非空;范围外 → 空;空声明 → 空', async () => {
const repo = mkdtempSync(join(tmpdir(), 'maestro-reeval-'));
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']);
execFileSync('mkdir', ['-p', join(repo, 'src')]);
writeFileSync(join(repo, 'src', 'a.ts'), 'a\n');
writeFileSync(join(repo, 'docs.md'), 'd\n');
g(['add', '-A']); g(['commit', '-qm', 'base']);
// 任务分支从此刻 main 切出(尚无任务提交,模拟 sync 前的 worktree HEAD
g(['checkout', '-q', '-b', 'feature']);
// main 在任务排队期间前移:改了 src/a.ts(声明范围内)
g(['checkout', '-q', 'main']);
writeFileSync(join(repo, 'src', 'a.ts'), 'a changed by main\n');
g(['add', '-A']); g(['commit', '-qm', 'main moved']);
g(['checkout', '-q', 'feature']); // worktree HEAD 回到分支点
try {
// 声明 src/**main 动了 src/a.ts → 撞上,触发重评估
const hit = await reevalScopeGate(repo, 'main', ['src/**']);
assert.deepEqual(hit.overlap, ['src/a.ts']);
// 声明 docs.mdmain 只动了 src/a.ts → 不撞,不触发
const miss = await reevalScopeGate(repo, 'main', ['docs.md']);
assert.deepEqual(miss.overlap, []);
// 空声明 → 不评估
const skip = await reevalScopeGate(repo, 'main', []);
assert.deepEqual(skip.overlap, []);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
test('reevalScopeGate(规则13):main 无前移 → 空 overlapgit 出错 → 空', async () => {
const repo = mkdtempSync(join(tmpdir(), 'maestro-reeval2-'));
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, 'src.ts'), 'x\n');
g(['add', '-A']); g(['commit', '-qm', 'base']);
g(['checkout', '-q', '-b', 'feature']); // 分支点 = main tipmain 未前移
try {
const r = await reevalScopeGate(repo, 'main', ['**']);
assert.deepEqual(r.overlap, []);
} finally {
rmSync(repo, { recursive: true, force: true });
}
const err = await reevalScopeGate('/nonexistent-repo-xyz', 'main', ['src/**']);
assert.deepEqual(err.overlap, []);
});