can-i-merge 0.1.0 → 0.2.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.
Files changed (36) hide show
  1. package/README.md +283 -3
  2. package/dist/cli/index.js +152 -0
  3. package/dist/config/credentialStore.js +118 -0
  4. package/dist/config/index.js +4 -0
  5. package/dist/context-engine/contextBudgetEngine.js +40 -0
  6. package/dist/context-engine/contextBuilder.js +182 -0
  7. package/dist/context-engine/contextScoreEngine.js +19 -0
  8. package/dist/context-engine/dependencyResolver.js +183 -0
  9. package/dist/context-engine/index.js +4 -0
  10. package/dist/core/index.js +1 -0
  11. package/dist/core/reviewOrchestrator.js +43 -0
  12. package/dist/git/gitAnalyzer.js +193 -0
  13. package/dist/git/index.js +1 -0
  14. package/dist/index.js +6 -0
  15. package/dist/normalizer/index.js +1 -0
  16. package/dist/normalizer/normalizer.js +112 -0
  17. package/dist/prompt/index.js +1 -0
  18. package/dist/prompt/promptBuilder.js +143 -0
  19. package/dist/provider/index.js +6 -0
  20. package/dist/provider/registry.js +26 -0
  21. package/dist/provider-anthropic/claudeProvider.js +123 -0
  22. package/dist/provider-anthropic/index.js +6 -0
  23. package/dist/provider-nvidia/index.js +1 -0
  24. package/dist/provider-nvidia/nvidiaProvider.js +33 -0
  25. package/dist/provider-ollama/index.js +6 -0
  26. package/dist/provider-ollama/ollamaProvider.js +34 -0
  27. package/dist/provider-openai/index.js +6 -0
  28. package/dist/provider-openai/openAIProvider.js +32 -0
  29. package/dist/reporter/consoleReporter.js +103 -0
  30. package/dist/reporter/index.js +2 -0
  31. package/dist/reporter/jsonReporter.js +3 -0
  32. package/dist/shared/colors.js +23 -0
  33. package/dist/shared/openaiCompatibleProvider.js +125 -0
  34. package/dist/shared/tokens.js +13 -0
  35. package/dist/shared/types.js +10 -0
  36. package/package.json +43 -18
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,152 @@
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 { clearProviderConfig, loadProviderConfig, saveProviderConfig } from "../config/index.js";
14
+ import { NoChangesError, runReview } from "../core/index.js";
15
+ import { isGitRepository } from "../git/index.js";
16
+ import { ProviderRegistry } from "../provider/index.js";
17
+ import { ClaudeProvider } from "../provider-anthropic/index.js";
18
+ import { NvidiaProvider } from "../provider-nvidia/index.js";
19
+ import { OllamaProvider } from "../provider-ollama/index.js";
20
+ import { OpenAIProvider } from "../provider-openai/index.js";
21
+ import { isMergeReady, reportConsole, reportJson } from "../reporter/index.js";
22
+ import { colors } from "../shared/colors.js";
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"));
25
+ const REVIEW_LEVELS = ["fast", "normal", "deep"];
26
+ const REVIEW_TYPES = [
27
+ "general",
28
+ "security",
29
+ "performance",
30
+ "architecture",
31
+ "style",
32
+ ];
33
+ const STAGE_MESSAGES = {
34
+ analyzing: "Analyzing Git Diff...",
35
+ context: "Building Context...",
36
+ reviewing: "Reviewing...",
37
+ };
38
+ const PROVIDER_ENV_VARS = {
39
+ claude: { apiKey: "ANTHROPIC_API_KEY", model: "ANTHROPIC_MODEL" },
40
+ openai: { apiKey: "OPENAI_API_KEY", model: "OPENAI_MODEL" },
41
+ nvidia: { apiKey: "NVIDIA_API_KEY", model: "NVIDIA_MODEL" },
42
+ ollama: { apiKey: "OLLAMA_API_KEY", model: "OLLAMA_MODEL" },
43
+ };
44
+ function isReviewLevel(value) {
45
+ return REVIEW_LEVELS.includes(value);
46
+ }
47
+ function isReviewType(value) {
48
+ return REVIEW_TYPES.includes(value);
49
+ }
50
+ function buildProviderRegistry() {
51
+ const registry = new ProviderRegistry();
52
+ registry.register("claude", (config) => new ClaudeProvider(config));
53
+ registry.register("openai", (config) => new OpenAIProvider(config));
54
+ registry.register("nvidia", (config) => new NvidiaProvider(config));
55
+ registry.register("ollama", (config) => new OllamaProvider(config));
56
+ return registry;
57
+ }
58
+ export async function main(argv = process.argv) {
59
+ const program = new Command();
60
+ program
61
+ .name("can-i-merge")
62
+ .description("AI-powered Git Review Pipeline with Intelligent Context Building")
63
+ .version(pkg.version)
64
+ .option("--commit <ref>", "review a specific commit instead of the staged index")
65
+ .option("--provider <name>", "AI provider to use: claude, openai, nvidia, or ollama", "claude")
66
+ .option("--api-key <key>", "API key for the selected provider (saved encrypted for future runs)")
67
+ .option("--model <model>", "model name for the selected provider (saved for future runs)")
68
+ .option("--forget-credentials", "remove stored credentials for the selected provider and exit", false)
69
+ .option("--level <level>", "review level: fast, normal, or deep", "normal")
70
+ .option("--type <type>", "review focus: general, security, performance, architecture, or style", "general")
71
+ .option("--json", "output the review result as JSON", false)
72
+ .option("--fix", "automatically apply suggested fixes (not yet supported)", false);
73
+ program.parse(argv);
74
+ const opts = program.opts();
75
+ if (opts.forgetCredentials) {
76
+ clearProviderConfig(opts.provider);
77
+ console.log(colors.green(`Removed stored credentials for provider "${opts.provider}".`));
78
+ return;
79
+ }
80
+ if (opts.fix) {
81
+ console.error(colors.yellow("--fix is not supported yet (see TOBE.md roadmap, Phase 4)."));
82
+ process.exitCode = 2;
83
+ return;
84
+ }
85
+ if (!isReviewLevel(opts.level)) {
86
+ console.error(colors.red(`Invalid --level "${opts.level}". Expected one of: ${REVIEW_LEVELS.join(", ")}.`));
87
+ process.exitCode = 2;
88
+ return;
89
+ }
90
+ if (!isReviewType(opts.type)) {
91
+ console.error(colors.red(`Invalid --type "${opts.type}". Expected one of: ${REVIEW_TYPES.join(", ")}.`));
92
+ process.exitCode = 2;
93
+ return;
94
+ }
95
+ const cwd = process.cwd();
96
+ if (!(await isGitRepository(cwd))) {
97
+ console.error(colors.red(`Not a git repository: ${cwd}`));
98
+ process.exitCode = 2;
99
+ return;
100
+ }
101
+ if (opts.apiKey !== undefined || opts.model !== undefined) {
102
+ saveProviderConfig(opts.provider, { apiKey: opts.apiKey, model: opts.model });
103
+ if (!opts.json) {
104
+ console.log(colors.dim(`Saved ${opts.provider} credentials to ~/.can-i-merge (encrypted).`));
105
+ }
106
+ }
107
+ const stored = loadProviderConfig(opts.provider);
108
+ const envVars = PROVIDER_ENV_VARS[opts.provider];
109
+ const apiKey = opts.apiKey ?? (envVars && process.env[envVars.apiKey]) ?? stored.apiKey;
110
+ const model = opts.model ?? (envVars && process.env[envVars.model]) ?? stored.model;
111
+ const registry = buildProviderRegistry();
112
+ let provider;
113
+ try {
114
+ provider = registry.create(opts.provider, { apiKey, model });
115
+ }
116
+ catch (err) {
117
+ console.error(colors.red(err instanceof Error ? err.message : String(err)));
118
+ process.exitCode = 2;
119
+ return;
120
+ }
121
+ if (!opts.json) {
122
+ console.log(colors.bold(`can-i-merge v${pkg.version}`));
123
+ }
124
+ try {
125
+ const result = await runReview({
126
+ cwd,
127
+ commit: opts.commit,
128
+ level: opts.level,
129
+ type: opts.type,
130
+ provider,
131
+ onStage: opts.json
132
+ ? undefined
133
+ : (stage) => console.log(STAGE_MESSAGES[stage]),
134
+ });
135
+ if (opts.json) {
136
+ reportJson(result);
137
+ }
138
+ else {
139
+ reportConsole(result);
140
+ }
141
+ process.exitCode = isMergeReady(result.score) ? 0 : 1;
142
+ }
143
+ catch (err) {
144
+ if (err instanceof NoChangesError) {
145
+ console.error(colors.yellow(err.message));
146
+ process.exitCode = 0;
147
+ return;
148
+ }
149
+ console.error(colors.red(err instanceof Error ? err.message : String(err)));
150
+ process.exitCode = 2;
151
+ }
152
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Local, encrypted credential store for provider API keys.
3
+ *
4
+ * CLI flags and env vars are per-invocation. This store lets a user pass
5
+ * --api-key/--model once and have it picked up automatically on every later
6
+ * run, without re-exporting an env var each time.
7
+ *
8
+ * The encryption key is a random 32-byte value generated on first use and
9
+ * kept in a file (key) separate from the ciphertext (credentials.json),
10
+ * both under the user's home directory with owner-only permissions. This
11
+ * guards against accidental exposure (e.g. a backup or sync tool scooping up
12
+ * one file but not the other) - it does not protect against an attacker who
13
+ * already has read access to the user's home directory, since the key lives
14
+ * on the same machine as the ciphertext.
15
+ */
16
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
17
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import path from "node:path";
20
+ const CONFIG_DIR = path.join(homedir(), ".can-i-merge");
21
+ const KEY_FILE = path.join(CONFIG_DIR, "key");
22
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
23
+ const ALGORITHM = "aes-256-gcm";
24
+ function ensureConfigDir() {
25
+ if (!existsSync(CONFIG_DIR)) {
26
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
27
+ }
28
+ }
29
+ function getOrCreateKey() {
30
+ ensureConfigDir();
31
+ if (existsSync(KEY_FILE)) {
32
+ return Buffer.from(readFileSync(KEY_FILE, "utf8"), "base64");
33
+ }
34
+ const key = randomBytes(32);
35
+ writeFileSync(KEY_FILE, key.toString("base64"), { mode: 0o600 });
36
+ return key;
37
+ }
38
+ function encrypt(plaintext, key) {
39
+ const iv = randomBytes(12);
40
+ const cipher = createCipheriv(ALGORITHM, key, iv);
41
+ const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
42
+ return {
43
+ iv: iv.toString("base64"),
44
+ tag: cipher.getAuthTag().toString("base64"),
45
+ data: data.toString("base64"),
46
+ };
47
+ }
48
+ function decrypt(value, key) {
49
+ const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(value.iv, "base64"));
50
+ decipher.setAuthTag(Buffer.from(value.tag, "base64"));
51
+ const data = Buffer.concat([
52
+ decipher.update(Buffer.from(value.data, "base64")),
53
+ decipher.final(),
54
+ ]);
55
+ return data.toString("utf8");
56
+ }
57
+ function readCredentialsFile() {
58
+ if (!existsSync(CREDENTIALS_FILE)) {
59
+ return {};
60
+ }
61
+ try {
62
+ return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf8"));
63
+ }
64
+ catch {
65
+ return {};
66
+ }
67
+ }
68
+ function writeCredentialsFile(contents) {
69
+ ensureConfigDir();
70
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(contents, null, 2), { mode: 0o600 });
71
+ }
72
+ /** Persist an API key and/or model for a provider, encrypting the API key at rest. */
73
+ export function saveProviderConfig(provider, config) {
74
+ const file = readCredentialsFile();
75
+ const next = { ...file[provider] };
76
+ if (config.apiKey !== undefined) {
77
+ next.apiKey = encrypt(config.apiKey, getOrCreateKey());
78
+ }
79
+ if (config.model !== undefined) {
80
+ next.model = config.model;
81
+ }
82
+ file[provider] = next;
83
+ writeCredentialsFile(file);
84
+ }
85
+ /** Load a previously persisted API key/model for a provider, decrypting the API key. */
86
+ export function loadProviderConfig(provider) {
87
+ const stored = readCredentialsFile()[provider];
88
+ if (!stored) {
89
+ return {};
90
+ }
91
+ const result = {};
92
+ if (stored.apiKey) {
93
+ try {
94
+ result.apiKey = decrypt(stored.apiKey, getOrCreateKey());
95
+ }
96
+ catch {
97
+ // Key file missing/rotated or ciphertext corrupted - treat as absent
98
+ // rather than crashing the CLI.
99
+ }
100
+ }
101
+ if (stored.model) {
102
+ result.model = stored.model;
103
+ }
104
+ return result;
105
+ }
106
+ /** Remove stored credentials for a provider (or all providers if omitted). */
107
+ export function clearProviderConfig(provider) {
108
+ if (!existsSync(CREDENTIALS_FILE)) {
109
+ return;
110
+ }
111
+ if (!provider) {
112
+ unlinkSync(CREDENTIALS_FILE);
113
+ return;
114
+ }
115
+ const file = readCredentialsFile();
116
+ delete file[provider];
117
+ writeCredentialsFile(file);
118
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * config package entry point. Re-exports the encrypted credential store.
3
+ */
4
+ export * from "./credentialStore.js";
@@ -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
+ }