Enforce bounded synchronous shellout use across the engine. - Audit every production synchronous shellout against a call-site allowlist. - Bound data-dependent git diff commands by timeout and output size. - Document the async shellout invariant and align focused command guards. Files changed: AGENTS.md | 2 +- docs/architecture.md | 1 + .../__tests__/engine-no-blocking-shellout.test.ts | 135 +++++++++++++++++++++ .../user-configured-command-no-execsync.test.ts | 5 +- packages/engine/src/merger-git-parse.ts | 16 ++- .../engine/src/merger-workspace-test-commands.ts | 27 ++++- 6 files changed, 181 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8367 Fusion-Task-Lineage: 976384e6-f283-4464-9f74-f328f2be3430 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
118 lines
4.5 KiB
TypeScript
118 lines
4.5 KiB
TypeScript
/**
|
||
* FNXC:CodeOrganization 2026-07-16-00:30:
|
||
* Pure git/test-output parse helpers peeled from merger.ts (string parsing only).
|
||
*
|
||
* FNXC:CodeOrganization 2026-07-16-14:00:
|
||
* Also hosts getBranchChangedFiles + quoteArg (shell-safe git name-only diff).
|
||
*/
|
||
import { execFileSync } from "node:child_process";
|
||
|
||
const BOUNDED_GIT_DIFF_TIMEOUT_MS = 5_000;
|
||
const BOUNDED_GIT_DIFF_MAX_BUFFER = 10 * 1024 * 1024;
|
||
|
||
|
||
export function parseFailingFilesFromOutput(output: string): string[] {
|
||
const paths = new Set<string>();
|
||
for (const line of output.split("\n")) {
|
||
// jest/vitest: "FAIL packages/engine/src/__tests__/foo.test.ts"
|
||
const failMatch = line.match(/^FAIL\s+(\S+)/);
|
||
if (failMatch && failMatch[1]) {
|
||
paths.add(failMatch[1]);
|
||
continue;
|
||
}
|
||
// vitest summary: " ❯ packages/engine/src/__tests__/foo.test.ts (2 tests | 1 failed)"
|
||
const vitestSummaryMatch = line.match(/^\s*[❯>]\s+(\S+\.(?:test|spec)\.[jt]sx?)\s/);
|
||
if (vitestSummaryMatch && vitestSummaryMatch[1]) {
|
||
paths.add(vitestSummaryMatch[1]);
|
||
continue;
|
||
}
|
||
// vitest: " × src/__tests__/foo.test.ts > some test name"
|
||
const crossMatch = line.match(/^\s*[×✕✗]\s+(\S+\.(?:test|spec)\.[jt]sx?)\s/);
|
||
if (crossMatch && crossMatch[1]) {
|
||
paths.add(crossMatch[1]);
|
||
}
|
||
}
|
||
return Array.from(paths);
|
||
}
|
||
|
||
/** Parse `git status -z --porcelain` into a Set of paths.
|
||
*
|
||
* Format per entry: `XY <space> <path>\0` where X = staged status, Y =
|
||
* unstaged status. Renames and copies are special: they emit TWO
|
||
* NUL-separated entries, `R <new>\0<old>\0` (or `C <new>\0<old>\0`).
|
||
* We must consume the trailing `<old>` entry without treating it as a
|
||
* separate path, otherwise observability code over-reports "cleared
|
||
* paths" with the historical names of renames. */
|
||
export function parsePorcelainZ(raw: string): Set<string> {
|
||
const paths = new Set<string>();
|
||
const entries = raw.split("\0");
|
||
for (let i = 0; i < entries.length; i++) {
|
||
const entry = entries[i];
|
||
if (!entry) continue;
|
||
if (entry.length < 4) continue;
|
||
const status = entry.slice(0, 2);
|
||
const path = entry.slice(3);
|
||
if (!path) continue;
|
||
paths.add(path);
|
||
// Rename/copy: the very next entry is the old path — skip it so it
|
||
// isn't mistaken for an independent dirty path.
|
||
if (status.charAt(0) === "R" || status.charAt(0) === "C") {
|
||
i++;
|
||
}
|
||
}
|
||
return paths;
|
||
}
|
||
|
||
export function parseShortstatSummary(statsOutput: string): { filesChanged: number; insertions: number; deletions: number } {
|
||
const normalized = statsOutput.trim().replace(/\n/g, " ");
|
||
const filesMatch = normalized.match(/(\d+) files? changed/);
|
||
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
|
||
const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/);
|
||
return {
|
||
filesChanged: filesMatch ? Number.parseInt(filesMatch[1], 10) : 0,
|
||
insertions: insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0,
|
||
deletions: deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0,
|
||
};
|
||
}
|
||
|
||
export function quoteArg(value: string): string {
|
||
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
|
||
}
|
||
|
||
/**
|
||
* Get the set of files changed in the branch relative to the base branch.
|
||
* Uses `git diff --name-only -z <baseBranch>...HEAD` (three-dot range so it
|
||
* computes the diff from the merge-base, not the current HEAD of baseBranch).
|
||
*
|
||
* FNXC:CodeOrganization 2026-07-16-16:00:
|
||
* Invoke git via execFileSync argv (no shell) so branch names cannot break
|
||
* quoting on Windows cmd.exe, and parse NUL-delimited paths so whitespace/
|
||
* newlines in filenames are preserved. Empty array on git errors (unknown).
|
||
*
|
||
* FNXC:EngineAsyncInvariant 2026-07-29-00:00:
|
||
* This data-dependent git diff remains short plumbing only with an explicit
|
||
* wall-clock timeout and bounded output. Do not remove either bound or add an
|
||
* unbounded synchronous shellout on the engine's shared event loop.
|
||
*
|
||
* @internal Exported for testing only.
|
||
*/
|
||
export function getBranchChangedFiles(rootDir: string, baseBranch: string, branch: string): string[] {
|
||
try {
|
||
const headRef = branch === "HEAD" ? "HEAD" : branch;
|
||
const output = execFileSync(
|
||
"git",
|
||
["diff", "--name-only", "-z", `${baseBranch}...${headRef}`],
|
||
{
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
timeout: BOUNDED_GIT_DIFF_TIMEOUT_MS,
|
||
maxBuffer: BOUNDED_GIT_DIFF_MAX_BUFFER,
|
||
},
|
||
);
|
||
return String(output).split("\0").map((f) => f.trim()).filter(Boolean);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|