Files
fusion/packages/engine/src/merger-git-parse.ts
gsxdsm ccb7d4e8ff FN-8367: enforce bounded engine shellouts
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>
2026-07-19 16:00:28 -07:00

118 lines
4.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.
/**
* 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 [];
}
}