Files
openclaw/scripts/check-no-conflict-markers.mjs
efa7e1a85a fix(scripts): bound conflict-marker scan reads to prevent OOM on large files (#104420)
* fix(scripts): bound conflict-marker scan reads to prevent OOM on large files

* test(scripts): add regression test for bounded conflict-marker scan on large files

* fix(scripts): update conflict-marker declaration for bounded scan params

* fix(scripts): bounded exact scan for conflict markers

* fix(scripts): use git grep line-number output for conflict-marker scan

* style(scripts): rename shadowed variable in conflict-marker parser

* ci(scripts): retry build artifacts after plugin-sdk export check flake

* fix(scripts): report tracked conflict-marker paths relative to cwd

* test(scripts): type execFileSync error by stderr shape

* fix(scripts): parse NUL-delimited path before record split in conflict-marker scan

* refactor(scripts): remove obsolete conflict scan helper

* fix(scripts): disable color in conflict marker scan

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
2026-07-18 12:25:40 +01:00

167 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// Rejects unresolved merge conflict markers in tracked files.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const CONFLICT_MARKER_GREP_PATTERN = "^(<<<<<<< |\\|\\|\\|\\|\\|\\|\\| |=======$|>>>>>>> )";
function isBinaryBuffer(buffer) {
return buffer.includes(0);
}
/**
* Returns one-based line numbers containing merge conflict markers.
*/
export function findConflictMarkerLines(content) {
const lines = content.split(/\r?\n/u);
const matches = [];
for (const [index, line] of lines.entries()) {
if (
line.startsWith("<<<<<<< ") ||
line.startsWith("||||||| ") ||
line === "=======" ||
line.startsWith(">>>>>>> ")
) {
matches.push(index + 1);
}
}
return matches;
}
/**
* Scans a list of files for merge conflict markers, skipping binary content.
* This is kept for direct, small-scale use (tests); the tracked-files path
* uses git grep directly to avoid reading large files into memory.
*/
export function findConflictMarkersInFiles(filePaths, readFile = fs.readFileSync) {
const violations = [];
for (const filePath of filePaths) {
let content;
try {
content = readFile(filePath);
} catch {
continue;
}
if (!Buffer.isBuffer(content)) {
content = Buffer.from(String(content));
}
if (isBinaryBuffer(content)) {
continue;
}
const lines = findConflictMarkerLines(content.toString("utf8"));
if (lines.length > 0) {
violations.push({
filePath,
lines,
});
}
}
return violations;
}
/**
* Parses output from `git grep -n -z -o -I -E` into violation records.
* The record format is:
* path\0line-number\0match-text\n
* The path must be read from its NUL delimiter before any newline-based
* record splitting: -z reports paths verbatim, and git allows newlines in
* tracked filenames, so splitting the output on newlines first would cut a
* newline-containing path across two records and silently mangle it.
* Keeping parsing to exact grep match records avoids reading candidate files
* whole and keeps memory bounded regardless of file size.
*/
function parseGitGrepConflictMarkerOutput(stdout) {
const byPath = new Map();
const output = stdout.toString("utf8");
let cursor = 0;
while (cursor < output.length) {
const pathEnd = output.indexOf("\0", cursor);
if (pathEnd === -1) {
break;
}
const lineNumberEnd = output.indexOf("\0", pathEnd + 1);
if (lineNumberEnd === -1) {
break;
}
// With -o the match text is a single-line match, so the record ends at
// the next newline after the line-number field.
const matchEnd = output.indexOf("\n", lineNumberEnd + 1);
const relativePath = output.slice(cursor, pathEnd);
const lineNumber = Number(output.slice(pathEnd + 1, lineNumberEnd));
cursor = matchEnd === -1 ? output.length : matchEnd + 1;
if (!relativePath || !Number.isFinite(lineNumber) || lineNumber <= 0) {
continue;
}
const existing = byPath.get(relativePath);
if (existing) {
existing.push(lineNumber);
} else {
byPath.set(relativePath, [lineNumber]);
}
}
const violations = [];
for (const [relativePath, lineNumbers] of byPath) {
lineNumbers.sort((a, b) => a - b);
violations.push({
filePath: relativePath,
lines: lineNumbers,
});
}
violations.sort((a, b) => a.filePath.localeCompare(b.filePath));
return violations;
}
/**
* Uses git grep to find exact conflict-marker matches in tracked files.
*/
export function findConflictMarkersInTrackedFiles(cwd = process.cwd(), run = spawnSync) {
const result = run(
"git",
["grep", "--no-color", "-n", "-z", "-o", "-I", "-E", CONFLICT_MARKER_GREP_PATTERN, "--", "."],
{
cwd,
encoding: "buffer",
},
);
if (result.status === 1) {
return [];
}
if (result.status !== 0) {
const stderr = result.stderr?.toString("utf8").trim();
throw new Error(stderr || `git grep failed with status ${result.status ?? "unknown"}`);
}
return parseGitGrepConflictMarkerOutput(result.stdout);
}
/**
* Runs the merge conflict marker check.
*/
export async function main() {
const cwd = process.cwd();
const violations = findConflictMarkersInTrackedFiles(cwd);
if (violations.length === 0) {
return;
}
console.error("Found unresolved merge conflict markers:");
for (const violation of violations) {
// findConflictMarkersInTrackedFiles already returns paths relative to cwd.
console.error(`- ${violation.filePath}:${violation.lines.join(",")}`);
}
process.exitCode = 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(
/** @param {unknown} error */ (error) => {
console.error(error);
process.exit(1);
},
);
}