Files
Cursor Agent 5b6a0899ce feat(plugins): build-test atom shell-out runner (Phase 7 entry slice)
Plan N1 / spec §10 / §22.4 / §16 Phase 7.

apps/daemon/src/plugins/atoms/build-test.ts ships the daemon-side
implementation that the bundled plugins/_official/atoms/build-test
SKILL.md fragment locks the contract for:

  runBuildTest({ cwd, buildCommand?, testCommand?, ... }) → BuildTestReport

The runner:
  - Honours explicit overrides (highest priority).
  - Otherwise infers commands from package.json scripts:
      typecheck (or build) → `{pm} run typecheck`
      test                  → `{pm} run test`
  - Auto-picks the package manager from lockfile presence
    (pnpm-lock.yaml → pnpm; yarn.lock → yarn; else npm).
  - Skips a half cleanly when no command resolved, recording an
    explicit reason. Skipped doesn't flip the matching signal off
    (a project that opts out of typecheck still gets build.passing=true).
  - Caps combined stdout+stderr at 1 MiB by default, marks
    truncated output with '…[truncated]'.
  - Per-command timeout 5min default, sends SIGTERM at expiry.
  - Spawn function is pluggable so callers can inject a stub.

writeBuildTestReport() persists the canonical critique/build-test.json
+ critique/build-test.log layout the SKILL.md fragment promises.

UntilSignals gains 'build.passing' + 'tests.passing' so a plan can
write `until: 'build.passing && tests.passing'` directly. Closes
the spec §22.4 limit-1 gap that was blocking scenario 2 (code
migration) from natively expressing 'build/test pass = converged'.

The score axis (legacy critique.score) maps:
  both pass        → 5
  only build pass  → 3
  both fail        → 1
so existing pipelines that read critique.score keep working.

Daemon tests: 1521 → 1528 (+7 cases on plugins-build-test:
both-pass, build-fail, skip-with-reason, log truncation, npm
script inference, pnpm preference, JSON + log persistence).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 14:30:36 +00:00

279 lines
9.9 KiB
TypeScript

// Phase 7 entry slice / spec §10 / §22.4 — build-test atom runner.
//
// Spec-side atom contract lives in `plugins/_official/atoms/build-test/SKILL.md`.
// This module is the daemon-side shell-out implementation: given a
// project cwd + an optional command override, run typecheck + test
// commands, write `critique/build-test.json`, and return the
// signals the devloop's `until` evaluator reads
// (`build.passing`, `tests.passing`, plus the legacy `critique.score`).
//
// The runner is intentionally framework-agnostic — it prefers the
// declared override, falls back to scripts inside `package.json`,
// and otherwise emits a `tests: 'skipped'` outcome with an explicit
// reason so the agent doesn't claim success on a project it never
// actually tested.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { spawn, type SpawnOptions } from 'node:child_process';
import type { UntilSignals } from '../until.js';
export type BuildTestStatus = 'passing' | 'failing' | 'skipped';
export interface BuildTestCommandResult {
command: string;
exitCode: number;
durationMs: number;
status: BuildTestStatus;
// Truncated combined stdout + stderr. Bounded by `logBudgetBytes`.
log: string;
// When status='skipped'; explains why.
reason?: string;
}
export interface BuildTestReport {
build: BuildTestCommandResult;
tests: BuildTestCommandResult;
// Signals the pipeline runner forwards into the devloop `until` eval.
signals: UntilSignals;
// ISO timestamp of when the run completed.
endedAt: string;
}
export interface BuildTestRunOptions {
// Project cwd. The atom shells out inside this directory; the
// caller is responsible for staging the imported repo here.
cwd: string;
// Explicit overrides (highest priority). Either OR both can be
// null — the atom skips the matching half cleanly.
buildCommand?: string | null;
testCommand?: string | null;
// Per-command timeout (ms). Default 5 min.
timeoutMs?: number;
// Cap on how many bytes of combined stdout/stderr we keep per
// command. Default 1 MiB.
logBudgetBytes?: number;
// Pluggable child-process spawner so unit tests don't actually
// shell out. Production passes node:child_process.spawn.
spawnFn?: (cmd: string, args: string[], opts: SpawnOptions) => ReturnType<typeof spawn>;
}
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_LOG_BUDGET = 1 * 1024 * 1024;
export async function runBuildTest(opts: BuildTestRunOptions): Promise<BuildTestReport> {
const cwd = path.resolve(opts.cwd);
const inferred = await inferCommands(cwd);
const buildCmd = opts.buildCommand !== undefined ? opts.buildCommand : inferred.build;
const testCmd = opts.testCommand !== undefined ? opts.testCommand : inferred.test;
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const logBudget = opts.logBudgetBytes ?? DEFAULT_LOG_BUDGET;
const build = await runCommandOrSkip({
label: 'build',
command: buildCmd,
cwd,
timeoutMs,
logBudget,
spawnFn: opts.spawnFn,
skipReason: 'no build / typecheck command resolved (set --build-command or add `typecheck` to package.json scripts)',
});
const tests = await runCommandOrSkip({
label: 'tests',
command: testCmd,
cwd,
timeoutMs,
logBudget,
spawnFn: opts.spawnFn,
skipReason: 'no test command resolved (set --test-command or add `test` to package.json scripts)',
});
const signals = computeSignals({ build, tests });
return { build, tests, signals, endedAt: new Date().toISOString() };
}
// Persist the report under the project cwd in the canonical layout the
// SKILL.md fragment locks. Returns the file paths so the caller can
// echo them on the run's SSE stream.
export async function writeBuildTestReport(args: {
cwd: string;
report: BuildTestReport;
}): Promise<{ jsonPath: string; logPath: string }> {
const dir = path.join(args.cwd, 'critique');
await fsp.mkdir(dir, { recursive: true });
const jsonPath = path.join(dir, 'build-test.json');
const logPath = path.join(dir, 'build-test.log');
const json = {
build: statusOnly(args.report.build),
tests: statusOnly(args.report.tests),
durationMs: args.report.build.durationMs + args.report.tests.durationMs,
commandsRun: [args.report.build.command, args.report.tests.command].filter(Boolean),
failures: [args.report.build, args.report.tests]
.filter((c) => c.status === 'failing')
.map((c) => ({ command: c.command, exitCode: c.exitCode })),
endedAt: args.report.endedAt,
signals: args.report.signals,
};
await fsp.writeFile(jsonPath, JSON.stringify(json, null, 2) + '\n', 'utf8');
const logBody = [
`# build (${args.report.build.status}) — ${args.report.build.command || '<skipped>'}`,
args.report.build.log,
'',
`# tests (${args.report.tests.status}) — ${args.report.tests.command || '<skipped>'}`,
args.report.tests.log,
].join('\n');
await fsp.writeFile(logPath, logBody, 'utf8');
return { jsonPath, logPath };
}
interface InferredCommands {
build: string | null;
test: string | null;
}
async function inferCommands(cwd: string): Promise<InferredCommands> {
try {
const raw = await fsp.readFile(path.join(cwd, 'package.json'), 'utf8');
const pkg = JSON.parse(raw) as { scripts?: Record<string, string> };
const scripts = pkg.scripts ?? {};
const pmRunner = await detectPackageManager(cwd);
const buildScript = scripts.typecheck ?? scripts.build ?? null;
const testScript = scripts.test ?? null;
return {
build: buildScript ? `${pmRunner} run ${pickScriptName(scripts, ['typecheck', 'build'])}` : null,
test: testScript ? `${pmRunner} run test` : null,
};
} catch {
return { build: null, test: null };
}
}
function pickScriptName(scripts: Record<string, string>, preferred: string[]): string {
for (const name of preferred) if (scripts[name]) return name;
return preferred[0]!;
}
async function detectPackageManager(cwd: string): Promise<'pnpm' | 'npm' | 'yarn'> {
if (await pathExists(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm';
if (await pathExists(path.join(cwd, 'yarn.lock'))) return 'yarn';
return 'npm';
}
async function pathExists(p: string): Promise<boolean> {
try { await fsp.access(p); return true; } catch { return false; }
}
interface RunCommandArgs {
label: 'build' | 'tests';
command: string | null | undefined;
cwd: string;
timeoutMs: number;
logBudget: number;
spawnFn: BuildTestRunOptions['spawnFn'];
skipReason: string;
}
async function runCommandOrSkip(args: RunCommandArgs): Promise<BuildTestCommandResult> {
if (!args.command) {
return {
command: '',
exitCode: 0,
durationMs: 0,
status: 'skipped',
log: '',
reason: args.skipReason,
};
}
return runShell({
command: args.command,
cwd: args.cwd,
timeoutMs: args.timeoutMs,
logBudget: args.logBudget,
spawnFn: args.spawnFn,
});
}
interface RunShellArgs {
command: string;
cwd: string;
timeoutMs: number;
logBudget: number;
spawnFn?: BuildTestRunOptions['spawnFn'];
}
function runShell(args: RunShellArgs): Promise<BuildTestCommandResult> {
return new Promise((resolve) => {
const startedAt = Date.now();
const launcher = args.spawnFn ?? spawn;
const child = launcher('sh', ['-c', args.command], {
cwd: args.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let buffer = '';
let truncated = false;
const onChunk = (chunk: Buffer | string) => {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
const remaining = args.logBudget - buffer.length;
if (remaining <= 0) { truncated = true; return; }
buffer += text.length > remaining ? text.slice(0, remaining) : text;
if (text.length > remaining) truncated = true;
};
child.stdout?.on('data', onChunk);
child.stderr?.on('data', onChunk);
const timer = setTimeout(() => {
try { child.kill('SIGTERM'); } catch { /* already dead */ }
}, args.timeoutMs);
timer.unref?.();
child.on('close', (code, signal) => {
clearTimeout(timer);
const exitCode = typeof code === 'number' ? code : (signal ? 124 : 1);
const status: BuildTestStatus = exitCode === 0 ? 'passing' : 'failing';
resolve({
command: args.command,
exitCode,
durationMs: Date.now() - startedAt,
status,
log: truncated ? buffer + '\n…[truncated]' : buffer,
});
});
child.on('error', (err) => {
clearTimeout(timer);
resolve({
command: args.command,
exitCode: 1,
durationMs: Date.now() - startedAt,
status: 'failing',
log: `[spawn-error] ${err.message ?? String(err)}`,
});
});
});
}
function statusOnly(c: BuildTestCommandResult) {
const out: Record<string, unknown> = {
command: c.command,
status: c.status,
exitCode: c.exitCode,
durationMs: c.durationMs,
};
if (c.reason) out.reason = c.reason;
return out;
}
function computeSignals({ build, tests }: { build: BuildTestCommandResult; tests: BuildTestCommandResult }): UntilSignals {
const buildPassing = build.status === 'passing' || build.status === 'skipped';
const testsPassing = tests.status === 'passing' || tests.status === 'skipped';
// Legacy critique.score axis: 5 when both pass, 3 when only build
// passes, 1 otherwise. spec §22.4 says the scoring is meant for
// pipelines that already read critique.score; new pipelines should
// read build.passing / tests.passing directly.
const score = buildPassing && testsPassing ? 5
: buildPassing ? 3
: 1;
return {
'build.passing': buildPassing,
'tests.passing': testsPassing,
'critique.score': score,
};
}