@ateriss_/aiv-cli 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,241 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import * as yaml from 'js-yaml';
5
+ import { GlobalConfig, RepoConfig, ResolvedConfig, AivRules, GithubAccount, CustomProviderConfig } from '../types';
6
+ import { t } from '../i18n';
7
+
8
+ // ─── Paths ─────────────────────────────────────────────────────────────────────
9
+
10
+ export function globalAivDir(): string {
11
+ return path.join(os.homedir(), '.aiv');
12
+ }
13
+
14
+ export function globalConfigPath(): string {
15
+ return path.join(globalAivDir(), 'config.yml');
16
+ }
17
+
18
+ export function getAivDir(cwd: string = process.cwd()): string {
19
+ return path.join(cwd, '.aiv');
20
+ }
21
+
22
+ export function configPath(cwd?: string): string {
23
+ return path.join(getAivDir(cwd), 'config.yml');
24
+ }
25
+
26
+ export function rulesPath(cwd?: string): string {
27
+ return path.join(getAivDir(cwd), 'rules.yml');
28
+ }
29
+
30
+ export function contextPath(cwd?: string): string {
31
+ return path.join(getAivDir(cwd), 'context.md');
32
+ }
33
+
34
+ export function treePath(cwd?: string): string {
35
+ return path.join(getAivDir(cwd), 'tree.json');
36
+ }
37
+
38
+ // ─── State checks ──────────────────────────────────────────────────────────────
39
+
40
+ export function isInitialized(cwd?: string): boolean {
41
+ return fs.existsSync(configPath(cwd));
42
+ }
43
+
44
+ export function isGlobalSetup(): boolean {
45
+ return fs.existsSync(globalConfigPath());
46
+ }
47
+
48
+ // ─── Global config (~/.aiv/config.yml) ────────────────────────────────────────
49
+
50
+ export function loadGlobalConfig(): GlobalConfig {
51
+ const file = globalConfigPath();
52
+ if (!fs.existsSync(file)) return { ...DEFAULT_GLOBAL_CONFIG };
53
+ const parsed = yaml.load(fs.readFileSync(file, 'utf8')) as GlobalConfig;
54
+ parsed.github ??= DEFAULT_GLOBAL_CONFIG.github;
55
+ parsed.github.accounts ??= {};
56
+ return parsed;
57
+ }
58
+
59
+ export function saveGlobalConfig(config: GlobalConfig): void {
60
+ const dir = globalAivDir();
61
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
62
+ fs.writeFileSync(globalConfigPath(), yaml.dump(config, { lineWidth: 120 }), 'utf8');
63
+ }
64
+
65
+ // ─── Repo config (.aiv/config.yml) ────────────────────────────────────────────
66
+
67
+ export function loadRepoConfig(cwd?: string): RepoConfig {
68
+ const file = configPath(cwd);
69
+ if (!fs.existsSync(file)) return {};
70
+ return yaml.load(fs.readFileSync(file, 'utf8')) as RepoConfig;
71
+ }
72
+
73
+ export function saveRepoConfig(config: RepoConfig, cwd?: string): void {
74
+ fs.writeFileSync(configPath(cwd), yaml.dump(config, { lineWidth: 120 }), 'utf8');
75
+ }
76
+
77
+ // ─── Rules (always per-repo) ───────────────────────────────────────────────────
78
+
79
+ export function loadRules(cwd?: string): AivRules {
80
+ const file = rulesPath(cwd);
81
+ if (!fs.existsSync(file)) return {};
82
+ return yaml.load(fs.readFileSync(file, 'utf8')) as AivRules;
83
+ }
84
+
85
+ export function saveRules(rules: AivRules, cwd?: string): void {
86
+ fs.writeFileSync(rulesPath(cwd), yaml.dump(rules, { lineWidth: 120 }), 'utf8');
87
+ }
88
+
89
+ // ─── Resolution ───────────────────────────────────────────────────────────────
90
+
91
+ export function resolveConfig(cwd?: string): ResolvedConfig {
92
+ const global = loadGlobalConfig();
93
+ const repo = loadRepoConfig(cwd);
94
+
95
+ const accountName = repo.github?.account ?? global.github.default_account ?? 'default';
96
+ const account: GithubAccount =
97
+ global.github.accounts[accountName] ??
98
+ global.github.accounts[global.github.default_account] ??
99
+ Object.values(global.github.accounts)[0] ??
100
+ { token_env: 'GITHUB_TOKEN' };
101
+
102
+ const globalReview = global.review ?? DEFAULT_GLOBAL_CONFIG.review!;
103
+
104
+ const gProviders = global.providers ?? DEFAULT_GLOBAL_CONFIG.providers;
105
+
106
+ return {
107
+ lang: global.lang ?? 'en',
108
+ providers: {
109
+ default: gProviders.default ?? 'claude',
110
+ fallback: gProviders.fallback ?? [],
111
+ agents: gProviders.agents ?? {},
112
+ },
113
+ claude: global.claude ?? DEFAULT_GLOBAL_CONFIG.claude!,
114
+ openai: global.openai ?? DEFAULT_GLOBAL_CONFIG.openai!,
115
+ gemini: global.gemini ?? DEFAULT_GLOBAL_CONFIG.gemini!,
116
+ custom_providers: global.custom_providers ?? {},
117
+ review: {
118
+ max_tokens: repo.review?.max_tokens ?? globalReview.max_tokens,
119
+ max_findings: repo.review?.max_findings ?? globalReview.max_findings,
120
+ },
121
+ github: {
122
+ accountName,
123
+ token_env: account.token_env,
124
+ username: account.username,
125
+ owner: repo.github?.owner,
126
+ repo: repo.github?.repo,
127
+ },
128
+ };
129
+ }
130
+
131
+ // ─── Token helper ─────────────────────────────────────────────────────────────
132
+
133
+ export function getGithubToken(config: ResolvedConfig): string {
134
+ const token = process.env[config.github.token_env];
135
+ if (!token) throw new Error(t().errorMissingToken(config.github.token_env, config.github.accountName));
136
+ return token;
137
+ }
138
+
139
+ // ─── Account management ───────────────────────────────────────────────��───────
140
+
141
+ export function addAccount(name: string, account: GithubAccount): void {
142
+ const config = loadGlobalConfig();
143
+ config.github.accounts[name] = account;
144
+ if (Object.keys(config.github.accounts).length === 1) {
145
+ config.github.default_account = name;
146
+ }
147
+ saveGlobalConfig(config);
148
+ }
149
+
150
+ export function removeAccount(name: string): void {
151
+ const config = loadGlobalConfig();
152
+ if (!(name in config.github.accounts)) throw new Error(t().errorAccountNotFound(name));
153
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
154
+ delete config.github.accounts[name];
155
+ if (config.github.default_account === name) {
156
+ config.github.default_account = Object.keys(config.github.accounts)[0] ?? '';
157
+ }
158
+ saveGlobalConfig(config);
159
+ }
160
+
161
+ export function setDefaultAccount(name: string): void {
162
+ const config = loadGlobalConfig();
163
+ if (!(name in config.github.accounts)) throw new Error(t().errorAccountNotFound(name));
164
+ config.github.default_account = name;
165
+ saveGlobalConfig(config);
166
+ }
167
+
168
+ export function setRepoAccount(name: string, cwd?: string): void {
169
+ const global = loadGlobalConfig();
170
+ if (!(name in global.github.accounts)) throw new Error(t().errorAccountNotFoundGlobal(name));
171
+ const repo = loadRepoConfig(cwd);
172
+ repo.github ??= {};
173
+ repo.github.account = name;
174
+ saveRepoConfig(repo, cwd);
175
+ }
176
+
177
+ export function listAccounts(): Array<{ name: string; account: GithubAccount; isDefault: boolean; hasToken: boolean }> {
178
+ const config = loadGlobalConfig();
179
+ return Object.entries(config.github.accounts).map(([name, account]) => ({
180
+ name,
181
+ account,
182
+ isDefault: name === config.github.default_account,
183
+ hasToken: Boolean(process.env[account.token_env]),
184
+ }));
185
+ }
186
+
187
+ // ─── Defaults ──────────────────────────────────────────────────────────────────
188
+
189
+ export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
190
+ lang: 'en',
191
+ providers: { default: 'claude' },
192
+ claude: { model: 'claude-sonnet-4-6', api_key_env: 'CLAUDE_API_KEY' },
193
+ openai: { model: 'gpt-4.1', api_key_env: 'OPENAI_API_KEY' },
194
+ gemini: { model: 'gemini-2.0-flash', api_key_env: 'GEMINI_API_KEY' },
195
+ custom_providers: {},
196
+ review: { max_tokens: 20000, max_findings: 20 },
197
+ github: {
198
+ default_account: 'default',
199
+ accounts: {
200
+ default: { token_env: 'GITHUB_TOKEN', description: 'Default GitHub account' },
201
+ },
202
+ },
203
+ };
204
+
205
+ // ─── Custom provider management ───────────────────────────────────────────────
206
+
207
+ export function addCustomProvider(name: string, cfg: CustomProviderConfig): void {
208
+ const config = loadGlobalConfig();
209
+ config.custom_providers ??= {};
210
+ config.custom_providers[name] = cfg;
211
+ saveGlobalConfig(config);
212
+ }
213
+
214
+ export function removeCustomProvider(name: string): void {
215
+ const config = loadGlobalConfig();
216
+ if (!config.custom_providers?.[name]) throw new Error(t().customProviderNotFound(name));
217
+ delete config.custom_providers[name];
218
+ saveGlobalConfig(config);
219
+ }
220
+
221
+ export function listCustomProviders(): Array<{ name: string; cfg: CustomProviderConfig; hasToken: boolean }> {
222
+ const config = loadGlobalConfig();
223
+ return Object.entries(config.custom_providers ?? {}).map(([name, cfg]) => ({
224
+ name,
225
+ cfg,
226
+ hasToken: Boolean(process.env[cfg.api_key_env]),
227
+ }));
228
+ }
229
+
230
+ export const DEFAULT_REPO_CONFIG: RepoConfig = {
231
+ github: {},
232
+ context: { provider: 'local' },
233
+ };
234
+
235
+ export const DEFAULT_RULES: AivRules = {
236
+ sensitive_modules: ['auth', 'payroll', 'payments', 'billing'],
237
+ business_rules: {
238
+ payroll: { required_calls: ['auditLog', 'validateTransaction'] },
239
+ auth: { required_checks: ['permissionValidation'] },
240
+ },
241
+ };
@@ -0,0 +1,199 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ const IGNORED = new Set([
5
+ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', 'coverage',
6
+ '.aiv', '.cache', 'vendor', '__pycache__', '.venv', 'venv',
7
+ ]);
8
+
9
+ const TECH_SIGNATURES: Array<[string, string]> = [
10
+ ['package.json', 'Node.js'],
11
+ ['tsconfig.json', 'TypeScript'],
12
+ ['requirements.txt', 'Python'],
13
+ ['Pipfile', 'Python (Pipenv)'],
14
+ ['pyproject.toml', 'Python (Poetry)'],
15
+ ['pom.xml', 'Java (Maven)'],
16
+ ['build.gradle', 'Java/Kotlin (Gradle)'],
17
+ ['Cargo.toml', 'Rust'],
18
+ ['go.mod', 'Go'],
19
+ ['composer.json', 'PHP (Composer)'],
20
+ ['Gemfile', 'Ruby'],
21
+ ['docker-compose.yml', 'Docker Compose'],
22
+ ['Dockerfile', 'Docker'],
23
+ ['terraform.tf', 'Terraform'],
24
+ ['.terraform', 'Terraform'],
25
+ ['kubernetes', 'Kubernetes'],
26
+ ['k8s', 'Kubernetes'],
27
+ ['prisma', 'Prisma ORM'],
28
+ ['drizzle.config', 'Drizzle ORM'],
29
+ ['sequelize', 'Sequelize ORM'],
30
+ ['typeorm', 'TypeORM'],
31
+ ['mongoose', 'MongoDB (Mongoose)'],
32
+ ['next.config', 'Next.js'],
33
+ ['nuxt.config', 'Nuxt.js'],
34
+ ['vite.config', 'Vite'],
35
+ ['webpack.config', 'Webpack'],
36
+ ['jest.config', 'Jest'],
37
+ ['vitest.config', 'Vitest'],
38
+ ];
39
+
40
+ const SENSITIVE_PATTERNS = [
41
+ 'auth', 'login', 'password', 'token', 'session', 'jwt', 'oauth',
42
+ 'payment', 'billing', 'invoice', 'stripe', 'paypal',
43
+ 'payroll', 'salary', 'compensation',
44
+ 'admin', 'permission', 'role', 'acl', 'rbac',
45
+ 'crypto', 'encrypt', 'decrypt', 'hash', 'secret',
46
+ 'migration', 'seed', 'database', 'schema',
47
+ ];
48
+
49
+ export async function buildContext(cwd: string): Promise<string> {
50
+ const name = path.basename(cwd);
51
+ const entries = fs.readdirSync(cwd);
52
+
53
+ const technologies = detectTechnologies(cwd, entries);
54
+ const modules = detectModules(cwd);
55
+ const sensitiveZones = detectSensitiveZones(cwd);
56
+ const dependencies = detectCriticalDeps(cwd);
57
+
58
+ return `# Project Context: ${name}
59
+
60
+ ## Architecture
61
+ ${inferArchitecture(cwd, entries, technologies)}
62
+
63
+ ## Modules
64
+ ${modules.map(m => `- ${m}`).join('\n') || '- (not detected)'}
65
+
66
+ ## Technologies
67
+ ${technologies.map(t => `- ${t}`).join('\n') || '- (not detected)'}
68
+
69
+ ## Critical Dependencies
70
+ ${dependencies.map(d => `- ${d}`).join('\n') || '- (not detected)'}
71
+
72
+ ## Sensitive Zones
73
+ ${sensitiveZones.map(z => `- ${z}`).join('\n') || '- (none detected)'}
74
+
75
+ ## Business Rules
76
+ (Edit this section to document key business rules for the AI reviewers)
77
+
78
+ ## System Summary
79
+ ${name} — ${technologies.slice(0, 3).join(', ')} project.
80
+ Auto-generated context. Edit ${cwd}/.aiv/context.md to add business-specific knowledge.
81
+ `;
82
+ }
83
+
84
+ const DEP_TECH_MAP: Array<[string[], string]> = [
85
+ [['react'], 'React'],
86
+ [['vue'], 'Vue.js'],
87
+ [['@angular/core'], 'Angular'],
88
+ [['express'], 'Express.js'],
89
+ [['fastify'], 'Fastify'],
90
+ [['nestjs', '@nestjs/core'], 'NestJS'],
91
+ [['graphql'], 'GraphQL'],
92
+ [['apollo-server', '@apollo/server'], 'Apollo Server'],
93
+ [['knex'], 'Knex.js'],
94
+ ];
95
+
96
+ function detectTechnologies(cwd: string, entries: string[]): string[] {
97
+ const found: string[] = [];
98
+
99
+ for (const [sig, tech] of TECH_SIGNATURES) {
100
+ if (entries.some(e => e.toLowerCase().startsWith(sig.toLowerCase()))) {
101
+ found.push(tech);
102
+ }
103
+ }
104
+
105
+ const pkgPath = path.join(cwd, 'package.json');
106
+ if (fs.existsSync(pkgPath)) {
107
+ try {
108
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as Record<string, unknown>;
109
+ const allDeps = { ...(pkg['dependencies'] as object), ...(pkg['devDependencies'] as object) } as Record<string, unknown>;
110
+ for (const [keys, tech] of DEP_TECH_MAP) {
111
+ if (keys.some(k => k in allDeps)) found.push(tech);
112
+ }
113
+ } catch { /* unreadable package.json — skip */ }
114
+ }
115
+
116
+ return [...new Set(found)];
117
+ }
118
+
119
+ function detectModules(cwd: string): string[] {
120
+ const srcPaths = ['src', 'app', 'lib', 'packages', 'modules'];
121
+ const modules: string[] = [];
122
+
123
+ for (const base of srcPaths) {
124
+ const p = path.join(cwd, base);
125
+ if (fs.existsSync(p) && fs.statSync(p).isDirectory()) {
126
+ const dirs = fs.readdirSync(p).filter(e => {
127
+ try { return fs.statSync(path.join(p, e)).isDirectory(); } catch { return false; }
128
+ });
129
+ dirs.forEach(d => modules.push(`${base}/${d}`));
130
+ }
131
+ }
132
+
133
+ return modules.slice(0, 20);
134
+ }
135
+
136
+ function detectSensitiveZones(cwd: string): string[] {
137
+ const zones: string[] = [];
138
+
139
+ function scan(dir: string, depth: number = 0): void {
140
+ if (depth > 3) return;
141
+ let entries: string[];
142
+ try { entries = fs.readdirSync(dir); } catch { return; }
143
+
144
+ for (const entry of entries) {
145
+ if (IGNORED.has(entry)) continue;
146
+ const lower = entry.toLowerCase();
147
+ for (const pattern of SENSITIVE_PATTERNS) {
148
+ if (lower.includes(pattern)) {
149
+ const rel = path.relative(cwd, path.join(dir, entry));
150
+ zones.push(rel);
151
+ break;
152
+ }
153
+ }
154
+ const full = path.join(dir, entry);
155
+ try {
156
+ if (fs.statSync(full).isDirectory()) scan(full, depth + 1);
157
+ } catch {}
158
+ }
159
+ }
160
+
161
+ scan(cwd);
162
+ return [...new Set(zones)].slice(0, 15);
163
+ }
164
+
165
+ function detectCriticalDeps(cwd: string): string[] {
166
+ const pkgPath = path.join(cwd, 'package.json');
167
+ if (!fs.existsSync(pkgPath)) return [];
168
+
169
+ try {
170
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
171
+ const all = { ...pkg.dependencies };
172
+ const critical = Object.keys(all).filter(dep => {
173
+ const d = dep.toLowerCase();
174
+ return d.includes('auth') || d.includes('jwt') || d.includes('passport') ||
175
+ d.includes('crypto') || d.includes('stripe') || d.includes('paypal') ||
176
+ d.includes('prisma') || d.includes('typeorm') || d.includes('sequelize') ||
177
+ d.includes('mongoose') || d.includes('knex') || d.includes('pg') ||
178
+ d.includes('mysql') || d.includes('redis') || d.includes('aws-sdk') ||
179
+ d.includes('@aws') || d.includes('firebase');
180
+ });
181
+ return critical.slice(0, 15);
182
+ } catch {
183
+ return [];
184
+ }
185
+ }
186
+
187
+ function inferArchitecture(cwd: string, entries: string[], technologies: string[]): string {
188
+ const hasSrc = entries.includes('src');
189
+ const hasPackages = entries.includes('packages');
190
+ const hasApps = entries.includes('apps');
191
+ const hasModules = entries.includes('modules');
192
+
193
+ if (hasPackages && hasApps) return 'Monorepo (apps/ + packages/ structure)';
194
+ if (hasModules) return 'Modular monolith (modules/ structure)';
195
+ if (hasSrc) return 'Standard source layout (src/)';
196
+ if (technologies.includes('NestJS')) return 'NestJS application (controllers/services/modules)';
197
+ if (technologies.includes('Next.js')) return 'Next.js application (pages/ or app/ router)';
198
+ return 'Standard project layout';
199
+ }
@@ -0,0 +1,138 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { LLMProvider } from '../providers/base';
4
+ import { treePath } from '../config';
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)$/;
8
+
9
+ export async function generateContextAndRules(
10
+ cwd: string,
11
+ provider: LLMProvider,
12
+ ): Promise<{ context: string; rules: string }> {
13
+ const name = path.basename(cwd);
14
+
15
+ const treeFile = treePath(cwd);
16
+ const treeData = fs.existsSync(treeFile)
17
+ ? fs.readFileSync(treeFile, 'utf8').slice(0, 6000)
18
+ : '(tree not available — run aiv context refresh first)';
19
+
20
+ const pkgInfo = readPackageJson(cwd);
21
+ const samples = readSampleFiles(cwd);
22
+
23
+ const userMessage = `
24
+ Project name: ${name}
25
+
26
+ package.json summary:
27
+ ${pkgInfo}
28
+
29
+ Project file tree (tree.json):
30
+ ${treeData}
31
+
32
+ Sample source files:
33
+ ${samples}
34
+
35
+ Generate context.md and rules.yml suitable for AI PR reviewers for this project.
36
+ Return ONLY valid JSON with exactly these two fields:
37
+ {
38
+ "context": "<full context.md content as a string>",
39
+ "rules": "<full rules.yml content as a string>"
40
+ }`;
41
+
42
+ const systemPrompt = `You are an expert software architect.
43
+ Given a project's structure, dependencies, and sample code, produce:
44
+
45
+ 1. context.md — Markdown file with these sections:
46
+ ## Architecture — overall pattern (monorepo, monolith, microservices, layers, etc.)
47
+ ## Modules — key modules or directories and their responsibilities
48
+ ## Technologies — main frameworks and libraries in use
49
+ ## Critical Dependencies — security-sensitive or infrastructure packages
50
+ ## Sensitive Zones — paths that require extra scrutiny (auth, payments, migrations, etc.)
51
+ ## Business Rules — inferred invariants the codebase enforces (edit this section after generation)
52
+ ## System Summary — one paragraph description
53
+
54
+ 2. rules.yml — YAML file with:
55
+ sensitive_modules: [list of folder/module names that are sensitive]
56
+ business_rules:
57
+ <module>:
58
+ required_calls: [functions that must always be called in this module]
59
+ required_checks: [validations that must be present]
60
+ forbidden_patterns: [patterns that must never appear]
61
+
62
+ Be specific. Infer real module names from the file tree. Do not invent generic placeholders.
63
+ Return ONLY the JSON object — no explanation, no markdown fences.`;
64
+
65
+ const response = await provider.complete(
66
+ [{ role: 'user', content: userMessage }],
67
+ systemPrompt,
68
+ 4000,
69
+ );
70
+
71
+ return parseResponse(response.content);
72
+ }
73
+
74
+ function parseResponse(raw: string): { context: string; rules: string } {
75
+ const jsonMatch = /\{[\s\S]*\}/.exec(raw.trim());
76
+ if (!jsonMatch) throw new Error('AI returned no JSON');
77
+
78
+ try {
79
+ const parsed = JSON.parse(jsonMatch[0]) as { context?: string; rules?: string };
80
+ if (!parsed.context || !parsed.rules) throw new Error('missing fields');
81
+ return { context: parsed.context, rules: parsed.rules };
82
+ } catch {
83
+ return {
84
+ context: raw,
85
+ rules: 'sensitive_modules: []\nbusiness_rules: {}\n',
86
+ };
87
+ }
88
+ }
89
+
90
+ function readPackageJson(cwd: string): string {
91
+ const file = path.join(cwd, 'package.json');
92
+ if (!fs.existsSync(file)) return '(not found)';
93
+ try {
94
+ const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
95
+ const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).slice(0, 30);
96
+ return `name: ${pkg.name ?? '?'}\nscripts: ${Object.keys(pkg.scripts ?? {}).join(', ')}\ndependencies: ${deps.join(', ')}`;
97
+ } catch {
98
+ return '(unreadable)';
99
+ }
100
+ }
101
+
102
+ function readSampleFiles(cwd: string): string {
103
+ const srcRoots = ['src', 'app', 'lib', 'packages', 'modules']
104
+ .map(d => path.join(cwd, d))
105
+ .filter(d => { try { return fs.statSync(d).isDirectory(); } catch { return false; } });
106
+
107
+ const files: string[] = [];
108
+ for (const root of srcRoots.slice(0, 2)) {
109
+ collectSourceFiles(root, files);
110
+ if (files.length >= 12) break;
111
+ }
112
+
113
+ return files.slice(0, 12).map(f => {
114
+ const rel = path.relative(cwd, f);
115
+ const content = fs.readFileSync(f, 'utf8').slice(0, 400);
116
+ return `--- ${rel} ---\n${content}`;
117
+ }).join('\n\n').slice(0, 7000);
118
+ }
119
+
120
+ function collectSourceFiles(dir: string, out: string[], depth = 0): void {
121
+ if (depth > 3 || out.length >= 12) return;
122
+ let entries: string[];
123
+ try { entries = fs.readdirSync(dir); } catch { return; }
124
+
125
+ for (const entry of entries) {
126
+ if (IGNORED.has(entry)) continue;
127
+ const full = path.join(dir, entry);
128
+ try {
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;
137
+ }
138
+ }
@@ -0,0 +1,83 @@
1
+ import * as fs from 'node:fs';
2
+ import { contextPath, treePath, isInitialized } from '../config';
3
+ import { buildContext } from './builder';
4
+ import { buildTree } from './tree';
5
+ import { PRDiff } from '../types';
6
+
7
+ export async function refreshContextFiles(cwd: string): Promise<{ treeOk: boolean; contextOk: boolean }> {
8
+ let treeOk = false;
9
+ let contextOk = false;
10
+
11
+ try {
12
+ const tree = await buildTree(cwd);
13
+ fs.writeFileSync(treePath(cwd), JSON.stringify(tree, null, 2), 'utf8');
14
+ treeOk = true;
15
+ } catch {}
16
+
17
+ try {
18
+ const context = await buildContext(cwd);
19
+ fs.writeFileSync(contextPath(cwd), context, 'utf8');
20
+ contextOk = true;
21
+ } catch {}
22
+
23
+ return { treeOk, contextOk };
24
+ }
25
+
26
+ export class ContextManager {
27
+ private readonly cwd: string;
28
+
29
+ constructor(cwd: string = process.cwd()) {
30
+ this.cwd = cwd;
31
+ }
32
+
33
+ readContext(): string {
34
+ if (!isInitialized(this.cwd)) return '';
35
+ const file = contextPath(this.cwd);
36
+ if (!fs.existsSync(file)) return '';
37
+ return fs.readFileSync(file, 'utf8');
38
+ }
39
+
40
+ readTree(): object | null {
41
+ const file = treePath(this.cwd);
42
+ if (!fs.existsSync(file)) return null;
43
+ try {
44
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ buildReviewContext(prDiff: PRDiff): string {
51
+ const context = this.readContext();
52
+
53
+ // Filter tree to only nodes relevant to changed files
54
+ const tree = this.readTree();
55
+ const changedPaths = new Set(prDiff.files.map(f => f.filename));
56
+ const relevantTree = tree ? summarizeRelevantTree(tree, changedPaths) : '';
57
+
58
+ return [
59
+ context,
60
+ relevantTree ? `\n## Relevant Project Tree\n\`\`\`json\n${relevantTree}\n\`\`\`` : '',
61
+ ].filter(Boolean).join('\n');
62
+ }
63
+ }
64
+
65
+ function summarizeRelevantTree(tree: object, changedPaths: Set<string>): string {
66
+ // Extract top-level structure only, to keep token count low
67
+ const topLevel = (tree as any).children ?? [];
68
+ const relevant = topLevel
69
+ .filter((node: any) => {
70
+ const nodePath = node.path ?? '';
71
+ return Array.from(changedPaths).some(p => p.startsWith(nodePath)) ||
72
+ node.sensitivity === 'high';
73
+ })
74
+ .map((node: any) => ({
75
+ path: node.path,
76
+ type: node.type,
77
+ module_type: node.module_type,
78
+ sensitivity: node.sensitivity,
79
+ }));
80
+
81
+ if (relevant.length === 0) return '';
82
+ return JSON.stringify(relevant, null, 2);
83
+ }