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 +283 -3
- package/dist/cli/index.js +121 -0
- package/dist/context-engine/contextBudgetEngine.js +40 -0
- package/dist/context-engine/contextBuilder.js +182 -0
- package/dist/context-engine/contextScoreEngine.js +19 -0
- package/dist/context-engine/dependencyResolver.js +183 -0
- package/dist/context-engine/index.js +4 -0
- package/dist/core/index.js +1 -0
- package/dist/core/reviewOrchestrator.js +43 -0
- package/dist/git/gitAnalyzer.js +193 -0
- package/dist/git/index.js +1 -0
- package/dist/index.js +6 -0
- package/dist/normalizer/index.js +1 -0
- package/dist/normalizer/normalizer.js +112 -0
- package/dist/prompt/index.js +1 -0
- package/dist/prompt/promptBuilder.js +143 -0
- package/dist/provider/index.js +6 -0
- package/dist/provider/registry.js +26 -0
- package/dist/provider-anthropic/claudeProvider.js +123 -0
- package/dist/provider-anthropic/index.js +6 -0
- package/dist/reporter/consoleReporter.js +103 -0
- package/dist/reporter/index.js +2 -0
- package/dist/reporter/jsonReporter.js +3 -0
- package/dist/shared/colors.js +23 -0
- package/dist/shared/tokens.js +13 -0
- package/dist/shared/types.js +10 -0
- package/package.json +42 -18
package/README.md
CHANGED
|
@@ -1,4 +1,284 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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)
|
|
12
|
+
[](#)
|
|
13
|
+
[](#)
|
|
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
|
+
}
|