@fitsummehari/mergeguard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/SECURITY.md +26 -0
- package/bin/mergeguard.js +11 -0
- package/docs/ARCHITECTURE.md +31 -0
- package/docs/CONFIGURATION.md +59 -0
- package/docs/DETECTORS.md +29 -0
- package/docs/INTEGRATIONS.md +40 -0
- package/docs/LAYA.md +82 -0
- package/docs/LIMITATIONS.md +10 -0
- package/docs/MIGRATION_FROM_V1.md +27 -0
- package/examples/github/mergeguard.yml +32 -0
- package/examples/gitlab/mergeguard.yml +15 -0
- package/package.json +62 -0
- package/scripts/laya_bridge.py +26 -0
- package/src/ci.js +58 -0
- package/src/cli.js +229 -0
- package/src/config.js +137 -0
- package/src/context.js +158 -0
- package/src/detectors/patterns.js +180 -0
- package/src/detectors/repository.js +45 -0
- package/src/doctor.js +70 -0
- package/src/git.js +269 -0
- package/src/hook.js +79 -0
- package/src/index.js +9 -0
- package/src/languages.js +29 -0
- package/src/package-meta.js +17 -0
- package/src/paths.js +33 -0
- package/src/reporters/gitlab.js +16 -0
- package/src/reporters/index.js +13 -0
- package/src/reporters/sarif.js +55 -0
- package/src/reporters/terminal.js +48 -0
- package/src/review.js +137 -0
- package/src/types.js +36 -0
- package/src/utils.js +183 -0
- package/src/verifiers/index.js +48 -0
- package/src/verifiers/jev.js +75 -0
- package/src/verifiers/laya.js +131 -0
- package/src/verifiers/offline.js +42 -0
- package/src/verifiers/questions.js +29 -0
- package/src/version.js +1 -0
package/src/git.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { matchesAnyGlob, normalizePath } from "./utils.js";
|
|
4
|
+
import { safeJoin } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
const ZERO_SHA = /^0+$/;
|
|
7
|
+
export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
8
|
+
const MAX_FILE_BYTES = 2_000_000;
|
|
9
|
+
|
|
10
|
+
export function git(args, { cwd = process.cwd(), input, allowFailure = false, encoding = "utf8" } = {}) {
|
|
11
|
+
try {
|
|
12
|
+
return execFileSync("git", args, {
|
|
13
|
+
cwd,
|
|
14
|
+
input,
|
|
15
|
+
encoding,
|
|
16
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
19
|
+
});
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (allowFailure) return undefined;
|
|
22
|
+
const stderr = error?.stderr?.toString?.().trim();
|
|
23
|
+
throw new Error(stderr || `git ${args.join(" ")} failed`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function repositoryRoot(cwd = process.cwd()) {
|
|
28
|
+
const root = git(["rev-parse", "--show-toplevel"], { cwd, allowFailure: true })?.trim();
|
|
29
|
+
if (!root) throw new Error("Not inside a Git repository");
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function gitAvailable() {
|
|
34
|
+
return Boolean(git(["--version"], { allowFailure: true }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function hooksDirectory(root) {
|
|
38
|
+
const configured = git(["config", "--get", "core.hooksPath"], { cwd: root, allowFailure: true })?.trim();
|
|
39
|
+
if (configured) {
|
|
40
|
+
const resolved = configured.startsWith("/") || /^[A-Za-z]:/.test(configured)
|
|
41
|
+
? configured
|
|
42
|
+
: safeJoin(root, configured) || `${root}/${configured.replaceAll("\\", "/")}`;
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
const gitDir = git(["rev-parse", "--git-path", "hooks"], { cwd: root }).trim();
|
|
46
|
+
return gitDir.startsWith("/") || /^[A-Za-z]:/.test(gitDir) ? gitDir : `${root}/${gitDir}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveReviewScope(root, options = {}) {
|
|
50
|
+
const hasHead = Boolean(git(["rev-parse", "--verify", "HEAD"], { cwd: root, allowFailure: true }));
|
|
51
|
+
const initialBase = hasHead ? "HEAD" : EMPTY_TREE;
|
|
52
|
+
if (options.push) return resolvePushScope(root, options.pushInput);
|
|
53
|
+
if (options.staged) {
|
|
54
|
+
return {
|
|
55
|
+
mode: "staged",
|
|
56
|
+
label: "staged changes",
|
|
57
|
+
diffArgs: ["diff", "--cached", "--find-renames", initialBase],
|
|
58
|
+
baseRef: initialBase,
|
|
59
|
+
headKind: "index",
|
|
60
|
+
headRef: undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (options.base || options.head) {
|
|
64
|
+
const base = options.base || defaultBaseRef(root);
|
|
65
|
+
const head = options.head || "HEAD";
|
|
66
|
+
if (!git(["rev-parse", "--verify", base], { cwd: root, allowFailure: true })) {
|
|
67
|
+
throw new Error(`Unknown Git ref '${base}'. Fetch the base branch or pass an existing --base.`);
|
|
68
|
+
}
|
|
69
|
+
if (!git(["rev-parse", "--verify", head], { cwd: root, allowFailure: true })) {
|
|
70
|
+
throw new Error(`Unknown Git ref '${head}'.`);
|
|
71
|
+
}
|
|
72
|
+
const mergeBase = git(["merge-base", base, head], { cwd: root, allowFailure: true })?.trim();
|
|
73
|
+
if (!mergeBase) throw new Error(`Could not find a merge-base between ${base} and ${head}`);
|
|
74
|
+
return {
|
|
75
|
+
mode: "range",
|
|
76
|
+
label: `${base}...${head}`,
|
|
77
|
+
diffArgs: ["diff", "--find-renames", `${base}...${head}`],
|
|
78
|
+
baseRef: mergeBase,
|
|
79
|
+
headKind: "ref",
|
|
80
|
+
headRef: head,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
mode: "working",
|
|
85
|
+
label: hasHead ? "working tree vs HEAD" : "working tree vs empty repository",
|
|
86
|
+
diffArgs: ["diff", "--find-renames", initialBase],
|
|
87
|
+
baseRef: initialBase,
|
|
88
|
+
headKind: "working",
|
|
89
|
+
headRef: undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolvePushScope(root, pushInput) {
|
|
94
|
+
const lines = String(pushInput ?? "").trim().split(/\r?\n/).filter(Boolean);
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
const [localRef, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/);
|
|
97
|
+
if (!localSha || ZERO_SHA.test(localSha)) continue;
|
|
98
|
+
let baseRef = remoteSha && !ZERO_SHA.test(remoteSha) ? remoteSha : undefined;
|
|
99
|
+
if (!baseRef) {
|
|
100
|
+
const remoteName = process.env.MERGEGUARD_REMOTE || "origin";
|
|
101
|
+
const target = remoteDefaultRef(root, remoteName);
|
|
102
|
+
if (target) baseRef = git(["merge-base", localSha, target], { cwd: root, allowFailure: true })?.trim();
|
|
103
|
+
}
|
|
104
|
+
if (!baseRef) baseRef = EMPTY_TREE;
|
|
105
|
+
return {
|
|
106
|
+
mode: "push",
|
|
107
|
+
label: `${localRef || "local"} → ${remoteRef || "remote"}`,
|
|
108
|
+
diffArgs: ["diff", "--find-renames", `${baseRef}..${localSha}`],
|
|
109
|
+
baseRef,
|
|
110
|
+
headKind: "ref",
|
|
111
|
+
headRef: localSha,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const head = git(["rev-parse", "HEAD"], { cwd: root, allowFailure: true })?.trim();
|
|
116
|
+
if (!head) {
|
|
117
|
+
return {
|
|
118
|
+
mode: "push",
|
|
119
|
+
label: "initial push",
|
|
120
|
+
diffArgs: ["diff", "--find-renames", EMPTY_TREE],
|
|
121
|
+
baseRef: EMPTY_TREE,
|
|
122
|
+
headKind: "working",
|
|
123
|
+
headRef: undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { cwd: root, allowFailure: true })?.trim();
|
|
127
|
+
const target = upstream || defaultBaseRef(root);
|
|
128
|
+
const base = git(["merge-base", head, target], { cwd: root, allowFailure: true })?.trim();
|
|
129
|
+
if (!base) {
|
|
130
|
+
return {
|
|
131
|
+
mode: "push",
|
|
132
|
+
label: "initial push",
|
|
133
|
+
diffArgs: ["diff", "--find-renames", `${EMPTY_TREE}..${head}`],
|
|
134
|
+
baseRef: EMPTY_TREE,
|
|
135
|
+
headKind: "ref",
|
|
136
|
+
headRef: head,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { mode: "push", label: `${target}..HEAD`, diffArgs: ["diff", "--find-renames", `${base}..${head}`], baseRef: base, headKind: "ref", headRef: head };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function remoteDefaultRef(root, remote = "origin") {
|
|
143
|
+
const symbolic = git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: root, allowFailure: true })?.trim();
|
|
144
|
+
if (symbolic) return symbolic.replace(/^refs\/remotes\//, "");
|
|
145
|
+
for (const candidate of [`${remote}/main`, `${remote}/master`]) {
|
|
146
|
+
if (git(["rev-parse", "--verify", candidate], { cwd: root, allowFailure: true })) return candidate;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function defaultBaseRef(root, remote = "origin") {
|
|
152
|
+
const symbolic = git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: root, allowFailure: true })?.trim();
|
|
153
|
+
if (symbolic) return symbolic.replace(/^refs\/remotes\//, "");
|
|
154
|
+
for (const candidate of [`${remote}/main`, `${remote}/master`, "main", "master"]) {
|
|
155
|
+
if (git(["rev-parse", "--verify", candidate], { cwd: root, allowFailure: true })) return candidate;
|
|
156
|
+
}
|
|
157
|
+
const parent = git(["rev-parse", "--verify", "HEAD^"], { cwd: root, allowFailure: true })?.trim();
|
|
158
|
+
if (parent) return "HEAD^";
|
|
159
|
+
throw new Error("Could not infer a review base. Pass --base <ref> explicitly.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function collectChangedFiles(root, scope, { includeUntracked = true, maxFiles = 300, ignore = [] } = {}) {
|
|
163
|
+
const diffArgs = scope.diffArgs.slice(1);
|
|
164
|
+
const raw = git(["diff", "--name-status", "-z", ...diffArgs], { cwd: root, encoding: "buffer" });
|
|
165
|
+
const tokens = raw.toString("utf8").split("\0").filter(Boolean);
|
|
166
|
+
const entries = [];
|
|
167
|
+
for (let i = 0; i < tokens.length; ) {
|
|
168
|
+
const statusToken = tokens[i++];
|
|
169
|
+
const code = statusToken[0];
|
|
170
|
+
if (code === "R" || code === "C") {
|
|
171
|
+
const previousPath = normalizePath(tokens[i++] || "");
|
|
172
|
+
const path = normalizePath(tokens[i++] || "");
|
|
173
|
+
entries.push({ path, previousPath, status: code === "R" ? "renamed" : "added" });
|
|
174
|
+
} else {
|
|
175
|
+
const path = normalizePath(tokens[i++] || "");
|
|
176
|
+
entries.push({ path, status: code === "A" ? "added" : code === "D" ? "deleted" : "modified" });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (scope.mode === "working" && includeUntracked) {
|
|
181
|
+
const untrackedRaw = git(["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer" });
|
|
182
|
+
for (const path of untrackedRaw.toString("utf8").split("\0").filter(Boolean)) {
|
|
183
|
+
const normalized = normalizePath(path);
|
|
184
|
+
if (!entries.some((entry) => entry.path === normalized)) entries.push({ path: normalized, status: "added", untracked: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const reviewable = entries.filter((entry) => entry.path && !matchesAnyGlob(entry.path, ignore) && safeJoin(root, entry.path));
|
|
189
|
+
const totalFiles = reviewable.length;
|
|
190
|
+
const ignoredFiles = entries.length - reviewable.length;
|
|
191
|
+
const limited = reviewable.slice(0, maxFiles);
|
|
192
|
+
const files = limited.map((entry) => hydrateChangedFile(root, scope, entry));
|
|
193
|
+
return { files, totalFiles, ignoredFiles, truncated: totalFiles > maxFiles };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hydrateChangedFile(root, scope, entry) {
|
|
197
|
+
let patch;
|
|
198
|
+
let headContent;
|
|
199
|
+
let baseContent;
|
|
200
|
+
if (entry.untracked) {
|
|
201
|
+
headContent = safeReadWorking(root, entry.path);
|
|
202
|
+
patch = createAddedPatch(entry.path, headContent || "");
|
|
203
|
+
} else {
|
|
204
|
+
const args = scope.diffArgs.slice(1);
|
|
205
|
+
patch = git(["diff", "--no-ext-diff", "--unified=3", ...args, "--", entry.path], { cwd: root, allowFailure: true }) || "";
|
|
206
|
+
headContent = entry.status === "deleted" ? undefined : readHeadContent(root, scope, entry.path);
|
|
207
|
+
}
|
|
208
|
+
if (scope.baseRef && entry.status !== "added") baseContent = gitShow(root, scope.baseRef, entry.previousPath || entry.path);
|
|
209
|
+
const { additions, deletions } = patchStats(patch);
|
|
210
|
+
return { ...entry, additions, deletions, patch, headContent, baseContent };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function readHeadContent(root, scope, path) {
|
|
214
|
+
if (scope.headKind === "working") return safeReadWorking(root, path);
|
|
215
|
+
if (scope.headKind === "index") return gitShow(root, "", path, true);
|
|
216
|
+
return gitShow(root, scope.headRef || "HEAD", path);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function safeReadWorking(root, path) {
|
|
220
|
+
const full = safeJoin(root, path);
|
|
221
|
+
if (!full || !existsSync(full)) return undefined;
|
|
222
|
+
try {
|
|
223
|
+
const buffer = readFileSync(full);
|
|
224
|
+
if (buffer.includes(0)) return undefined;
|
|
225
|
+
return buffer.length > MAX_FILE_BYTES ? buffer.subarray(0, MAX_FILE_BYTES).toString("utf8") : buffer.toString("utf8");
|
|
226
|
+
} catch {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function gitShow(root, ref, path, index = false) {
|
|
232
|
+
if (!safeJoin(root, path)) return undefined;
|
|
233
|
+
const spec = index ? `:${path}` : `${ref}:${path}`;
|
|
234
|
+
const result = git(["show", spec], { cwd: root, allowFailure: true, encoding: "buffer" });
|
|
235
|
+
if (!result || result.includes(0)) return undefined;
|
|
236
|
+
return result.length > MAX_FILE_BYTES ? result.subarray(0, MAX_FILE_BYTES).toString("utf8") : result.toString("utf8");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function createAddedPatch(path, content) {
|
|
240
|
+
const lines = content.split(/\r?\n/);
|
|
241
|
+
return `diff --git a/${path} b/${path}\nnew file mode 100644\n--- /dev/null\n+++ b/${path}\n@@ -0,0 +1,${lines.length} @@\n${lines.map((line) => `+${line}`).join("\n")}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function patchStats(patch = "") {
|
|
245
|
+
let additions = 0;
|
|
246
|
+
let deletions = 0;
|
|
247
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
248
|
+
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
249
|
+
else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
|
|
250
|
+
}
|
|
251
|
+
return { additions, deletions };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function repositoryInfo(root) {
|
|
255
|
+
const headSha = git(["rev-parse", "HEAD"], { cwd: root, allowFailure: true })?.trim() || "working";
|
|
256
|
+
const branch = git(["branch", "--show-current"], { cwd: root, allowFailure: true })?.trim() || "detached";
|
|
257
|
+
const remoteUrl = git(["config", "--get", "remote.origin.url"], { cwd: root, allowFailure: true })?.trim();
|
|
258
|
+
return { root, headSha, branch, remoteUrl };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function trackedFiles(root, { limit = 4000 } = {}) {
|
|
262
|
+
const raw = git(["ls-files", "-z"], { cwd: root, encoding: "buffer" });
|
|
263
|
+
const files = raw.toString("utf8").split("\0").filter(Boolean).map(normalizePath);
|
|
264
|
+
return files.slice(0, limit);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function emptyTree() {
|
|
268
|
+
return EMPTY_TREE;
|
|
269
|
+
}
|
package/src/hook.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { hooksDirectory, repositoryRoot } from "./git.js";
|
|
4
|
+
import { installSpec } from "./package-meta.js";
|
|
5
|
+
|
|
6
|
+
const START = "# >>> mergeguard >>>";
|
|
7
|
+
const END = "# <<< mergeguard <<<";
|
|
8
|
+
|
|
9
|
+
export function hookPath(root) {
|
|
10
|
+
return join(hooksDirectory(root), "pre-push");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function installHook(cwd = process.cwd(), { force = false } = {}) {
|
|
14
|
+
const root = repositoryRoot(cwd);
|
|
15
|
+
const path = hookPath(root);
|
|
16
|
+
let existing = existsSync(path) ? readFileSync(path, "utf8") : "#!/bin/sh\n\n";
|
|
17
|
+
if (existing.includes(START)) return { path, changed: false, message: "MergeGuard pre-push hook is already installed." };
|
|
18
|
+
if (!existing.startsWith("#!")) {
|
|
19
|
+
if (!force) throw new Error(`Existing pre-push hook has no shebang: ${path}. Use --force to prepend one safely.`);
|
|
20
|
+
existing = `#!/bin/sh\n${existing}`;
|
|
21
|
+
}
|
|
22
|
+
const pkg = installSpec();
|
|
23
|
+
const block = `
|
|
24
|
+
${START}
|
|
25
|
+
# MergeGuard reviews the outgoing push range. Bypass with: git push --no-verify
|
|
26
|
+
input=$(cat)
|
|
27
|
+
ROOT="$(git rev-parse --show-toplevel </dev/null)" || exit 2
|
|
28
|
+
cd "$ROOT" || exit 2
|
|
29
|
+
export MERGEGUARD_REMOTE="$1"
|
|
30
|
+
run_mergeguard() {
|
|
31
|
+
if [ -n "$MERGEGUARD_BIN" ]; then
|
|
32
|
+
"$MERGEGUARD_BIN" review --push
|
|
33
|
+
elif [ -x "$ROOT/node_modules/.bin/mergeguard" ]; then
|
|
34
|
+
"$ROOT/node_modules/.bin/mergeguard" review --push
|
|
35
|
+
elif command -v mergeguard >/dev/null 2>&1; then
|
|
36
|
+
mergeguard review --push
|
|
37
|
+
else
|
|
38
|
+
echo "MergeGuard is not installed in this repository." >&2
|
|
39
|
+
echo "Install with: npm install -D ${pkg}" >&2
|
|
40
|
+
echo "Do not install the unscoped npm name mergeguard (different package)." >&2
|
|
41
|
+
echo "Or set MERGEGUARD_BIN to the mergeguard executable." >&2
|
|
42
|
+
exit 2
|
|
43
|
+
fi
|
|
44
|
+
}
|
|
45
|
+
printf '%s\\n' "$input" | run_mergeguard
|
|
46
|
+
status=$?
|
|
47
|
+
if [ "$status" -eq 1 ]; then
|
|
48
|
+
echo "MergeGuard blocked this push. Fix the findings, or bypass with: git push --no-verify" >&2
|
|
49
|
+
exit 1
|
|
50
|
+
fi
|
|
51
|
+
if [ "$status" -ne 0 ]; then exit "$status"; fi
|
|
52
|
+
${END}
|
|
53
|
+
`;
|
|
54
|
+
writeFileSync(path, `${existing.trimEnd()}\n${block}`);
|
|
55
|
+
try { chmodSync(path, 0o755); } catch { /* Windows may lack POSIX chmod */ }
|
|
56
|
+
return { path, changed: true, message: "MergeGuard pre-push hook installed." };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function uninstallHook(cwd = process.cwd()) {
|
|
60
|
+
const root = repositoryRoot(cwd);
|
|
61
|
+
const path = hookPath(root);
|
|
62
|
+
if (!existsSync(path)) return { path, changed: false, message: "No pre-push hook exists." };
|
|
63
|
+
const existing = readFileSync(path, "utf8");
|
|
64
|
+
if (!existing.includes(START)) return { path, changed: false, message: "MergeGuard is not installed in the pre-push hook." };
|
|
65
|
+
const cleaned = existing.replace(new RegExp(`\\n?${escapeRegExp(START)}[\\s\\S]*?${escapeRegExp(END)}\\n?`, "m"), "\n");
|
|
66
|
+
writeFileSync(path, `${cleaned.trimEnd()}\n`);
|
|
67
|
+
return { path, changed: true, message: "MergeGuard block removed; other pre-push hook content was preserved." };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function hookStatus(cwd = process.cwd()) {
|
|
71
|
+
const root = repositoryRoot(cwd);
|
|
72
|
+
const path = hookPath(root);
|
|
73
|
+
const installed = existsSync(path) && readFileSync(path, "utf8").includes(START);
|
|
74
|
+
return { path, installed };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function escapeRegExp(value) {
|
|
78
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
79
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public Node API. Prefer this over parsing CLI output.
|
|
3
|
+
* @module mergeguard
|
|
4
|
+
*/
|
|
5
|
+
export { review, reviewChangeSet } from "./review.js";
|
|
6
|
+
export { loadConfig, normalizeConfig, defaultConfig } from "./config.js";
|
|
7
|
+
export { renderReport, toSarif, toGitLabCodeQuality } from "./reporters/index.js";
|
|
8
|
+
export { detectLaya } from "./verifiers/index.js";
|
|
9
|
+
export { VERSION } from "./version.js";
|
package/src/languages.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { extname, basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
const BY_EXT = new Map([
|
|
4
|
+
[".js", "javascript"], [".jsx", "javascript"], [".mjs", "javascript"], [".cjs", "javascript"],
|
|
5
|
+
[".ts", "typescript"], [".tsx", "typescript"], [".mts", "typescript"], [".cts", "typescript"],
|
|
6
|
+
[".py", "python"], [".pyi", "python"],
|
|
7
|
+
[".java", "java"], [".kt", "kotlin"], [".kts", "kotlin"],
|
|
8
|
+
[".go", "go"], [".php", "php"], [".rb", "ruby"], [".cs", "csharp"],
|
|
9
|
+
[".rs", "rust"], [".cpp", "cpp"], [".cc", "cpp"], [".cxx", "cpp"], [".c", "c"], [".h", "c"], [".hpp", "cpp"],
|
|
10
|
+
[".swift", "swift"], [".scala", "scala"], [".ex", "elixir"], [".exs", "elixir"],
|
|
11
|
+
[".sql", "sql"], [".sh", "shell"], [".bash", "shell"], [".zsh", "shell"], [".ps1", "powershell"],
|
|
12
|
+
[".tf", "terraform"], [".tfvars", "terraform"],
|
|
13
|
+
[".vue", "vue"], [".svelte", "svelte"],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function languageForPath(path) {
|
|
17
|
+
const name = basename(path).toLowerCase();
|
|
18
|
+
if (name === "dockerfile" || name.startsWith("dockerfile.")) return "dockerfile";
|
|
19
|
+
if (name === "gemfile") return "ruby";
|
|
20
|
+
if (name === "makefile") return "make";
|
|
21
|
+
if (/\.ya?ml$/.test(name)) return "yaml";
|
|
22
|
+
if (name.endsWith(".json")) return "json";
|
|
23
|
+
if (name.endsWith(".xml")) return "xml";
|
|
24
|
+
return BY_EXT.get(extname(name)) || "text";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isCodePath(path) {
|
|
28
|
+
return languageForPath(path) !== "text" && !["json", "yaml", "xml", "make", "dockerfile"].includes(languageForPath(path));
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
5
|
+
|
|
6
|
+
/** npm registry name from package.json (scoped). Product and CLI remain "MergeGuard" / "mergeguard". */
|
|
7
|
+
export const NPM_PACKAGE_NAME = pkg.name;
|
|
8
|
+
|
|
9
|
+
/** Spec for `npm install --save-dev …`. */
|
|
10
|
+
export function installSpec() {
|
|
11
|
+
return NPM_PACKAGE_NAME;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One-off npx without a prior local install (avoids the unrelated unscoped `mergeguard` package). */
|
|
15
|
+
export function oneOffNpx(command = "review") {
|
|
16
|
+
return `npx --package=${NPM_PACKAGE_NAME} mergeguard ${command}`;
|
|
17
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { normalizePath } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
/** Join a repository-relative path only if it stays inside root. */
|
|
6
|
+
export function safeJoin(root, relativePath) {
|
|
7
|
+
const normalized = normalizePath(String(relativePath || ""));
|
|
8
|
+
if (!normalized || normalized.includes("\0") || isAbsolute(normalized) || /^[A-Za-z]:/.test(normalized)) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if (normalized.split("/").some((part) => part === "..")) return undefined;
|
|
12
|
+
const rootResolved = resolve(root);
|
|
13
|
+
const full = resolve(rootResolved, normalized);
|
|
14
|
+
const rel = relative(rootResolved, full);
|
|
15
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return undefined;
|
|
16
|
+
try {
|
|
17
|
+
const stat = lstatSync(full);
|
|
18
|
+
if (stat.isSymbolicLink()) {
|
|
19
|
+
const real = realpathSync(full);
|
|
20
|
+
const rootWithSep = rootResolved.endsWith(sep) ? rootResolved : `${rootResolved}${sep}`;
|
|
21
|
+
if (real !== rootResolved && !real.startsWith(rootWithSep)) return undefined;
|
|
22
|
+
} else if (!stat.isFile() && !stat.isDirectory()) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
return full;
|
|
27
|
+
}
|
|
28
|
+
return full;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function stripControlChars(text) {
|
|
32
|
+
return String(text ?? "").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function toGitLabCodeQuality(result) {
|
|
2
|
+
return result.findings.map((finding) => ({
|
|
3
|
+
description: `${finding.title}: ${finding.description}`,
|
|
4
|
+
check_name: finding.detector || "mergeguard",
|
|
5
|
+
fingerprint: finding.id,
|
|
6
|
+
severity: mapSeverity(finding.severity),
|
|
7
|
+
location: {
|
|
8
|
+
path: finding.file.replaceAll("\\", "/"),
|
|
9
|
+
lines: { begin: finding.startLine || 1, end: finding.endLine || finding.startLine || 1 },
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function mapSeverity(severity) {
|
|
15
|
+
return { critical: "blocker", high: "critical", medium: "major", low: "minor", info: "info" }[severity] || "major";
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { terminalReport } from "./terminal.js";
|
|
2
|
+
import { toSarif } from "./sarif.js";
|
|
3
|
+
import { toGitLabCodeQuality } from "./gitlab.js";
|
|
4
|
+
|
|
5
|
+
export function renderReport(result, format = "terminal", options = {}) {
|
|
6
|
+
if (format === "terminal") return terminalReport(result, options);
|
|
7
|
+
if (format === "json") return JSON.stringify(result, null, 2);
|
|
8
|
+
if (format === "sarif") return JSON.stringify(toSarif(result), null, 2);
|
|
9
|
+
if (format === "gitlab") return JSON.stringify(toGitLabCodeQuality(result), null, 2);
|
|
10
|
+
throw new Error(`Unknown output format '${format}'. Use terminal, json, sarif, or gitlab.`);
|
|
11
|
+
}
|
|
12
|
+
export { toSarif } from "./sarif.js";
|
|
13
|
+
export { toGitLabCodeQuality } from "./gitlab.js";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const INFORMATION_URI = "https://github.com/FitsumMehari/MergeGuard";
|
|
2
|
+
|
|
3
|
+
export function toSarif(result) {
|
|
4
|
+
const rules = new Map();
|
|
5
|
+
for (const finding of result.findings) {
|
|
6
|
+
const id = finding.detector || finding.id;
|
|
7
|
+
if (!rules.has(id)) {
|
|
8
|
+
rules.set(id, {
|
|
9
|
+
id,
|
|
10
|
+
name: String(id).replace(/[^A-Za-z0-9_-]/g, "-"),
|
|
11
|
+
shortDescription: { text: finding.title },
|
|
12
|
+
fullDescription: { text: finding.description },
|
|
13
|
+
help: { text: finding.remediation || finding.description },
|
|
14
|
+
defaultConfiguration: { level: sarifLevel(finding.severity) },
|
|
15
|
+
properties: { category: finding.category, severity: finding.severity, tags: [finding.category, finding.severity] },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
version: "2.1.0",
|
|
21
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
22
|
+
runs: [{
|
|
23
|
+
tool: {
|
|
24
|
+
driver: {
|
|
25
|
+
name: "MergeGuard",
|
|
26
|
+
version: result.version,
|
|
27
|
+
informationUri: INFORMATION_URI,
|
|
28
|
+
rules: [...rules.values()],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
results: result.findings.map((finding) => ({
|
|
32
|
+
ruleId: finding.detector || finding.id,
|
|
33
|
+
level: sarifLevel(finding.severity),
|
|
34
|
+
message: { text: `${finding.title}: ${finding.description}${finding.remediation ? ` Fix: ${finding.remediation}` : ""}` },
|
|
35
|
+
locations: [{
|
|
36
|
+
physicalLocation: {
|
|
37
|
+
artifactLocation: { uri: finding.file.replaceAll("\\", "/") },
|
|
38
|
+
region: { startLine: finding.startLine || 1, ...(finding.endLine ? { endLine: finding.endLine } : {}) },
|
|
39
|
+
},
|
|
40
|
+
}],
|
|
41
|
+
partialFingerprints: { mergeguardFindingId: finding.id },
|
|
42
|
+
properties: {
|
|
43
|
+
category: finding.category,
|
|
44
|
+
severity: finding.severity,
|
|
45
|
+
confidence: finding.confidence,
|
|
46
|
+
verifier: finding.verification?.provider,
|
|
47
|
+
},
|
|
48
|
+
})),
|
|
49
|
+
}],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sarifLevel(severity) {
|
|
54
|
+
return severity === "critical" || severity === "high" ? "error" : severity === "medium" ? "warning" : "note";
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isAtLeastSeverity } from "../utils.js";
|
|
2
|
+
|
|
3
|
+
export function terminalReport(result, { color = process.stdout.isTTY && !process.env.NO_COLOR, verbose = false } = {}) {
|
|
4
|
+
const c = color ? colors : plain;
|
|
5
|
+
const lines = [];
|
|
6
|
+
lines.push(`${c.bold("MergeGuard")} ${c.dim(result.scope.label)}`);
|
|
7
|
+
lines.push(`${result.summary.filesReviewed} changed file${result.summary.filesReviewed === 1 ? "" : "s"} reviewed${result.summary.ignoredFiles ? ` · ${result.summary.ignoredFiles} ignored` : ""} · verifier: ${result.verifier}`);
|
|
8
|
+
if (result.warnings.length) for (const warning of result.warnings) lines.push(c.yellow(`warning: ${warning}`));
|
|
9
|
+
lines.push("");
|
|
10
|
+
|
|
11
|
+
if (!result.findings.length) {
|
|
12
|
+
lines.push(c.green("✓ No reportable issues found."));
|
|
13
|
+
} else {
|
|
14
|
+
for (const finding of result.findings) {
|
|
15
|
+
const sev = severityLabel(finding.severity, c);
|
|
16
|
+
const location = `${finding.file}${finding.startLine ? `:${finding.startLine}` : ""}`;
|
|
17
|
+
lines.push(`${sev} ${c.bold(location)}`);
|
|
18
|
+
lines.push(c.bold(finding.title));
|
|
19
|
+
lines.push(finding.description);
|
|
20
|
+
if (finding.evidence?.[0]) lines.push(`${c.dim("Evidence:")} ${finding.evidence[0]}`);
|
|
21
|
+
if (finding.remediation) lines.push(`${c.dim("Fix:")} ${finding.remediation}`);
|
|
22
|
+
lines.push(`${c.dim("Confidence:")} ${Math.round(finding.confidence * 100)}% · ${c.dim("verified by:")} ${finding.verification.provider}`);
|
|
23
|
+
if (verbose) {
|
|
24
|
+
const v = finding.verification;
|
|
25
|
+
lines.push(c.dim(`plausible=${v.plausible} reachable=${v.reachable} impact=${v.impact} protection=${v.existingProtection} report=${v.worthReporting}`));
|
|
26
|
+
}
|
|
27
|
+
lines.push("");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const parts = ["critical", "high", "medium", "low", "info"].filter((s) => result.summary[s]).map((s) => `${result.summary[s]} ${s}`);
|
|
32
|
+
if (parts.length) lines.push(parts.join(" · "));
|
|
33
|
+
if (result.blocking) lines.push(c.red(c.bold(`BLOCKED — finding at or above '${result.failOn}' threshold.`)));
|
|
34
|
+
else lines.push(c.green(c.bold("PASS")));
|
|
35
|
+
return lines.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function severityLabel(severity, c) {
|
|
39
|
+
const text = severity.toUpperCase().padEnd(8);
|
|
40
|
+
if (severity === "critical" || severity === "high") return c.red(text);
|
|
41
|
+
if (severity === "medium") return c.yellow(text);
|
|
42
|
+
if (severity === "low") return c.cyan(text);
|
|
43
|
+
return c.dim(text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const wrap = (code) => (text) => `\x1b[${code}m${text}\x1b[0m`;
|
|
47
|
+
const colors = { bold: wrap(1), dim: wrap(2), red: wrap(31), green: wrap(32), yellow: wrap(33), cyan: wrap(36) };
|
|
48
|
+
const plain = { bold: String, dim: String, red: String, green: String, yellow: String, cyan: String };
|