@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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: MergeGuard
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
security-events: write
|
|
9
|
+
|
|
10
|
+
# Default: Node + offline verifier (reproducible). Optional Laya: see docs/LAYA.md.
|
|
11
|
+
jobs:
|
|
12
|
+
mergeguard:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0
|
|
18
|
+
- uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: 22
|
|
21
|
+
- name: Install MergeGuard
|
|
22
|
+
run: npm install --save-dev @fitsummehari/mergeguard
|
|
23
|
+
- name: Review pull request diff
|
|
24
|
+
run: npx mergeguard review --verifier offline --base "origin/${{ github.base_ref }}" --head "${{ github.event.pull_request.head.sha }}"
|
|
25
|
+
- name: Write SARIF
|
|
26
|
+
if: always()
|
|
27
|
+
run: npx mergeguard review --verifier offline --base "origin/${{ github.base_ref }}" --head "${{ github.event.pull_request.head.sha }}" --fail-on none --format sarif --output mergeguard.sarif
|
|
28
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
29
|
+
if: always()
|
|
30
|
+
continue-on-error: true
|
|
31
|
+
with:
|
|
32
|
+
sarif_file: mergeguard.sarif
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Default: Node + offline verifier. Optional Laya: see docs/LAYA.md.
|
|
2
|
+
mergeguard:
|
|
3
|
+
stage: test
|
|
4
|
+
image: node:22
|
|
5
|
+
rules:
|
|
6
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
7
|
+
before_script:
|
|
8
|
+
- npm install --save-dev @fitsummehari/mergeguard
|
|
9
|
+
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
10
|
+
script:
|
|
11
|
+
- npx mergeguard review --verifier offline --base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --head "$CI_COMMIT_SHA" --format gitlab --output gl-code-quality-report.json
|
|
12
|
+
artifacts:
|
|
13
|
+
when: always
|
|
14
|
+
reports:
|
|
15
|
+
codequality: gl-code-quality-report.json
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fitsummehari/mergeguard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-first semantic code-change gate that reviews Git diffs before push or merge",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mergeguard": "bin/mergeguard.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"scripts/laya_bridge.py",
|
|
18
|
+
"docs/ARCHITECTURE.md",
|
|
19
|
+
"docs/CONFIGURATION.md",
|
|
20
|
+
"docs/DETECTORS.md",
|
|
21
|
+
"docs/INTEGRATIONS.md",
|
|
22
|
+
"docs/LAYA.md",
|
|
23
|
+
"docs/LIMITATIONS.md",
|
|
24
|
+
"docs/MIGRATION_FROM_V1.md",
|
|
25
|
+
"examples",
|
|
26
|
+
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"SECURITY.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test --test-concurrency=1 tests/*.test.js",
|
|
33
|
+
"check": "node scripts/check.js",
|
|
34
|
+
"pack:list": "npm pack --dry-run",
|
|
35
|
+
"prepublishOnly": "npm run check"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/FitsumMehari/MergeGuard.git"
|
|
43
|
+
},
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/FitsumMehari/MergeGuard/issues"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/FitsumMehari/MergeGuard#readme",
|
|
48
|
+
"keywords": [
|
|
49
|
+
"git",
|
|
50
|
+
"code-review",
|
|
51
|
+
"pre-push",
|
|
52
|
+
"static-analysis",
|
|
53
|
+
"security",
|
|
54
|
+
"ci",
|
|
55
|
+
"sarif",
|
|
56
|
+
"semantic-analysis"
|
|
57
|
+
],
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tiny stdio bridge: Node MergeGuard -> local Python Laya. No server."""
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
payload = json.load(sys.stdin)
|
|
9
|
+
from laya import Router
|
|
10
|
+
|
|
11
|
+
router = Router(preload=False)
|
|
12
|
+
questions = payload["questions"]
|
|
13
|
+
model = payload.get("model")
|
|
14
|
+
max_len = int(payload.get("max_len") or 4096)
|
|
15
|
+
output = []
|
|
16
|
+
for item in payload.get("items", []):
|
|
17
|
+
kwargs = {"max_len": max_len}
|
|
18
|
+
if model:
|
|
19
|
+
kwargs["model"] = model
|
|
20
|
+
result = router.predict(item["state"], questions, **kwargs)
|
|
21
|
+
output.append({"id": item.get("id"), "answers": result.get("answers", {}), "routing": result.get("routing")})
|
|
22
|
+
json.dump({"items": output}, sys.stdout, ensure_ascii=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|
package/src/ci.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { installSpec } from "./package-meta.js";
|
|
2
|
+
|
|
3
|
+
export function githubWorkflow() {
|
|
4
|
+
const pkg = installSpec();
|
|
5
|
+
return `name: MergeGuard
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
pull_request:
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
security-events: write
|
|
13
|
+
|
|
14
|
+
# Default: Node + offline verifier (reproducible). Optional Laya: see docs/LAYA.md.
|
|
15
|
+
jobs:
|
|
16
|
+
mergeguard:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
with:
|
|
21
|
+
fetch-depth: 0
|
|
22
|
+
- uses: actions/setup-node@v4
|
|
23
|
+
with:
|
|
24
|
+
node-version: 22
|
|
25
|
+
- name: Install MergeGuard
|
|
26
|
+
run: npm install --save-dev ${pkg}
|
|
27
|
+
- name: Review pull request diff
|
|
28
|
+
run: npx mergeguard review --verifier offline --base "origin/\${{ github.base_ref }}" --head "\${{ github.event.pull_request.head.sha }}"
|
|
29
|
+
- name: Write SARIF
|
|
30
|
+
if: always()
|
|
31
|
+
run: npx mergeguard review --verifier offline --base "origin/\${{ github.base_ref }}" --head "\${{ github.event.pull_request.head.sha }}" --fail-on none --format sarif --output mergeguard.sarif
|
|
32
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
33
|
+
if: always()
|
|
34
|
+
continue-on-error: true
|
|
35
|
+
with:
|
|
36
|
+
sarif_file: mergeguard.sarif
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function gitlabWorkflow() {
|
|
41
|
+
const pkg = installSpec();
|
|
42
|
+
return `mergeguard:
|
|
43
|
+
stage: test
|
|
44
|
+
image: node:22
|
|
45
|
+
rules:
|
|
46
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
47
|
+
# Default: Node + offline verifier. Optional Laya: see docs/LAYA.md.
|
|
48
|
+
before_script:
|
|
49
|
+
- npm install --save-dev ${pkg}
|
|
50
|
+
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
51
|
+
script:
|
|
52
|
+
- npx mergeguard review --verifier offline --base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --head "$CI_COMMIT_SHA" --format gitlab --output gl-code-quality-report.json
|
|
53
|
+
artifacts:
|
|
54
|
+
when: always
|
|
55
|
+
reports:
|
|
56
|
+
codequality: gl-code-quality-report.json
|
|
57
|
+
`;
|
|
58
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { review } from "./review.js";
|
|
4
|
+
import { renderReport } from "./reporters/index.js";
|
|
5
|
+
import { installHook, uninstallHook, hookStatus } from "./hook.js";
|
|
6
|
+
import { githubWorkflow, gitlabWorkflow } from "./ci.js";
|
|
7
|
+
import { writeDefaultConfig } from "./config.js";
|
|
8
|
+
import { repositoryRoot } from "./git.js";
|
|
9
|
+
import { collectDoctorReport, formatDoctorReport } from "./doctor.js";
|
|
10
|
+
import { VERSION } from "./version.js";
|
|
11
|
+
|
|
12
|
+
const MACHINE_FORMATS = new Set(["json", "sarif", "gitlab"]);
|
|
13
|
+
|
|
14
|
+
export async function main(argv) {
|
|
15
|
+
const args = [...argv];
|
|
16
|
+
if (args.includes("--version") || args[0] === "version") {
|
|
17
|
+
console.log(`mergeguard ${VERSION}`);
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
if (args.includes("--help") || args[0] === "help" || args.length === 0) {
|
|
21
|
+
console.log(help());
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
if (args[0]?.startsWith("-")) args.unshift("review");
|
|
25
|
+
const [command, ...rest] = args;
|
|
26
|
+
if (command === "review") return reviewCommand(rest);
|
|
27
|
+
if (command === "hook") return hookCommand(rest);
|
|
28
|
+
if (command === "init") return initCommand(rest);
|
|
29
|
+
if (command === "doctor") return doctorCommand(rest);
|
|
30
|
+
if (command === "ci") return ciCommand(rest);
|
|
31
|
+
throw new Error(`Unknown command '${command}'. Run mergeguard --help.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function reviewCommand(args) {
|
|
35
|
+
const opts = parseArgs(args, {
|
|
36
|
+
booleans: new Set(["staged", "push", "no-ai", "verbose", "no-color", "no-untracked"]),
|
|
37
|
+
values: new Set(["base", "head", "format", "output", "fail-on", "verifier", "config", "confidence", "max-files", "max-candidates"]),
|
|
38
|
+
});
|
|
39
|
+
const format = opts.format || "terminal";
|
|
40
|
+
if (!["terminal", "json", "sarif", "gitlab"].includes(format)) {
|
|
41
|
+
throw new Error(`Unknown output format '${format}'. Use terminal, json, sarif, or gitlab.`);
|
|
42
|
+
}
|
|
43
|
+
let pushInput;
|
|
44
|
+
if (opts.push && !process.stdin.isTTY) pushInput = await readStdin();
|
|
45
|
+
const result = await review({
|
|
46
|
+
staged: opts.staged,
|
|
47
|
+
push: opts.push,
|
|
48
|
+
pushInput,
|
|
49
|
+
base: opts.base,
|
|
50
|
+
head: opts.head,
|
|
51
|
+
verifier: opts.verifier === "deterministic" ? "offline" : opts.verifier,
|
|
52
|
+
noAi: opts["no-ai"],
|
|
53
|
+
failOn: opts["fail-on"],
|
|
54
|
+
configPath: opts.config,
|
|
55
|
+
confidence: numberOption(opts.confidence),
|
|
56
|
+
maxFiles: intOption(opts["max-files"]),
|
|
57
|
+
maxCandidates: intOption(opts["max-candidates"]),
|
|
58
|
+
includeUntracked: !opts["no-untracked"],
|
|
59
|
+
});
|
|
60
|
+
const color = !opts["no-color"] && process.stdout.isTTY && format === "terminal";
|
|
61
|
+
const output = renderReport(result, format, { verbose: opts.verbose, color });
|
|
62
|
+
if (MACHINE_FORMATS.has(format)) process.stderr.write(`${briefStatus(result)}\n`);
|
|
63
|
+
if (opts.output) {
|
|
64
|
+
const dest = resolve(opts.output);
|
|
65
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
66
|
+
writeFileSync(dest, output.endsWith("\n") ? output : `${output}\n`);
|
|
67
|
+
if (MACHINE_FORMATS.has(format)) process.stderr.write(`MergeGuard wrote ${format} report to ${dest}\n`);
|
|
68
|
+
else process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
|
|
69
|
+
} else {
|
|
70
|
+
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
|
|
71
|
+
}
|
|
72
|
+
return result.blocking ? 1 : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function briefStatus(result) {
|
|
76
|
+
const counts = ["critical", "high", "medium", "low", "info"].filter((s) => result.summary[s]).map((s) => `${result.summary[s]} ${s}`);
|
|
77
|
+
const body = counts.length ? counts.join(", ") : "no reportable findings";
|
|
78
|
+
return `MergeGuard: ${result.findings.length} finding${result.findings.length === 1 ? "" : "s"} (${body}) — ${result.passed ? "PASS" : "BLOCKED"}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hookCommand(args) {
|
|
82
|
+
const action = args[0] || "status";
|
|
83
|
+
const force = args.includes("--force");
|
|
84
|
+
if (action === "install") {
|
|
85
|
+
const result = installHook(process.cwd(), { force });
|
|
86
|
+
console.log(`${result.message}\n${result.path}`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (action === "uninstall") {
|
|
90
|
+
const result = uninstallHook();
|
|
91
|
+
console.log(`${result.message}\n${result.path}`);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
if (action === "status") {
|
|
95
|
+
const result = hookStatus();
|
|
96
|
+
console.log(result.installed ? `installed: ${result.path}` : `not installed: ${result.path}`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
throw new Error("Usage: mergeguard hook install|uninstall|status [--force]");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function initCommand(args) {
|
|
103
|
+
let root;
|
|
104
|
+
try { root = repositoryRoot(); } catch { root = process.cwd(); }
|
|
105
|
+
const path = writeDefaultConfig(root, { force: args.includes("--force") });
|
|
106
|
+
console.log(`Created ${path}`);
|
|
107
|
+
if (args.includes("--hook")) {
|
|
108
|
+
const result = installHook(root);
|
|
109
|
+
console.log(result.message);
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function doctorCommand(args = []) {
|
|
115
|
+
const report = collectDoctorReport();
|
|
116
|
+
if (args.includes("--json")) {
|
|
117
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(formatDoctorReport(report));
|
|
120
|
+
}
|
|
121
|
+
return report.ok ? 0 : 2;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ciCommand(args) {
|
|
125
|
+
const provider = args[0];
|
|
126
|
+
const write = args.includes("--write");
|
|
127
|
+
const force = args.includes("--force");
|
|
128
|
+
let text;
|
|
129
|
+
let dest;
|
|
130
|
+
if (provider === "github") {
|
|
131
|
+
text = githubWorkflow();
|
|
132
|
+
dest = ".github/workflows/mergeguard.yml";
|
|
133
|
+
} else if (provider === "gitlab") {
|
|
134
|
+
text = gitlabWorkflow();
|
|
135
|
+
dest = ".gitlab-ci.mergeguard.yml";
|
|
136
|
+
} else {
|
|
137
|
+
throw new Error("Usage: mergeguard ci github|gitlab [--write] [--force]");
|
|
138
|
+
}
|
|
139
|
+
if (!write) {
|
|
140
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
const root = repositoryRoot();
|
|
144
|
+
const path = resolve(root, dest);
|
|
145
|
+
if (existsSync(path) && !force) throw new Error(`${path} already exists (use --force to replace it)`);
|
|
146
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
147
|
+
writeFileSync(path, text.endsWith("\n") ? text : `${text}\n`);
|
|
148
|
+
console.log(`Wrote ${path}`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseArgs(args, schema) {
|
|
153
|
+
const out = {};
|
|
154
|
+
for (let i = 0; i < args.length; i++) {
|
|
155
|
+
const token = args[i];
|
|
156
|
+
if (!token.startsWith("--")) throw new Error(`Unexpected argument '${token}'`);
|
|
157
|
+
const eq = token.indexOf("=");
|
|
158
|
+
const key = token.slice(2, eq > 0 ? eq : undefined);
|
|
159
|
+
if (schema.booleans.has(key)) {
|
|
160
|
+
out[key] = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!schema.values.has(key)) throw new Error(`Unknown option '--${key}'`);
|
|
164
|
+
const value = eq > 0 ? token.slice(eq + 1) : args[++i];
|
|
165
|
+
if (value === undefined || value.startsWith("--")) throw new Error(`Option '--${key}' requires a value`);
|
|
166
|
+
out[key] = value;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function numberOption(value) {
|
|
172
|
+
if (value === undefined) return undefined;
|
|
173
|
+
const n = Number(value);
|
|
174
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) throw new Error("--confidence must be between 0 and 1");
|
|
175
|
+
return n;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function intOption(value) {
|
|
179
|
+
if (value === undefined) return undefined;
|
|
180
|
+
const n = Number(value);
|
|
181
|
+
if (!Number.isInteger(n) || n < 1) throw new Error("numeric limit options must be positive integers");
|
|
182
|
+
return n;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function readStdin() {
|
|
186
|
+
let text = "";
|
|
187
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
188
|
+
return text;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function help() {
|
|
192
|
+
return `MergeGuard ${VERSION}
|
|
193
|
+
Local-first semantic code-change gate for Git diffs.
|
|
194
|
+
|
|
195
|
+
USAGE
|
|
196
|
+
mergeguard review [options]
|
|
197
|
+
mergeguard hook install|uninstall|status
|
|
198
|
+
mergeguard init [--hook] [--force]
|
|
199
|
+
mergeguard doctor [--json]
|
|
200
|
+
mergeguard ci github|gitlab [--write] [--force]
|
|
201
|
+
|
|
202
|
+
REVIEW SCOPES
|
|
203
|
+
mergeguard review Working tree + staged + untracked vs HEAD
|
|
204
|
+
mergeguard review --staged Staged changes only
|
|
205
|
+
mergeguard review --base main Branch diff from merge-base(main, HEAD)
|
|
206
|
+
mergeguard review --base main --head SHA
|
|
207
|
+
mergeguard review --push Outgoing commits (normally called by pre-push hook)
|
|
208
|
+
|
|
209
|
+
OPTIONS
|
|
210
|
+
--format terminal|json|sarif|gitlab (machine formats stay parseable on stdout)
|
|
211
|
+
--output FILE
|
|
212
|
+
--fail-on critical|high|medium|low|info|none
|
|
213
|
+
--verifier auto|offline|deterministic|laya|jev
|
|
214
|
+
--no-ai Force deterministic/offline verification
|
|
215
|
+
--config FILE Explicit config path
|
|
216
|
+
--confidence 0..1
|
|
217
|
+
--max-files N
|
|
218
|
+
--max-candidates N
|
|
219
|
+
--no-untracked
|
|
220
|
+
--verbose
|
|
221
|
+
--no-color
|
|
222
|
+
--version
|
|
223
|
+
|
|
224
|
+
EXIT CODES
|
|
225
|
+
0 Review completed; no blocking finding
|
|
226
|
+
1 Review completed; blocking finding present
|
|
227
|
+
2 MergeGuard/config/runtime failure
|
|
228
|
+
`;
|
|
229
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { CATEGORIES, SEVERITIES, parseSimpleYaml } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
export const defaultConfig = Object.freeze({
|
|
6
|
+
version: 1,
|
|
7
|
+
failOn: "high",
|
|
8
|
+
verifier: "offline",
|
|
9
|
+
confidence: 0.62,
|
|
10
|
+
maxFiles: 300,
|
|
11
|
+
maxCandidates: 120,
|
|
12
|
+
contextFiles: 24,
|
|
13
|
+
contextChars: 18_000,
|
|
14
|
+
ignore: [
|
|
15
|
+
"node_modules/**", "vendor/**", "dist/**", "build/**", "coverage/**", ".next/**", ".git/**",
|
|
16
|
+
"**/*.min.js", "**/*.map", "**/generated/**",
|
|
17
|
+
],
|
|
18
|
+
categories: Object.fromEntries(CATEGORIES.map((category) => [category, true])),
|
|
19
|
+
laya: { python: undefined, model: undefined, maxLen: 4096 },
|
|
20
|
+
jev: { url: "https://api.typesafe.ai/v1/systemone", model: "jev-latest" },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export function loadConfig(root, explicitPath) {
|
|
24
|
+
const candidates = explicitPath
|
|
25
|
+
? [resolve(root, explicitPath)]
|
|
26
|
+
: [resolve(root, ".mergeguard.yml"), resolve(root, ".mergeguard.yaml"), resolve(root, ".mergeguard.json")];
|
|
27
|
+
const path = candidates.find(existsSync);
|
|
28
|
+
if (!path) return { config: structuredClone(defaultConfig), path: undefined, warnings: [] };
|
|
29
|
+
let parsed;
|
|
30
|
+
const text = readFileSync(path, "utf8");
|
|
31
|
+
try {
|
|
32
|
+
parsed = path.endsWith(".json") ? JSON.parse(text) : parseSimpleYaml(text);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(`Could not parse ${path}: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
return { config: normalizeConfig(parsed), path, warnings: unknownKeyWarnings(parsed, path) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeConfig(input = {}) {
|
|
40
|
+
const config = structuredClone(defaultConfig);
|
|
41
|
+
if (input.fail_on !== undefined || input.failOn !== undefined) {
|
|
42
|
+
const failOn = String(input.fail_on ?? input.failOn).toLowerCase();
|
|
43
|
+
if (![...SEVERITIES, "none"].includes(failOn)) throw new Error(`fail_on must be one of: ${[...SEVERITIES, "none"].join(", ")}`);
|
|
44
|
+
config.failOn = failOn;
|
|
45
|
+
}
|
|
46
|
+
if (input.verifier !== undefined) {
|
|
47
|
+
let verifier = typeof input.verifier === "object" ? input.verifier.engine : input.verifier;
|
|
48
|
+
if (verifier === "deterministic") verifier = "offline";
|
|
49
|
+
if (verifier && !["auto", "offline", "laya", "jev"].includes(verifier)) throw new Error("verifier must be auto, offline, deterministic, laya, or jev");
|
|
50
|
+
if (verifier) config.verifier = verifier;
|
|
51
|
+
}
|
|
52
|
+
if (input.confidence !== undefined) {
|
|
53
|
+
const value = Number(typeof input.confidence === "object" ? input.confidence.report : input.confidence);
|
|
54
|
+
if (Number.isFinite(value) && value >= 0 && value <= 1) config.confidence = value;
|
|
55
|
+
}
|
|
56
|
+
const maxFiles = Number(input.max_files ?? input.maxFiles);
|
|
57
|
+
if (Number.isInteger(maxFiles) && maxFiles > 0) config.maxFiles = Math.min(maxFiles, 5000);
|
|
58
|
+
const maxCandidates = Number(input.max_candidates ?? input.maxCandidates);
|
|
59
|
+
if (Number.isInteger(maxCandidates) && maxCandidates > 0) config.maxCandidates = Math.min(maxCandidates, 1000);
|
|
60
|
+
const contextFiles = Number(input.context_files ?? input.contextFiles);
|
|
61
|
+
if (Number.isInteger(contextFiles) && contextFiles > 0) config.contextFiles = Math.min(contextFiles, 100);
|
|
62
|
+
const contextChars = Number(input.context_chars ?? input.contextChars);
|
|
63
|
+
if (Number.isInteger(contextChars) && contextChars > 0) config.contextChars = Math.min(contextChars, 200_000);
|
|
64
|
+
|
|
65
|
+
const ignore = input.exclude?.paths ?? input.exclude ?? input.ignore?.paths ?? input.ignore;
|
|
66
|
+
if (Array.isArray(ignore)) config.ignore = ignore.filter((x) => typeof x === "string" && x.trim());
|
|
67
|
+
|
|
68
|
+
const review = input.review ?? input.categories;
|
|
69
|
+
if (review && typeof review === "object") {
|
|
70
|
+
for (const category of CATEGORIES) {
|
|
71
|
+
if (typeof review[category] === "boolean") config.categories[category] = review[category];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (input.laya && typeof input.laya === "object") {
|
|
76
|
+
if (input.laya.python) config.laya.python = String(input.laya.python);
|
|
77
|
+
if (input.laya.model) config.laya.model = String(input.laya.model);
|
|
78
|
+
const maxLen = Number(input.laya.max_len ?? input.laya.maxLen);
|
|
79
|
+
if (Number.isInteger(maxLen) && maxLen >= 256) config.laya.maxLen = Math.min(maxLen, 8192);
|
|
80
|
+
}
|
|
81
|
+
if (input.jev && typeof input.jev === "object") {
|
|
82
|
+
if (input.jev.url) config.jev.url = String(input.jev.url);
|
|
83
|
+
if (input.jev.model) config.jev.model = String(input.jev.model);
|
|
84
|
+
}
|
|
85
|
+
return config;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function writeDefaultConfig(root, { force = false } = {}) {
|
|
89
|
+
const path = resolve(root, ".mergeguard.yml");
|
|
90
|
+
if (existsSync(path) && !force) throw new Error(`${path} already exists (use --force to replace it)`);
|
|
91
|
+
const text = [
|
|
92
|
+
"# MergeGuard configuration",
|
|
93
|
+
"version: 1",
|
|
94
|
+
"fail_on: high",
|
|
95
|
+
"# Use offline in CI for identical results across machines.",
|
|
96
|
+
"# auto picks local Laya when installed and is environment-dependent.",
|
|
97
|
+
"verifier:",
|
|
98
|
+
" engine: offline",
|
|
99
|
+
"",
|
|
100
|
+
"confidence: 0.62",
|
|
101
|
+
"",
|
|
102
|
+
"exclude:",
|
|
103
|
+
" - node_modules/**",
|
|
104
|
+
" - vendor/**",
|
|
105
|
+
" - dist/**",
|
|
106
|
+
" - build/**",
|
|
107
|
+
" - coverage/**",
|
|
108
|
+
" - generated/**",
|
|
109
|
+
"",
|
|
110
|
+
"review:",
|
|
111
|
+
" correctness: true",
|
|
112
|
+
" security: true",
|
|
113
|
+
" concurrency: true",
|
|
114
|
+
" database: true",
|
|
115
|
+
" authorization: true",
|
|
116
|
+
" tenant-isolation: true",
|
|
117
|
+
" reliability: true",
|
|
118
|
+
" performance: true",
|
|
119
|
+
" api: true",
|
|
120
|
+
"",
|
|
121
|
+
].join("\n");
|
|
122
|
+
writeFileSync(path, text);
|
|
123
|
+
return path;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const KNOWN_ROOT_KEYS = new Set([
|
|
127
|
+
"version", "fail_on", "failOn", "verifier", "confidence", "ignore", "exclude", "review",
|
|
128
|
+
"categories", "laya", "jev", "max_files", "maxFiles", "max_candidates", "maxCandidates",
|
|
129
|
+
"context_files", "contextFiles", "context_chars", "contextChars",
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
function unknownKeyWarnings(parsed, path) {
|
|
133
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
134
|
+
return Object.keys(parsed)
|
|
135
|
+
.filter((key) => !KNOWN_ROOT_KEYS.has(key))
|
|
136
|
+
.map((key) => `${path}: unknown config key '${key}' was ignored`);
|
|
137
|
+
}
|