fa4ba3db28
通用规则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>
200 lines
8.9 KiB
TypeScript
200 lines
8.9 KiB
TypeScript
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('diffSizeGate:git 出错 → 不拦截(测不出体量)', 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('scopeFileGate:git 出错 → 不拦截', 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.md,main 只动了 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 无前移 → 空 overlap;git 出错 → 空', 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 tip,main 未前移
|
||
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, []);
|
||
});
|