can-i-merge 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -1,4 +1,284 @@
1
- # PA
1
+ <div align="center">
2
2
 
3
- ## license
4
- CC BY-NC-SA 4.0
3
+ # can-i-merge
4
+
5
+ ### Before you merge, ask one question.
6
+
7
+ > **can-i-merge?**
8
+
9
+ AI-powered Git review pipeline with intelligent context building.
10
+
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#license)
12
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](#)
13
+ [![Node.js](https://img.shields.io/badge/Node.js-20+-green)](#)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## Why?
20
+
21
+ Most AI code review tools simply send your changed files to an LLM.
22
+
23
+ **can-i-merge** does something different.
24
+
25
+ Instead of asking:
26
+
27
+ > "Which AI should I use?"
28
+
29
+ It asks:
30
+
31
+ > **"What is the best context I can give the AI?"**
32
+
33
+ The AI is replaceable.
34
+
35
+ The **Context Engine** is the real product.
36
+
37
+ ---
38
+
39
+ ## Features
40
+
41
+ - Git Diff analysis
42
+ - Intelligent Context Builder
43
+ - Context Budget Engine
44
+ - Dependency-aware context collection
45
+ - Multiple AI providers
46
+ - Claude
47
+ - OpenAI
48
+ - NVIDIA
49
+ - Gemini
50
+ - Ollama
51
+ - OpenRouter
52
+ - Provider abstraction
53
+ - GitHub Action (Planned)
54
+ - VSCode Extension (Planned)
55
+ - MCP Server (Planned)
56
+ - Review Memory (Planned)
57
+
58
+ ---
59
+
60
+ ## Philosophy
61
+
62
+ Good AI reviews don't come from better models.
63
+
64
+ They come from better context.
65
+
66
+ ```
67
+ Git Diff
68
+
69
+
70
+ Context Engine
71
+
72
+
73
+ Claude / GPT / Gemini / NVIDIA
74
+
75
+
76
+ Normalized Review
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Installation
82
+
83
+ ```bash
84
+ npm install -g can-i-merge
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Usage
90
+
91
+ Review current changes
92
+
93
+ ```bash
94
+ can-i-merge
95
+ ```
96
+
97
+ Review latest commit
98
+
99
+ ```bash
100
+ can-i-merge --commit HEAD
101
+ ```
102
+
103
+ Deep review
104
+
105
+ ```bash
106
+ can-i-merge --level deep
107
+ ```
108
+
109
+ Choose AI provider
110
+
111
+ ```bash
112
+ can-i-merge --provider claude
113
+ ```
114
+
115
+ Security review
116
+
117
+ ```bash
118
+ can-i-merge --type security
119
+ ```
120
+
121
+ JSON output
122
+
123
+ ```bash
124
+ can-i-merge --json
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Example
130
+
131
+ ```text
132
+ $ can-i-merge
133
+
134
+ Analyzing Git Diff...
135
+ Building Context...
136
+ Reviewing with Claude...
137
+
138
+ ────────────────────────────
139
+
140
+ Overall Score
141
+
142
+ 92 / 100
143
+
144
+ Critical
145
+ 0
146
+
147
+ High
148
+ 1
149
+
150
+ Medium
151
+ 2
152
+
153
+ Low
154
+ 1
155
+
156
+ ────────────────────────────
157
+
158
+ ❌ Merge Status
159
+
160
+ NOT READY
161
+
162
+ ────────────────────────────
163
+
164
+ High
165
+
166
+ src/auth/login.ts:48
167
+
168
+ JWT validation should happen after authorization.
169
+
170
+ Recommendation
171
+
172
+ Move authorization check before JWT validation.
173
+ ```
174
+
175
+ ---
176
+
177
+ # Architecture
178
+
179
+ ```
180
+ Git Repository
181
+
182
+
183
+ Git Analyzer
184
+
185
+
186
+ Context Builder
187
+ ┌──────────────────────────┐
188
+ │ Dependency Resolver │
189
+ │ Context Score Engine │
190
+ │ Context Budget Engine │
191
+ │ Prompt Builder │
192
+ │ Review Memory │
193
+ └──────────────────────────┘
194
+
195
+
196
+ Provider Layer
197
+ ┌──────────────────────────┐
198
+ │ Claude │
199
+ │ OpenAI │
200
+ │ NVIDIA │
201
+ │ Gemini │
202
+ │ Ollama │
203
+ └──────────────────────────┘
204
+
205
+
206
+ Normalizer
207
+
208
+
209
+ ReviewResult
210
+ ```
211
+
212
+ ---
213
+
214
+ # Why another AI review tool?
215
+
216
+ Most tools focus on the AI.
217
+
218
+ We focus on the context.
219
+
220
+ Instead of sending an entire repository to an LLM,
221
+ can-i-merge builds the smallest, most relevant context possible.
222
+
223
+ That means:
224
+
225
+ - Lower cost
226
+ - Faster reviews
227
+ - Better answers
228
+ - Model independence
229
+
230
+ ---
231
+
232
+ # Roadmap
233
+
234
+ ## v0.1
235
+
236
+ - CLI
237
+ - Git Diff Analyzer
238
+ - Context Builder
239
+ - Claude Provider
240
+ - Terminal Reporter
241
+
242
+ ---
243
+
244
+ ## v0.2
245
+
246
+ - Context Budget Engine
247
+ - Context Score Engine
248
+ - OpenAI Provider
249
+ - NVIDIA Provider
250
+ - Ollama Provider
251
+
252
+ ---
253
+
254
+ ## v0.3
255
+
256
+ - Review Memory
257
+ - Incremental Review
258
+ - GitHub Action
259
+ - VSCode Extension
260
+
261
+ ---
262
+
263
+ ## v1.0
264
+
265
+ - Multi AI Consensus
266
+ - MCP Server
267
+ - Dashboard
268
+ - Auto Fix
269
+ - Team Review
270
+
271
+ ---
272
+
273
+ # Contributing
274
+
275
+ Contributions are welcome.
276
+
277
+ If you have ideas for improving the Context Engine,
278
+ please open an issue or submit a pull request.
279
+
280
+ ---
281
+
282
+ # License
283
+
284
+ MIT License
@@ -0,0 +1,121 @@
1
+ /**
2
+ * CLI composition root.
3
+ *
4
+ * This is the only place in the project that wires a concrete provider
5
+ * (ClaudeProvider) into the provider-agnostic Core pipeline. Adding a new
6
+ * provider later means registering it here - runReview() in core never
7
+ * changes.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { Command } from "commander";
13
+ import { NoChangesError, runReview } from "../core/index.js";
14
+ import { isGitRepository } from "../git/index.js";
15
+ import { ProviderRegistry } from "../provider/index.js";
16
+ import { ClaudeProvider } from "../provider-anthropic/index.js";
17
+ import { isMergeReady, reportConsole, reportJson } from "../reporter/index.js";
18
+ import { colors } from "../shared/colors.js";
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"));
21
+ const REVIEW_LEVELS = ["fast", "normal", "deep"];
22
+ const REVIEW_TYPES = [
23
+ "general",
24
+ "security",
25
+ "performance",
26
+ "architecture",
27
+ "style",
28
+ ];
29
+ const STAGE_MESSAGES = {
30
+ analyzing: "Analyzing Git Diff...",
31
+ context: "Building Context...",
32
+ reviewing: "Reviewing...",
33
+ };
34
+ function isReviewLevel(value) {
35
+ return REVIEW_LEVELS.includes(value);
36
+ }
37
+ function isReviewType(value) {
38
+ return REVIEW_TYPES.includes(value);
39
+ }
40
+ function buildProviderRegistry() {
41
+ const registry = new ProviderRegistry();
42
+ registry.register("claude", (config) => new ClaudeProvider(config));
43
+ return registry;
44
+ }
45
+ export async function main(argv = process.argv) {
46
+ const program = new Command();
47
+ program
48
+ .name("can-i-merge")
49
+ .description("AI-powered Git Review Pipeline with Intelligent Context Building")
50
+ .version(pkg.version)
51
+ .option("--commit <ref>", "review a specific commit instead of the staged index")
52
+ .option("--provider <name>", "AI provider to use", "claude")
53
+ .option("--level <level>", "review level: fast, normal, or deep", "normal")
54
+ .option("--type <type>", "review focus: general, security, performance, architecture, or style", "general")
55
+ .option("--json", "output the review result as JSON", false)
56
+ .option("--fix", "automatically apply suggested fixes (not yet supported)", false);
57
+ program.parse(argv);
58
+ const opts = program.opts();
59
+ if (opts.fix) {
60
+ console.error(colors.yellow("--fix is not supported yet (see TOBE.md roadmap, Phase 4)."));
61
+ process.exitCode = 2;
62
+ return;
63
+ }
64
+ if (!isReviewLevel(opts.level)) {
65
+ console.error(colors.red(`Invalid --level "${opts.level}". Expected one of: ${REVIEW_LEVELS.join(", ")}.`));
66
+ process.exitCode = 2;
67
+ return;
68
+ }
69
+ if (!isReviewType(opts.type)) {
70
+ console.error(colors.red(`Invalid --type "${opts.type}". Expected one of: ${REVIEW_TYPES.join(", ")}.`));
71
+ process.exitCode = 2;
72
+ return;
73
+ }
74
+ const cwd = process.cwd();
75
+ if (!(await isGitRepository(cwd))) {
76
+ console.error(colors.red(`Not a git repository: ${cwd}`));
77
+ process.exitCode = 2;
78
+ return;
79
+ }
80
+ const registry = buildProviderRegistry();
81
+ let provider;
82
+ try {
83
+ provider = registry.create(opts.provider);
84
+ }
85
+ catch (err) {
86
+ console.error(colors.red(err instanceof Error ? err.message : String(err)));
87
+ process.exitCode = 2;
88
+ return;
89
+ }
90
+ if (!opts.json) {
91
+ console.log(colors.bold(`can-i-merge v${pkg.version}`));
92
+ }
93
+ try {
94
+ const result = await runReview({
95
+ cwd,
96
+ commit: opts.commit,
97
+ level: opts.level,
98
+ type: opts.type,
99
+ provider,
100
+ onStage: opts.json
101
+ ? undefined
102
+ : (stage) => console.log(STAGE_MESSAGES[stage]),
103
+ });
104
+ if (opts.json) {
105
+ reportJson(result);
106
+ }
107
+ else {
108
+ reportConsole(result);
109
+ }
110
+ process.exitCode = isMergeReady(result.score) ? 0 : 1;
111
+ }
112
+ catch (err) {
113
+ if (err instanceof NoChangesError) {
114
+ console.error(colors.yellow(err.message));
115
+ process.exitCode = 0;
116
+ return;
117
+ }
118
+ console.error(colors.red(err instanceof Error ? err.message : String(err)));
119
+ process.exitCode = 2;
120
+ }
121
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Context Budget Engine
3
+ *
4
+ * Context Builder operates on a TOKEN BUDGET, not a fixed depth. The git
5
+ * diff itself is always included in full and its token cost is reserved off
6
+ * the top; the budget only decides how many extra *related* files get
7
+ * pulled in around it.
8
+ */
9
+ import { estimateTokens } from "../shared/tokens.js";
10
+ export const DEFAULT_BUDGET = {
11
+ maxTokens: 12000,
12
+ maxFiles: 15,
13
+ reservedDiffTokens: 3000,
14
+ };
15
+ export function applyBudget(relatedFiles, diffText, budget) {
16
+ const diffTokens = Math.max(estimateTokens(diffText), budget.reservedDiffTokens);
17
+ const sorted = [...relatedFiles].sort((a, b) => b.score - a.score);
18
+ const included = [];
19
+ let truncated = false;
20
+ let includedTokens = 0;
21
+ for (const candidate of sorted) {
22
+ if (included.length + 1 > budget.maxFiles) {
23
+ truncated = true;
24
+ continue;
25
+ }
26
+ const candidateTokens = estimateTokens(candidate.content);
27
+ const remaining = budget.maxTokens - diffTokens - includedTokens;
28
+ if (candidateTokens > remaining) {
29
+ truncated = true;
30
+ continue;
31
+ }
32
+ included.push(candidate);
33
+ includedTokens += candidateTokens;
34
+ }
35
+ return {
36
+ included,
37
+ truncated,
38
+ tokenEstimate: diffTokens + includedTokens,
39
+ };
40
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Context Builder - main entry point for the Context Engine.
3
+ *
4
+ * Orchestrates dependency resolution, scoring and budgeting to produce a
5
+ * ReviewContext for the Prompt Builder. The git diff is always included in
6
+ * full; the token budget only governs how many extra related files are
7
+ * pulled in around it.
8
+ */
9
+ import * as fs from "node:fs/promises";
10
+ import * as path from "node:path";
11
+ import { estimateTokens } from "../shared/tokens.js";
12
+ import { applyBudget, DEFAULT_BUDGET } from "./contextBudgetEngine.js";
13
+ import { scoreCandidates } from "./contextScoreEngine.js";
14
+ import { resolveDependencies } from "./dependencyResolver.js";
15
+ const TEST_EXTENSION_SUFFIXES = [
16
+ ".test.ts",
17
+ ".test.tsx",
18
+ ".spec.ts",
19
+ ".spec.tsx",
20
+ ];
21
+ const ROOT_CONFIG_FILES = [
22
+ "tsconfig.json",
23
+ "package.json",
24
+ ".eslintrc.json",
25
+ ".eslintrc.cjs",
26
+ ".eslintrc.js",
27
+ ];
28
+ const MAX_CONFIG_FILE_BYTES = 50_000;
29
+ function toPosixPath(p) {
30
+ return p.split(path.sep).join("/");
31
+ }
32
+ async function readFileIfSmallEnough(absPath, maxBytes) {
33
+ try {
34
+ const stat = await fs.stat(absPath);
35
+ if (!stat.isFile())
36
+ return undefined;
37
+ if (stat.size > maxBytes)
38
+ return undefined;
39
+ return await fs.readFile(absPath, "utf8");
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ function stripKnownExtension(filePath) {
46
+ const ext = path.extname(filePath);
47
+ return ext ? filePath.slice(0, -ext.length) : filePath;
48
+ }
49
+ async function findTestFileFor(changedFile, repoRoot) {
50
+ const base = stripKnownExtension(changedFile);
51
+ const dir = path.dirname(changedFile);
52
+ const baseName = path.basename(base);
53
+ const candidateRelPaths = [];
54
+ for (const suffix of TEST_EXTENSION_SUFFIXES) {
55
+ candidateRelPaths.push(toPosixPath(base + suffix));
56
+ }
57
+ for (const suffix of TEST_EXTENSION_SUFFIXES) {
58
+ candidateRelPaths.push(toPosixPath(path.join(dir, "__tests__", baseName + suffix)));
59
+ }
60
+ for (const relPath of candidateRelPaths) {
61
+ if (relPath === changedFile)
62
+ continue;
63
+ const absPath = path.join(repoRoot, relPath);
64
+ const content = await readFileIfSmallEnough(absPath, 200_000);
65
+ if (content !== undefined) {
66
+ return {
67
+ file: relPath,
68
+ content,
69
+ reason: `test file for ${changedFile}`,
70
+ score: 65,
71
+ };
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+ async function gatherDeepExtras(changedFiles, repoRoot, existingFiles) {
77
+ const extras = [];
78
+ const seen = new Set(existingFiles);
79
+ for (const changed of changedFiles) {
80
+ if (changed.status === "deleted")
81
+ continue;
82
+ const testFile = await findTestFileFor(changed.file, repoRoot);
83
+ if (testFile && !seen.has(testFile.file)) {
84
+ extras.push(testFile);
85
+ seen.add(testFile.file);
86
+ }
87
+ }
88
+ for (const configFile of ROOT_CONFIG_FILES) {
89
+ if (seen.has(configFile))
90
+ continue;
91
+ const absPath = path.join(repoRoot, configFile);
92
+ const content = await readFileIfSmallEnough(absPath, MAX_CONFIG_FILE_BYTES);
93
+ if (content !== undefined) {
94
+ extras.push({
95
+ file: configFile,
96
+ content,
97
+ reason: "project config",
98
+ score: 55,
99
+ });
100
+ seen.add(configFile);
101
+ }
102
+ }
103
+ return extras;
104
+ }
105
+ async function loadProjectRules(repoRoot) {
106
+ try {
107
+ const configPath = path.join(repoRoot, "can-i-merge.config.json");
108
+ const configContent = await readFileIfSmallEnough(configPath, MAX_CONFIG_FILE_BYTES);
109
+ if (configContent !== undefined) {
110
+ const parsed = JSON.parse(configContent);
111
+ if (parsed &&
112
+ typeof parsed === "object" &&
113
+ "rules" in parsed &&
114
+ typeof parsed.rules === "string") {
115
+ return parsed.rules;
116
+ }
117
+ }
118
+ }
119
+ catch {
120
+ // fall through to next strategy - malformed config should not throw
121
+ }
122
+ try {
123
+ const rulesPath = path.join(repoRoot, ".can-i-merge", "rules.md");
124
+ const rulesContent = await fs.readFile(rulesPath, "utf8");
125
+ return rulesContent;
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ export async function buildContext(changedFiles, options) {
132
+ const budget = { ...DEFAULT_BUDGET, ...options.budget };
133
+ const diff = changedFiles.map((c) => c.diff).join("\n\n");
134
+ let scored = [];
135
+ if (options.level !== "fast") {
136
+ const maxDepth = options.level === "deep" ? 3 : 2;
137
+ const candidates = await resolveDependencies(changedFiles, options.repoRoot, {
138
+ maxDepth,
139
+ });
140
+ scored = scoreCandidates(candidates);
141
+ if (options.level === "deep") {
142
+ const existingFiles = new Set(scored.map((f) => f.file));
143
+ const extras = await gatherDeepExtras(changedFiles, options.repoRoot, existingFiles);
144
+ scored = [...scored, ...extras];
145
+ }
146
+ }
147
+ let relatedFiles;
148
+ let tokenEstimate;
149
+ let truncated;
150
+ if (options.level !== "fast") {
151
+ const budgetResult = applyBudget(scored, diff, budget);
152
+ relatedFiles = budgetResult.included;
153
+ tokenEstimate = budgetResult.tokenEstimate;
154
+ truncated = budgetResult.truncated;
155
+ }
156
+ else {
157
+ relatedFiles = [];
158
+ tokenEstimate = estimateTokens(diff);
159
+ truncated = false;
160
+ }
161
+ let projectRules;
162
+ if (options.projectRules !== undefined) {
163
+ projectRules = options.projectRules;
164
+ }
165
+ else {
166
+ projectRules = await loadProjectRules(options.repoRoot);
167
+ }
168
+ return {
169
+ level: options.level,
170
+ type: options.type,
171
+ diff,
172
+ changedFiles,
173
+ relatedFiles,
174
+ projectRules,
175
+ prompt: undefined,
176
+ budget,
177
+ meta: {
178
+ tokenEstimate,
179
+ truncated,
180
+ },
181
+ };
182
+ }
@@ -0,0 +1,19 @@
1
+ function clamp(value, min, max) {
2
+ return Math.min(max, Math.max(min, value));
3
+ }
4
+ export function scoreCandidates(candidates) {
5
+ return candidates.map((candidate) => {
6
+ const proximityScore = Math.max(10, 100 - (candidate.distance - 1) * 30);
7
+ const referenceBonus = Math.min(candidate.referencedBy.length, 5) * 5;
8
+ const score = clamp(proximityScore + referenceBonus, 0, 100);
9
+ const reason = candidate.referencedBy.length
10
+ ? `referenced by ${candidate.referencedBy.join(", ")}`
11
+ : "related file";
12
+ return {
13
+ file: candidate.file,
14
+ content: candidate.content,
15
+ reason,
16
+ score,
17
+ };
18
+ });
19
+ }