@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.
@@ -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
+ }