@ateriss_/aiv-cli 1.0.2 → 1.0.3
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 +89 -9
- package/dist/index.js +324 -255
- package/package.json +2 -2
- package/src/agents/architecture.ts +10 -14
- package/src/agents/base.ts +15 -7
- package/src/agents/business.ts +8 -13
- package/src/agents/security.ts +12 -14
- package/src/agents/triage.ts +66 -0
- package/src/cli/commands/check.ts +216 -0
- package/src/cli/commands/prs.ts +1 -1
- package/src/cli/commands/review.ts +7 -4
- package/src/cli/selector.ts +53 -1
- package/src/context/generator.ts +161 -65
- package/src/git/github.ts +20 -0
- package/src/git/local.ts +113 -0
- package/src/git/utils.ts +17 -8
- package/src/i18n/en.ts +19 -1
- package/src/i18n/es.ts +19 -1
- package/src/index.ts +2 -0
- package/src/orchestrator/index.ts +48 -2
package/src/context/generator.ts
CHANGED
|
@@ -3,8 +3,23 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { LLMProvider } from '../providers/base';
|
|
4
4
|
import { treePath } from '../config';
|
|
5
5
|
|
|
6
|
-
const IGNORED = new Set(['node_modules', '.git', 'dist', 'build', '.aiv', '.next', 'coverage']);
|
|
7
|
-
const SOURCE_EXT = /\.(ts|js|tsx|jsx|py|go|java|rb|php|cs|rs)$/;
|
|
6
|
+
const IGNORED = new Set(['node_modules', '.git', 'dist', 'build', '.aiv', '.next', 'coverage', '.cache', 'out', '__pycache__']);
|
|
7
|
+
const SOURCE_EXT = /\.(ts|js|tsx|jsx|py|go|java|rb|php|cs|rs|kt|swift)$/;
|
|
8
|
+
const CONFIG_EXT = /\.(json|yaml|yml|toml|env\.example|env\.sample)$/;
|
|
9
|
+
|
|
10
|
+
const EDITORIAL_HEADER = `<!-- =========================================================
|
|
11
|
+
aiv context.md — auto-generated by \`aiv context generate\`
|
|
12
|
+
|
|
13
|
+
READ THIS FILE and edit it before your first review.
|
|
14
|
+
The more accurate this file is, the better the AI findings will be.
|
|
15
|
+
|
|
16
|
+
Key sections to review:
|
|
17
|
+
- Business Rules: these directly drive agent findings — add real invariants
|
|
18
|
+
- Sensitive Zones: add any path that handles money, auth, or PII
|
|
19
|
+
- Architecture: correct any wrong assumptions about your layer structure
|
|
20
|
+
========================================================= -->
|
|
21
|
+
|
|
22
|
+
`;
|
|
8
23
|
|
|
9
24
|
export async function generateContextAndRules(
|
|
10
25
|
cwd: string,
|
|
@@ -14,61 +29,98 @@ export async function generateContextAndRules(
|
|
|
14
29
|
|
|
15
30
|
const treeFile = treePath(cwd);
|
|
16
31
|
const treeData = fs.existsSync(treeFile)
|
|
17
|
-
? fs.readFileSync(treeFile, 'utf8').slice(0,
|
|
32
|
+
? fs.readFileSync(treeFile, 'utf8').slice(0, 10000)
|
|
18
33
|
: '(tree not available — run aiv context refresh first)';
|
|
19
34
|
|
|
20
|
-
const pkgInfo
|
|
21
|
-
const
|
|
35
|
+
const pkgInfo = readPackageJson(cwd);
|
|
36
|
+
const readme = readReadme(cwd);
|
|
37
|
+
const configs = readConfigFiles(cwd);
|
|
38
|
+
const sources = readSourceFiles(cwd);
|
|
22
39
|
|
|
23
|
-
const userMessage = `
|
|
24
|
-
Project name: ${name}
|
|
40
|
+
const userMessage = `Project name: ${name}
|
|
25
41
|
|
|
26
|
-
package.json
|
|
42
|
+
## package.json
|
|
27
43
|
${pkgInfo}
|
|
28
44
|
|
|
29
|
-
|
|
45
|
+
## README (if present)
|
|
46
|
+
${readme}
|
|
47
|
+
|
|
48
|
+
## Config files (tsconfig, env.example, etc.)
|
|
49
|
+
${configs}
|
|
50
|
+
|
|
51
|
+
## Project file tree
|
|
30
52
|
${treeData}
|
|
31
53
|
|
|
32
|
-
|
|
33
|
-
${
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
Return ONLY valid JSON
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
## Source files sample
|
|
55
|
+
${sources}
|
|
56
|
+
|
|
57
|
+
Analyze this project thoroughly and generate context.md and rules.yml for AI PR reviewers.
|
|
58
|
+
Return ONLY valid JSON: { "context": "<context.md content>", "rules": "<rules.yml content>" }`;
|
|
59
|
+
|
|
60
|
+
const systemPrompt = `You are an expert software architect analyzing a codebase to generate AI reviewer context.
|
|
61
|
+
|
|
62
|
+
Your output must help AI agents understand:
|
|
63
|
+
1. How the project is structured and what each layer/module does
|
|
64
|
+
2. What business domain it operates in and what invariants must hold
|
|
65
|
+
3. Which parts are security-sensitive or require extra care
|
|
66
|
+
4. What architectural patterns the project follows (so deviations can be flagged)
|
|
67
|
+
|
|
68
|
+
Generate two files:
|
|
69
|
+
|
|
70
|
+
### context.md — include ALL of these sections:
|
|
71
|
+
|
|
72
|
+
## Business Context
|
|
73
|
+
Describe the business domain (e-commerce, fintech, healthcare, SaaS, etc.) and what the system does.
|
|
74
|
+
State the most critical business invariants — rules that must NEVER be violated (e.g., "balances must never go negative", "all payments require an audit log").
|
|
75
|
+
|
|
76
|
+
## Architecture
|
|
77
|
+
Describe the overall pattern: monolith/microservices/monorepo, layering (handlers → services → repositories), module structure, main data flows.
|
|
78
|
+
Name the layers explicitly so agents can flag violations.
|
|
79
|
+
|
|
80
|
+
## Modules
|
|
81
|
+
List each significant module/directory with a one-line description of its responsibility.
|
|
82
|
+
|
|
83
|
+
## Technologies
|
|
84
|
+
Main frameworks, ORMs, auth libraries, messaging systems, etc.
|
|
85
|
+
|
|
86
|
+
## Critical Dependencies
|
|
87
|
+
Security-sensitive packages (auth, crypto, payments, HTTP clients).
|
|
88
|
+
|
|
89
|
+
## Sensitive Zones
|
|
90
|
+
Exact file paths or directories that handle: authentication, payments/billing, PII, database migrations, permissions.
|
|
91
|
+
Format as a list: "- src/auth/ — JWT issuance and session management"
|
|
92
|
+
|
|
93
|
+
## Business Rules
|
|
94
|
+
IMPORTANT: infer real domain rules from the code. Examples:
|
|
95
|
+
- "All mutations in src/payments/ must call auditLog()"
|
|
96
|
+
- "User balance must only be modified via BalanceService, never directly"
|
|
97
|
+
- "Orders cannot transition from COMPLETED to any other state"
|
|
98
|
+
Be specific. Vague rules produce vague findings.
|
|
99
|
+
|
|
100
|
+
## System Summary
|
|
101
|
+
Two-sentence description of what the system does and who uses it.
|
|
102
|
+
|
|
103
|
+
### rules.yml — infer real module names and rules:
|
|
104
|
+
|
|
105
|
+
sensitive_modules: [list actual folder names that are sensitive]
|
|
106
|
+
business_rules:
|
|
107
|
+
<real_module_name>:
|
|
108
|
+
required_calls: [actual function names that must be called]
|
|
109
|
+
required_checks: [actual validation functions/guards]
|
|
110
|
+
forbidden_patterns: [actual antipatterns found or expected]
|
|
111
|
+
|
|
112
|
+
Use only real names from the file tree. No generic placeholders like "your-module".
|
|
113
|
+
Return ONLY the JSON object — no markdown fences, no explanation outside the JSON.`;
|
|
64
114
|
|
|
65
115
|
const response = await provider.complete(
|
|
66
116
|
[{ role: 'user', content: userMessage }],
|
|
67
117
|
systemPrompt,
|
|
68
|
-
|
|
118
|
+
6000,
|
|
69
119
|
);
|
|
70
120
|
|
|
71
|
-
|
|
121
|
+
const result = parseResponse(response.content);
|
|
122
|
+
result.context = EDITORIAL_HEADER + result.context;
|
|
123
|
+
return result;
|
|
72
124
|
}
|
|
73
125
|
|
|
74
126
|
function parseResponse(raw: string): { context: string; rules: string } {
|
|
@@ -92,47 +144,91 @@ function readPackageJson(cwd: string): string {
|
|
|
92
144
|
if (!fs.existsSync(file)) return '(not found)';
|
|
93
145
|
try {
|
|
94
146
|
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
95
|
-
const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })
|
|
96
|
-
return `name: ${pkg.name ?? '?'}\nscripts: ${Object.keys(pkg.scripts ?? {}).join(', ')}\ndependencies: ${deps.join(', ')}`;
|
|
147
|
+
const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
|
|
148
|
+
return `name: ${pkg.name ?? '?'}\nversion: ${pkg.version ?? '?'}\nscripts: ${Object.keys(pkg.scripts ?? {}).join(', ')}\ndependencies (${deps.length}): ${deps.join(', ')}`;
|
|
97
149
|
} catch {
|
|
98
150
|
return '(unreadable)';
|
|
99
151
|
}
|
|
100
152
|
}
|
|
101
153
|
|
|
102
|
-
function
|
|
103
|
-
const
|
|
154
|
+
function readReadme(cwd: string): string {
|
|
155
|
+
for (const name of ['README.md', 'readme.md', 'README.txt', 'README']) {
|
|
156
|
+
const file = path.join(cwd, name);
|
|
157
|
+
if (fs.existsSync(file)) {
|
|
158
|
+
try { return fs.readFileSync(file, 'utf8').slice(0, 3000); } catch {}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return '(not found)';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readConfigFiles(cwd: string): string {
|
|
165
|
+
const candidates = [
|
|
166
|
+
'tsconfig.json', 'jsconfig.json',
|
|
167
|
+
'.env.example', '.env.sample', '.env.template',
|
|
168
|
+
'docker-compose.yml', 'docker-compose.yaml',
|
|
169
|
+
'angular.json', 'nest-cli.json', 'vite.config.ts', 'vite.config.js',
|
|
170
|
+
'next.config.js', 'next.config.ts',
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const parts: string[] = [];
|
|
174
|
+
for (const name of candidates) {
|
|
175
|
+
const file = path.join(cwd, name);
|
|
176
|
+
if (!fs.existsSync(file)) continue;
|
|
177
|
+
try {
|
|
178
|
+
const content = fs.readFileSync(file, 'utf8').slice(0, 800);
|
|
179
|
+
parts.push(`--- ${name} ---\n${content}`);
|
|
180
|
+
} catch {}
|
|
181
|
+
if (parts.length >= 4) break;
|
|
182
|
+
}
|
|
183
|
+
return parts.join('\n\n') || '(none found)';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readSourceFiles(cwd: string): string {
|
|
187
|
+
const srcRoots = ['src', 'app', 'lib', 'packages', 'modules', 'server', 'api']
|
|
104
188
|
.map(d => path.join(cwd, d))
|
|
105
189
|
.filter(d => { try { return fs.statSync(d).isDirectory(); } catch { return false; } });
|
|
106
190
|
|
|
107
191
|
const files: string[] = [];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
192
|
+
|
|
193
|
+
// Prioritize key files: services, controllers, models, auth, guards, interceptors
|
|
194
|
+
const priority = /\.(service|controller|model|entity|repository|guard|middleware|interceptor|router|handler|resolver)\.(ts|js)$/i;
|
|
195
|
+
|
|
196
|
+
for (const root of srcRoots) {
|
|
197
|
+
collectSourceFiles(root, files, priority);
|
|
198
|
+
}
|
|
199
|
+
for (const root of srcRoots) {
|
|
200
|
+
collectSourceFiles(root, files, null);
|
|
201
|
+
if (files.length >= 40) break;
|
|
111
202
|
}
|
|
112
203
|
|
|
113
|
-
|
|
204
|
+
const unique = [...new Set(files)].slice(0, 40);
|
|
205
|
+
return unique.map(f => {
|
|
114
206
|
const rel = path.relative(cwd, f);
|
|
115
|
-
const content = fs.readFileSync(f, 'utf8').slice(0,
|
|
207
|
+
const content = fs.readFileSync(f, 'utf8').slice(0, 600);
|
|
116
208
|
return `--- ${rel} ---\n${content}`;
|
|
117
|
-
}).join('\n\n').slice(0,
|
|
209
|
+
}).join('\n\n').slice(0, 20000);
|
|
118
210
|
}
|
|
119
211
|
|
|
120
|
-
function collectSourceFiles(dir: string, out: string[], depth = 0): void {
|
|
121
|
-
if (depth >
|
|
212
|
+
function collectSourceFiles(dir: string, out: string[], filter: RegExp | null, depth = 0): void {
|
|
213
|
+
if (depth > 5 || out.length >= 40) return;
|
|
122
214
|
let entries: string[];
|
|
123
215
|
try { entries = fs.readdirSync(dir); } catch { return; }
|
|
124
216
|
|
|
125
217
|
for (const entry of entries) {
|
|
126
218
|
if (IGNORED.has(entry)) continue;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const stat = fs.statSync(full);
|
|
130
|
-
if (stat.isFile() && SOURCE_EXT.test(entry)) {
|
|
131
|
-
out.push(full);
|
|
132
|
-
} else if (stat.isDirectory()) {
|
|
133
|
-
collectSourceFiles(full, out, depth + 1);
|
|
134
|
-
}
|
|
135
|
-
} catch {}
|
|
136
|
-
if (out.length >= 12) break;
|
|
219
|
+
processEntry(path.join(dir, entry), entry, out, filter, depth);
|
|
220
|
+
if (out.length >= 40) break;
|
|
137
221
|
}
|
|
138
222
|
}
|
|
223
|
+
|
|
224
|
+
function processEntry(full: string, name: string, out: string[], filter: RegExp | null, depth: number): void {
|
|
225
|
+
try {
|
|
226
|
+
const stat = fs.statSync(full);
|
|
227
|
+
if (stat.isFile()) {
|
|
228
|
+
const matches = filter ? filter.test(name) : SOURCE_EXT.test(name);
|
|
229
|
+
if (matches && !out.includes(full)) out.push(full);
|
|
230
|
+
} else if (stat.isDirectory()) {
|
|
231
|
+
collectSourceFiles(full, out, filter, depth + 1);
|
|
232
|
+
}
|
|
233
|
+
} catch {}
|
|
234
|
+
}
|
package/src/git/github.ts
CHANGED
|
@@ -107,6 +107,26 @@ export class GithubClient {
|
|
|
107
107
|
if (!res.ok) await this.throwError(res, `merge PR #${prNumber}`);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
async createPR(
|
|
111
|
+
owner: string,
|
|
112
|
+
repo: string,
|
|
113
|
+
title: string,
|
|
114
|
+
body: string,
|
|
115
|
+
head: string,
|
|
116
|
+
base: string,
|
|
117
|
+
): Promise<{ number: number; url: string }> {
|
|
118
|
+
const url = `${GITHUB_API}/repos/${owner}/${repo}/pulls`;
|
|
119
|
+
const { default: fetch } = await import('node-fetch');
|
|
120
|
+
const res = await fetch(url, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { ...this.headers, 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({ title, body, head, base }),
|
|
124
|
+
}) as unknown as Response;
|
|
125
|
+
if (!res.ok) await this.throwError(res, 'create PR');
|
|
126
|
+
const data = await res.json() as any;
|
|
127
|
+
return { number: data.number, url: data.html_url };
|
|
128
|
+
}
|
|
129
|
+
|
|
110
130
|
async submitReview(
|
|
111
131
|
owner: string,
|
|
112
132
|
repo: string,
|
package/src/git/local.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import type { PRDiff, PRFile, PullRequest } from '../types';
|
|
3
|
+
|
|
4
|
+
export function getCurrentBranch(cwd: string): string {
|
|
5
|
+
return execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: 'pipe' }).toString().trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Always prefers origin/<branch> (remote = source of truth). Falls back to local if no remote.
|
|
9
|
+
function resolveRef(cwd: string, branch: string): string {
|
|
10
|
+
try {
|
|
11
|
+
execSync(`git rev-parse --verify origin/${branch}`, { cwd, stdio: 'pipe' });
|
|
12
|
+
return `origin/${branch}`;
|
|
13
|
+
} catch {
|
|
14
|
+
return branch;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function detectBaseBranch(cwd: string): string {
|
|
19
|
+
const head = getCurrentBranch(cwd);
|
|
20
|
+
for (const b of ['main', 'master', 'develop']) {
|
|
21
|
+
if (b === head) continue;
|
|
22
|
+
// Check remote first, then local
|
|
23
|
+
for (const ref of [`origin/${b}`, b]) {
|
|
24
|
+
try {
|
|
25
|
+
execSync(`git rev-parse --verify ${ref}`, { cwd, stdio: 'pipe' });
|
|
26
|
+
return b;
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return 'main';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function hasUnpushedCommits(cwd: string, branch: string): boolean {
|
|
34
|
+
try {
|
|
35
|
+
execSync(`git rev-parse --verify origin/${branch}`, { cwd, stdio: 'pipe' });
|
|
36
|
+
const out = execSync(`git log origin/${branch}..HEAD --oneline`, { cwd, stdio: 'pipe' })
|
|
37
|
+
.toString().trim();
|
|
38
|
+
return out.length > 0;
|
|
39
|
+
} catch {
|
|
40
|
+
// origin/<branch> doesn't exist — branch was never pushed
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getLastCommitTitle(cwd: string): string {
|
|
46
|
+
try {
|
|
47
|
+
return execSync('git log -1 --pretty=%s', { cwd, stdio: 'pipe' }).toString().trim();
|
|
48
|
+
} catch { return ''; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildLocalPRDiff(cwd: string, base: string): PRDiff {
|
|
52
|
+
const head = getCurrentBranch(cwd);
|
|
53
|
+
const ref = resolveRef(cwd, base);
|
|
54
|
+
|
|
55
|
+
const numstat = execSync(`git diff ${ref}...HEAD --numstat`, { cwd, stdio: 'pipe' })
|
|
56
|
+
.toString().trim();
|
|
57
|
+
const nameStatus = execSync(`git diff ${ref}...HEAD --name-status`, { cwd, stdio: 'pipe' })
|
|
58
|
+
.toString().trim();
|
|
59
|
+
const rawDiff = execSync(`git diff ${ref}...HEAD`, {
|
|
60
|
+
cwd, stdio: 'pipe', maxBuffer: 5 * 1024 * 1024,
|
|
61
|
+
}).toString();
|
|
62
|
+
|
|
63
|
+
const statsMap = new Map<string, { additions: number; deletions: number }>();
|
|
64
|
+
for (const line of numstat.split('\n').filter(Boolean)) {
|
|
65
|
+
const parts = line.split('\t');
|
|
66
|
+
if (parts.length >= 3) {
|
|
67
|
+
statsMap.set(parts[2], {
|
|
68
|
+
additions: Number.parseInt(parts[0]) || 0,
|
|
69
|
+
deletions: Number.parseInt(parts[1]) || 0,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const files: PRFile[] = [];
|
|
75
|
+
for (const line of nameStatus.split('\n').filter(Boolean)) {
|
|
76
|
+
const parts = line.split('\t');
|
|
77
|
+
if (parts.length < 2) continue;
|
|
78
|
+
const statusChar = parts[0][0];
|
|
79
|
+
const filename = parts[parts.length - 1];
|
|
80
|
+
const statusMap: Record<string, PRFile['status']> = {
|
|
81
|
+
A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'added',
|
|
82
|
+
};
|
|
83
|
+
const status = statusMap[statusChar] ?? 'modified';
|
|
84
|
+
const stats = statsMap.get(filename) ?? { additions: 0, deletions: 0 };
|
|
85
|
+
files.push({ filename, status, ...stats });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const log = (() => {
|
|
89
|
+
try {
|
|
90
|
+
return execSync(`git log ${ref}...HEAD --oneline`, { cwd, stdio: 'pipe' }).toString().trim();
|
|
91
|
+
} catch { return ''; }
|
|
92
|
+
})();
|
|
93
|
+
|
|
94
|
+
const pr: PullRequest = {
|
|
95
|
+
id: 0,
|
|
96
|
+
number: 0,
|
|
97
|
+
title: `${head} → ${base}`,
|
|
98
|
+
author: 'local',
|
|
99
|
+
branch: head,
|
|
100
|
+
base,
|
|
101
|
+
url: '',
|
|
102
|
+
state: 'draft',
|
|
103
|
+
createdAt: new Date().toISOString(),
|
|
104
|
+
updatedAt: new Date().toISOString(),
|
|
105
|
+
additions: files.reduce((s, f) => s + f.additions, 0),
|
|
106
|
+
deletions: files.reduce((s, f) => s + f.deletions, 0),
|
|
107
|
+
changedFiles: files.length,
|
|
108
|
+
labels: [],
|
|
109
|
+
description: log || undefined,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return { pr, files, rawDiff };
|
|
113
|
+
}
|
package/src/git/utils.ts
CHANGED
|
@@ -26,18 +26,27 @@ export function parseGithubUrl(url: string): RepoInfo | null {
|
|
|
26
26
|
return null;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const GITIGNORE_ENTRIES = [
|
|
30
|
+
'.aiv/tree.json', // auto-generated snapshot — large, not useful in diffs
|
|
31
|
+
'.aiv/checklist.md', // personal pre-PR draft — not team-relevant
|
|
32
|
+
];
|
|
33
|
+
|
|
29
34
|
export function appendGitignore(cwd: string): void {
|
|
30
35
|
const gitignorePath = path.join(cwd, '.gitignore');
|
|
31
|
-
const entry = '.aiv/';
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
fs.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
const current = fs.existsSync(gitignorePath)
|
|
38
|
+
? fs.readFileSync(gitignorePath, 'utf8')
|
|
39
|
+
: '';
|
|
40
|
+
|
|
41
|
+
const missing = GITIGNORE_ENTRIES.filter(e => !current.includes(e));
|
|
42
|
+
if (missing.length === 0) return;
|
|
43
|
+
|
|
44
|
+
const block = '\n# aiv — auto-generated / local-only files\n' + missing.join('\n') + '\n';
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
if (fs.existsSync(gitignorePath)) {
|
|
47
|
+
fs.appendFileSync(gitignorePath, block, 'utf8');
|
|
48
|
+
} else {
|
|
49
|
+
fs.writeFileSync(gitignorePath, block.trimStart(), 'utf8');
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
52
|
|
package/src/i18n/en.ts
CHANGED
|
@@ -42,7 +42,7 @@ export const en = {
|
|
|
42
42
|
prsColPR: 'PR',
|
|
43
43
|
prsColTitle: 'Title',
|
|
44
44
|
prsColAuthor: 'Author',
|
|
45
|
-
prsColBranch: 'Branch',
|
|
45
|
+
prsColBranch: 'Branch → Target',
|
|
46
46
|
prsColChanges: 'Changes',
|
|
47
47
|
prsColCreated: 'Created',
|
|
48
48
|
prsFooter: (count: number) => ` Showing ${count} open PR(s). Run aiv review <pr-number> to analyze.\n`,
|
|
@@ -108,6 +108,9 @@ export const en = {
|
|
|
108
108
|
accountsRepoHint: ' Repo config: .aiv/config.yml\n',
|
|
109
109
|
|
|
110
110
|
// ── orchestrator ───────────────────────────────────────────────────────────
|
|
111
|
+
orchestratorTriaging: 'Analyzing diff to select agents...',
|
|
112
|
+
orchestratorTriageResult: (agents: string, reason: string) => ` Agents selected: ${agents} — ${reason}`,
|
|
113
|
+
orchestratorTriageSkipped: 'No agents needed for this diff.',
|
|
111
114
|
orchestratorRunning: (agent: string) => ` Running ${agent} agent...`,
|
|
112
115
|
orchestratorDone: (agent: string, count: number, score: number) => ` ${agent} — ${count} finding(s) [score: ${score}]`,
|
|
113
116
|
orchestratorAgentFailed: (agent: string, msg: string) => ` ${agent} failed: ${msg}`,
|
|
@@ -201,6 +204,21 @@ export const en = {
|
|
|
201
204
|
contextGenerateWritten: (file: string) => ` ${file} written.`,
|
|
202
205
|
contextGenerateProviderError: (envVar: string) => `Missing AI key: ${envVar}. Check your provider config.`,
|
|
203
206
|
|
|
207
|
+
// ── precheck command ───────────────────────────────────────────────────────
|
|
208
|
+
precheckTitle: (branch: string, base: string) => `\n aiv check — ${branch} → ${base}\n`,
|
|
209
|
+
precheckNotGitRepo: 'Not a git repository.',
|
|
210
|
+
precheckBuilding: (base: string) => `Building diff against ${base}...`,
|
|
211
|
+
precheckDiffBuilt: (files: number) => `Diff ready: ${files} file(s) changed`,
|
|
212
|
+
precheckDiffFailed: (msg: string) => `Failed to build local diff: ${msg}`,
|
|
213
|
+
precheckNoChanges: 'No changes detected against base branch.',
|
|
214
|
+
precheckSelectAction: 'What would you like to do?',
|
|
215
|
+
precheckCreatingPR: 'Creating PR on GitHub...',
|
|
216
|
+
precheckPRCreated: (url: string) => ` PR created: ${url}`,
|
|
217
|
+
precheckPRFailed: (msg: string) => `Failed to create PR: ${msg}`,
|
|
218
|
+
precheckSavingChecklist: 'Saving checklist...',
|
|
219
|
+
precheckChecklistSaved: (path: string) => ` Checklist saved to ${path}`,
|
|
220
|
+
precheckMissingConfig: 'GitHub owner/repo not configured. Use --owner and --repo or configure in .aiv/config.yml',
|
|
221
|
+
|
|
204
222
|
// ── agents command ─────────────────────────────────────────────────────────
|
|
205
223
|
agentsTitle: '\n aiv — Available Agents\n',
|
|
206
224
|
agentsColAgent: 'Agent',
|
package/src/i18n/es.ts
CHANGED
|
@@ -44,7 +44,7 @@ export const es: TranslationKeys = {
|
|
|
44
44
|
prsColPR: 'PR',
|
|
45
45
|
prsColTitle: 'Título',
|
|
46
46
|
prsColAuthor: 'Autor',
|
|
47
|
-
prsColBranch: 'Rama',
|
|
47
|
+
prsColBranch: 'Rama → Destino',
|
|
48
48
|
prsColChanges: 'Cambios',
|
|
49
49
|
prsColCreated: 'Creado',
|
|
50
50
|
prsFooter: (count: number) => ` Mostrando ${count} PR(s) abierto(s). Ejecuta aiv review <numero-pr> para analizar.\n`,
|
|
@@ -110,6 +110,9 @@ export const es: TranslationKeys = {
|
|
|
110
110
|
accountsRepoHint: ' Config repo: .aiv/config.yml\n',
|
|
111
111
|
|
|
112
112
|
// ── orchestrator ───────────────────────────────────────────────────────────
|
|
113
|
+
orchestratorTriaging: 'Analizando diff para seleccionar agentes...',
|
|
114
|
+
orchestratorTriageResult: (agents: string, reason: string) => ` Agentes seleccionados: ${agents} — ${reason}`,
|
|
115
|
+
orchestratorTriageSkipped: 'No se necesitan agentes para este diff.',
|
|
113
116
|
orchestratorRunning: (agent: string) => ` Ejecutando agente ${agent}...`,
|
|
114
117
|
orchestratorDone: (agent: string, count: number, score: number) => ` ${agent} — ${count} hallazgo(s) [puntuación: ${score}]`,
|
|
115
118
|
orchestratorAgentFailed: (agent: string, msg: string) => ` ${agent} falló: ${msg}`,
|
|
@@ -203,6 +206,21 @@ export const es: TranslationKeys = {
|
|
|
203
206
|
contextGenerateWritten: (file: string) => ` ${file} escrito.`,
|
|
204
207
|
contextGenerateProviderError: (envVar: string) => `Clave de IA faltante: ${envVar}. Revisa tu config de proveedor.`,
|
|
205
208
|
|
|
209
|
+
// ── precheck command ───────────────────────────────────────────────────────
|
|
210
|
+
precheckTitle: (branch: string, base: string) => `\n aiv check — ${branch} → ${base}\n`,
|
|
211
|
+
precheckNotGitRepo: 'No es un repositorio git.',
|
|
212
|
+
precheckBuilding: (base: string) => `Construyendo diff contra ${base}...`,
|
|
213
|
+
precheckDiffBuilt: (files: number) => `Diff listo: ${files} archivo(s) modificado(s)`,
|
|
214
|
+
precheckDiffFailed: (msg: string) => `Error al construir el diff local: ${msg}`,
|
|
215
|
+
precheckNoChanges: 'No se detectaron cambios contra la rama base.',
|
|
216
|
+
precheckSelectAction: '¿Qué deseas hacer?',
|
|
217
|
+
precheckCreatingPR: 'Creando PR en GitHub...',
|
|
218
|
+
precheckPRCreated: (url: string) => ` PR creado: ${url}`,
|
|
219
|
+
precheckPRFailed: (msg: string) => `Error al crear el PR: ${msg}`,
|
|
220
|
+
precheckSavingChecklist: 'Guardando checklist...',
|
|
221
|
+
precheckChecklistSaved: (path: string) => ` Checklist guardado en ${path}`,
|
|
222
|
+
precheckMissingConfig: 'Owner/repo de GitHub no configurado. Usa --owner y --repo o configúralo en .aiv/config.yml',
|
|
223
|
+
|
|
206
224
|
// ── agents command ─────────────────────────────────────────────────────────
|
|
207
225
|
agentsTitle: '\n aiv — Agentes Disponibles\n',
|
|
208
226
|
agentsColAgent: 'Agente',
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { reviewCommand } from './cli/commands/review';
|
|
|
8
8
|
import { contextCommand } from './cli/commands/context';
|
|
9
9
|
import { configCommand } from './cli/commands/config';
|
|
10
10
|
import { agentsCommand } from './cli/commands/agents';
|
|
11
|
+
import { checkCommand } from './cli/commands/check';
|
|
11
12
|
|
|
12
13
|
initLang();
|
|
13
14
|
printBanner();
|
|
@@ -25,5 +26,6 @@ program.addCommand(reviewCommand());
|
|
|
25
26
|
program.addCommand(contextCommand());
|
|
26
27
|
program.addCommand(configCommand());
|
|
27
28
|
program.addCommand(agentsCommand());
|
|
29
|
+
program.addCommand(checkCommand());
|
|
28
30
|
|
|
29
31
|
program.parse(process.argv);
|
|
@@ -6,6 +6,7 @@ import { BusinessReviewer } from '../agents/business';
|
|
|
6
6
|
import { ArchitectureReviewer } from '../agents/architecture';
|
|
7
7
|
import { SecurityReviewer } from '../agents/security';
|
|
8
8
|
import { BaseAgent, AgentContext } from '../agents/base';
|
|
9
|
+
import { triageAgents } from '../agents/triage';
|
|
9
10
|
import { t } from '../i18n';
|
|
10
11
|
|
|
11
12
|
export class Orchestrator {
|
|
@@ -14,9 +15,16 @@ export class Orchestrator {
|
|
|
14
15
|
private readonly rules: AivRules,
|
|
15
16
|
) {}
|
|
16
17
|
|
|
17
|
-
async run(prDiff: PRDiff, projectContext: string,
|
|
18
|
-
const
|
|
18
|
+
async run(prDiff: PRDiff, projectContext: string, requestedAgents: string[], auto: boolean): Promise<ReviewResult> {
|
|
19
|
+
const agentNames = auto
|
|
20
|
+
? await this.triage(prDiff, requestedAgents)
|
|
21
|
+
: requestedAgents;
|
|
22
|
+
|
|
23
|
+
if (agentNames.length === 0) {
|
|
24
|
+
return buildEmptyResult(prDiff);
|
|
25
|
+
}
|
|
19
26
|
|
|
27
|
+
const agents = buildAgents(agentNames, this.config);
|
|
20
28
|
const agentCtx: AgentContext = { diff: prDiff, projectContext, rules: this.rules };
|
|
21
29
|
const agentResults = await runAgents(agents, agentCtx);
|
|
22
30
|
|
|
@@ -37,6 +45,44 @@ export class Orchestrator {
|
|
|
37
45
|
generatedAt: new Date().toISOString(),
|
|
38
46
|
};
|
|
39
47
|
}
|
|
48
|
+
|
|
49
|
+
private async triage(prDiff: PRDiff, requested: string[]): Promise<string[]> {
|
|
50
|
+
const spinner = ora(t().orchestratorTriaging).start();
|
|
51
|
+
try {
|
|
52
|
+
const provider = createProviderFor(this.config, 'triage');
|
|
53
|
+
const result = await triageAgents(prDiff, provider);
|
|
54
|
+
const selected = result.agents.filter(a => requested.includes(a));
|
|
55
|
+
|
|
56
|
+
if (selected.length === 0) {
|
|
57
|
+
spinner.succeed(chalk.dim(t().orchestratorTriageSkipped));
|
|
58
|
+
} else {
|
|
59
|
+
spinner.succeed(chalk.dim(t().orchestratorTriageResult(
|
|
60
|
+
selected.map(a => chalk.cyan(a)).join(', '),
|
|
61
|
+
result.reasoning,
|
|
62
|
+
)));
|
|
63
|
+
}
|
|
64
|
+
return selected;
|
|
65
|
+
} catch {
|
|
66
|
+
spinner.warn(chalk.yellow('Triage failed — running all agents'));
|
|
67
|
+
return requested;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildEmptyResult(prDiff: PRDiff): ReviewResult {
|
|
73
|
+
return {
|
|
74
|
+
prNumber: prDiff.pr.number,
|
|
75
|
+
prTitle: prDiff.pr.title,
|
|
76
|
+
executiveSummary: t().orchestratorTriageSkipped,
|
|
77
|
+
riskScore: 0,
|
|
78
|
+
riskLabel: 'LOW',
|
|
79
|
+
agents: [],
|
|
80
|
+
businessRisks: [],
|
|
81
|
+
architectureIssues: [],
|
|
82
|
+
securityIssues: [],
|
|
83
|
+
possibleRegressions: [],
|
|
84
|
+
generatedAt: new Date().toISOString(),
|
|
85
|
+
};
|
|
40
86
|
}
|
|
41
87
|
|
|
42
88
|
function buildAgents(names: string[], config: ResolvedConfig): BaseAgent[] {
|