codereview-aia 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.
Files changed (153) hide show
  1. package/.cr-aia.yml +23 -0
  2. package/.crignore +0 -0
  3. package/dist/index.js +27 -0
  4. package/package.json +85 -0
  5. package/src/analysis/FindingsExtractor.ts +431 -0
  6. package/src/analysis/ai-detection/analyzers/BaseAnalyzer.ts +267 -0
  7. package/src/analysis/ai-detection/analyzers/DocumentationAnalyzer.ts +622 -0
  8. package/src/analysis/ai-detection/analyzers/GitHistoryAnalyzer.ts +430 -0
  9. package/src/analysis/ai-detection/core/AIDetectionEngine.ts +467 -0
  10. package/src/analysis/ai-detection/types/DetectionTypes.ts +406 -0
  11. package/src/analysis/ai-detection/utils/SubmissionConverter.ts +390 -0
  12. package/src/analysis/context/ReviewContext.ts +378 -0
  13. package/src/analysis/context/index.ts +7 -0
  14. package/src/analysis/index.ts +8 -0
  15. package/src/analysis/tokens/TokenAnalysisFormatter.ts +154 -0
  16. package/src/analysis/tokens/TokenAnalyzer.ts +747 -0
  17. package/src/analysis/tokens/index.ts +8 -0
  18. package/src/clients/base/abstractClient.ts +190 -0
  19. package/src/clients/base/httpClient.ts +160 -0
  20. package/src/clients/base/index.ts +12 -0
  21. package/src/clients/base/modelDetection.ts +107 -0
  22. package/src/clients/base/responseProcessor.ts +586 -0
  23. package/src/clients/factory/clientFactory.ts +55 -0
  24. package/src/clients/factory/index.ts +8 -0
  25. package/src/clients/implementations/index.ts +8 -0
  26. package/src/clients/implementations/openRouterClient.ts +411 -0
  27. package/src/clients/openRouterClient.ts +863 -0
  28. package/src/clients/openRouterClientWrapper.ts +44 -0
  29. package/src/clients/utils/directoryStructure.ts +52 -0
  30. package/src/clients/utils/index.ts +11 -0
  31. package/src/clients/utils/languageDetection.ts +44 -0
  32. package/src/clients/utils/promptFormatter.ts +105 -0
  33. package/src/clients/utils/promptLoader.ts +53 -0
  34. package/src/clients/utils/tokenCounter.ts +297 -0
  35. package/src/core/ApiClientSelector.ts +37 -0
  36. package/src/core/ConfigurationService.ts +591 -0
  37. package/src/core/ConsolidationService.ts +423 -0
  38. package/src/core/InteractiveDisplayManager.ts +81 -0
  39. package/src/core/OutputManager.ts +275 -0
  40. package/src/core/ReviewGenerator.ts +140 -0
  41. package/src/core/fileDiscovery.ts +237 -0
  42. package/src/core/handlers/EstimationHandler.ts +104 -0
  43. package/src/core/handlers/FileProcessingHandler.ts +204 -0
  44. package/src/core/handlers/OutputHandler.ts +125 -0
  45. package/src/core/handlers/ReviewExecutor.ts +104 -0
  46. package/src/core/reviewOrchestrator.ts +333 -0
  47. package/src/core/utils/ModelInfoUtils.ts +56 -0
  48. package/src/formatters/outputFormatter.ts +62 -0
  49. package/src/formatters/utils/IssueFormatters.ts +83 -0
  50. package/src/formatters/utils/JsonFormatter.ts +77 -0
  51. package/src/formatters/utils/MarkdownFormatters.ts +609 -0
  52. package/src/formatters/utils/MetadataFormatter.ts +269 -0
  53. package/src/formatters/utils/ModelInfoExtractor.ts +115 -0
  54. package/src/index.ts +27 -0
  55. package/src/plugins/PluginInterface.ts +50 -0
  56. package/src/plugins/PluginManager.ts +126 -0
  57. package/src/prompts/PromptManager.ts +69 -0
  58. package/src/prompts/cache/PromptCache.ts +50 -0
  59. package/src/prompts/promptText/common/variables/css-frameworks.json +33 -0
  60. package/src/prompts/promptText/common/variables/framework-versions.json +45 -0
  61. package/src/prompts/promptText/frameworks/react/comprehensive.hbs +19 -0
  62. package/src/prompts/promptText/languages/css/comprehensive.hbs +18 -0
  63. package/src/prompts/promptText/languages/generic/comprehensive.hbs +20 -0
  64. package/src/prompts/promptText/languages/html/comprehensive.hbs +18 -0
  65. package/src/prompts/promptText/languages/javascript/comprehensive.hbs +18 -0
  66. package/src/prompts/promptText/languages/python/comprehensive.hbs +18 -0
  67. package/src/prompts/promptText/languages/typescript/comprehensive.hbs +18 -0
  68. package/src/runtime/auth/service.ts +58 -0
  69. package/src/runtime/auth/session.ts +103 -0
  70. package/src/runtime/auth/types.ts +11 -0
  71. package/src/runtime/cliEntry.ts +196 -0
  72. package/src/runtime/errors.ts +13 -0
  73. package/src/runtime/fileCollector.ts +188 -0
  74. package/src/runtime/manifest.ts +64 -0
  75. package/src/runtime/openrouterProxy.ts +45 -0
  76. package/src/runtime/proxyConfig.ts +94 -0
  77. package/src/runtime/proxyEnvironment.ts +71 -0
  78. package/src/runtime/reportMerge.ts +102 -0
  79. package/src/runtime/reporting/markdownReportBuilder.ts +138 -0
  80. package/src/runtime/reporting/reportDataCollector.ts +234 -0
  81. package/src/runtime/reporting/summaryGenerator.ts +86 -0
  82. package/src/runtime/reviewPipeline.ts +155 -0
  83. package/src/runtime/runAiCodeReview.ts +153 -0
  84. package/src/runtime/runtimeConfig.ts +5 -0
  85. package/src/runtime/ui/Layout.tsx +57 -0
  86. package/src/runtime/ui/RuntimeApp.tsx +150 -0
  87. package/src/runtime/ui/inkModules.ts +73 -0
  88. package/src/runtime/ui/screens/AuthScreen.tsx +128 -0
  89. package/src/runtime/ui/screens/ModeSelection.tsx +52 -0
  90. package/src/runtime/ui/screens/ProgressScreen.tsx +55 -0
  91. package/src/runtime/ui/screens/ResultsScreen.tsx +76 -0
  92. package/src/strategies/ArchitecturalReviewStrategy.ts +54 -0
  93. package/src/strategies/CodingTestReviewStrategy.ts +920 -0
  94. package/src/strategies/ConsolidatedReviewStrategy.ts +59 -0
  95. package/src/strategies/ExtractPatternsReviewStrategy.ts +64 -0
  96. package/src/strategies/MultiPassReviewStrategy.ts +785 -0
  97. package/src/strategies/ReviewStrategy.ts +64 -0
  98. package/src/strategies/StrategyFactory.ts +79 -0
  99. package/src/strategies/index.ts +14 -0
  100. package/src/tokenizers/baseTokenizer.ts +61 -0
  101. package/src/tokenizers/gptTokenizer.ts +27 -0
  102. package/src/tokenizers/index.ts +8 -0
  103. package/src/types/apiResponses.ts +40 -0
  104. package/src/types/cli.ts +24 -0
  105. package/src/types/common.ts +39 -0
  106. package/src/types/configuration.ts +201 -0
  107. package/src/types/handlebars.d.ts +5 -0
  108. package/src/types/patch.d.ts +25 -0
  109. package/src/types/review.ts +294 -0
  110. package/src/types/reviewContext.d.ts +65 -0
  111. package/src/types/reviewSchema.ts +181 -0
  112. package/src/types/structuredReview.ts +167 -0
  113. package/src/types/tokenAnalysis.ts +56 -0
  114. package/src/utils/FileReader.ts +93 -0
  115. package/src/utils/FileWriter.ts +76 -0
  116. package/src/utils/PathGenerator.ts +97 -0
  117. package/src/utils/api/apiUtils.ts +14 -0
  118. package/src/utils/api/index.ts +1 -0
  119. package/src/utils/apiErrorHandler.ts +287 -0
  120. package/src/utils/ciDataCollector.ts +252 -0
  121. package/src/utils/codingTestConfigLoader.ts +466 -0
  122. package/src/utils/dependencies/aiDependencyAnalyzer.ts +454 -0
  123. package/src/utils/detection/frameworkDetector.ts +879 -0
  124. package/src/utils/detection/index.ts +10 -0
  125. package/src/utils/detection/projectTypeDetector.ts +518 -0
  126. package/src/utils/diagramGenerator.ts +206 -0
  127. package/src/utils/errorLogger.ts +60 -0
  128. package/src/utils/estimationUtils.ts +407 -0
  129. package/src/utils/fileFilters.ts +373 -0
  130. package/src/utils/fileSystem.ts +57 -0
  131. package/src/utils/index.ts +36 -0
  132. package/src/utils/logger.ts +240 -0
  133. package/src/utils/pathValidator.ts +98 -0
  134. package/src/utils/priorityFilter.ts +59 -0
  135. package/src/utils/projectDocs.ts +189 -0
  136. package/src/utils/promptPaths.ts +29 -0
  137. package/src/utils/promptTemplateManager.ts +157 -0
  138. package/src/utils/review/consolidateReview.ts +553 -0
  139. package/src/utils/review/fixDisplay.ts +100 -0
  140. package/src/utils/review/fixImplementation.ts +61 -0
  141. package/src/utils/review/index.ts +36 -0
  142. package/src/utils/review/interactiveProcessing.ts +294 -0
  143. package/src/utils/review/progressTracker.ts +296 -0
  144. package/src/utils/review/reviewExtraction.ts +382 -0
  145. package/src/utils/review/types.ts +46 -0
  146. package/src/utils/reviewActionHandler.ts +18 -0
  147. package/src/utils/reviewParser.ts +253 -0
  148. package/src/utils/sanitizer.ts +238 -0
  149. package/src/utils/smartFileSelector.ts +255 -0
  150. package/src/utils/templateLoader.ts +514 -0
  151. package/src/utils/treeGenerator.ts +153 -0
  152. package/tsconfig.build.json +14 -0
  153. package/tsconfig.json +59 -0
@@ -0,0 +1,188 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { relative as relativePath, resolve as resolvePath, isAbsolute as isAbsolutePath, join as joinPath } from 'node:path';
3
+ import { execa } from 'execa';
4
+ import { MissingCrIgnoreError } from './errors';
5
+
6
+ export async function collectFiles(): Promise<string[]> {
7
+ try {
8
+ const { stdout: rootOut } = await execa('git', ['rev-parse', '--show-toplevel']);
9
+ const repoRoot = resolvePath(rootOut.trim());
10
+ const workspaceRoot = resolvePath(process.cwd());
11
+ const workspaceRelative = relativePath(repoRoot, workspaceRoot) || '.';
12
+
13
+ if (workspaceRelative.startsWith('..')) {
14
+ throw new Error('Workspace is outside of the current git repository.');
15
+ }
16
+
17
+ const crIgnorePath = joinPath(workspaceRoot, '.crignore');
18
+ if (!existsSync(crIgnorePath)) {
19
+ throw new MissingCrIgnoreError(workspaceRoot, crIgnorePath);
20
+ }
21
+
22
+ const isWithinWorkspace = (absolutePath: string): boolean => {
23
+ if (workspaceRoot === repoRoot) {
24
+ return true;
25
+ }
26
+
27
+ const rel = relativePath(workspaceRoot, absolutePath);
28
+ if (rel === '') {
29
+ return true;
30
+ }
31
+
32
+ return !rel.startsWith('..') && !isAbsolutePath(rel);
33
+ };
34
+
35
+ const normalize = (filePath: string | undefined | null): string | null => {
36
+ if (!filePath) {
37
+ return null;
38
+ }
39
+
40
+ if (filePath.startsWith('.git/') || filePath.includes('node_modules')) {
41
+ return null;
42
+ }
43
+
44
+ const absolutePath = resolvePath(repoRoot, filePath);
45
+ if (!isWithinWorkspace(absolutePath)) {
46
+ return null;
47
+ }
48
+
49
+ if (!existsSync(absolutePath)) {
50
+ return null;
51
+ }
52
+
53
+ const stats = statSync(absolutePath);
54
+ if (!stats.isFile()) {
55
+ return null;
56
+ }
57
+
58
+ return absolutePath;
59
+ };
60
+
61
+ const gitWorkspaceOptions = { cwd: workspaceRoot } as const;
62
+ const gitPathSpec = ['--', '.'] as const;
63
+
64
+ const { stdout: statusOut } = await execa('git', ['status', '--porcelain=1', '-z', ...gitPathSpec], gitWorkspaceOptions);
65
+ const { stdout: diffOut } = await execa('git', ['diff', '--name-only', '-z', ...gitPathSpec], gitWorkspaceOptions);
66
+
67
+ const statusEntries = statusOut.split('\0').filter(Boolean);
68
+ const statusFiles: string[] = [];
69
+
70
+ for (let i = 0; i < statusEntries.length; i += 1) {
71
+ const entry = statusEntries[i];
72
+ const statusPrefix = entry.slice(0, 3);
73
+ const pathPart = entry.slice(3).trim();
74
+
75
+ if (!pathPart) {
76
+ continue;
77
+ }
78
+
79
+ if (statusPrefix.trim().startsWith('R')) {
80
+ const renamedTarget = statusEntries[i + 1];
81
+ if (renamedTarget) {
82
+ statusFiles.push(renamedTarget);
83
+ i += 1;
84
+ continue;
85
+ }
86
+ }
87
+
88
+ statusFiles.push(pathPart);
89
+ }
90
+
91
+ const diffFiles = diffOut.split('\0').filter(Boolean);
92
+ const unique = new Set([...statusFiles, ...diffFiles]);
93
+
94
+ const resolved = Array.from(unique)
95
+ .map((file) => normalize(file))
96
+ .filter((file): file is string => Boolean(file));
97
+
98
+ const gitFiltered = await excludeIgnored(repoRoot, resolved);
99
+ return await excludeCrIgnored(workspaceRoot, crIgnorePath, gitFiltered);
100
+ } catch (error: any) {
101
+ if (error.message?.includes('not a git repository')) {
102
+ throw new Error('Not a git repository. Please run this command from a git repository root.');
103
+ }
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ async function excludeIgnored(repoRoot: string, files: string[]): Promise<string[]> {
109
+ if (files.length === 0) {
110
+ return [];
111
+ }
112
+
113
+ const relPaths = files.map((file) => {
114
+ const rel = relativePath(repoRoot, file);
115
+ return rel.replace(/\\/g, '/');
116
+ });
117
+
118
+ const { stdout, exitCode } = await execa('git', ['check-ignore', '--stdin'], {
119
+ cwd: repoRoot,
120
+ input: relPaths.join('\n'),
121
+ reject: false,
122
+ });
123
+
124
+ if (exitCode !== 0 && exitCode !== 1) {
125
+ throw new Error(`git check-ignore failed with code ${exitCode}`);
126
+ }
127
+
128
+ if (!stdout) {
129
+ return files;
130
+ }
131
+
132
+ const ignored = new Set(
133
+ stdout
134
+ .split('\n')
135
+ .map((line) => line.trim())
136
+ .filter(Boolean),
137
+ );
138
+
139
+ return files.filter((_, index) => !ignored.has(relPaths[index]));
140
+ }
141
+
142
+ async function excludeCrIgnored(
143
+ workspaceRoot: string,
144
+ ignoreFile: string,
145
+ files: string[],
146
+ ): Promise<string[]> {
147
+ if (files.length === 0) {
148
+ return [];
149
+ }
150
+
151
+ const entries = files
152
+ .map((file) => {
153
+ const rel = relativePath(workspaceRoot, file).replace(/\\/g, '/');
154
+ return { file, rel };
155
+ })
156
+ .filter((entry) => entry.rel && entry.rel !== '.crignore');
157
+
158
+ if (entries.length === 0) {
159
+ return [];
160
+ }
161
+
162
+ const { stdout, exitCode } = await execa(
163
+ 'git',
164
+ ['-c', `core.excludesFile=${ignoreFile}`, 'check-ignore', '--stdin'],
165
+ {
166
+ cwd: workspaceRoot,
167
+ input: entries.map((entry) => entry.rel).join('\n'),
168
+ reject: false,
169
+ },
170
+ );
171
+
172
+ if (exitCode !== 0 && exitCode !== 1) {
173
+ throw new Error(`git check-ignore (crignore) failed with code ${exitCode}`);
174
+ }
175
+
176
+ if (!stdout) {
177
+ return entries.map((entry) => entry.file);
178
+ }
179
+
180
+ const ignored = new Set(
181
+ stdout
182
+ .split('\n')
183
+ .map((line) => line.trim())
184
+ .filter(Boolean),
185
+ );
186
+
187
+ return entries.filter((entry) => !ignored.has(entry.rel)).map((entry) => entry.file);
188
+ }
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import yaml from 'js-yaml';
4
+
5
+ const DEFAULT_MANIFEST: Record<string, unknown> = {
6
+ rules: {
7
+ universal: {
8
+ secret_leaks: 'high',
9
+ todos_left: 'medium',
10
+ dead_code: 'medium',
11
+ license_headers: 'low',
12
+ },
13
+ ts: {
14
+ no_any: 'high',
15
+ prefer_readonly: 'medium',
16
+ exhaustiveness_in_switch: 'high',
17
+ },
18
+ py: {
19
+ type_hints_required: 'medium',
20
+ no_bare_except: 'high',
21
+ },
22
+ go: {
23
+ errcheck_required: 'high',
24
+ context_passthrough: 'medium',
25
+ },
26
+ },
27
+ policies: {
28
+ fail_on: 'critical',
29
+ },
30
+ outputs: {
31
+ report: 'json',
32
+ },
33
+ };
34
+
35
+ export function loadManifest(repoRoot: string): Record<string, unknown> {
36
+ const defaultManifest = clone(DEFAULT_MANIFEST);
37
+
38
+ const overridePaths = [join(repoRoot, 'cr-manifest.yml'), join(repoRoot, '.cr-aia.yml')];
39
+
40
+ for (const overridePath of overridePaths) {
41
+ if (existsSync(overridePath)) {
42
+ const override = yaml.load(readFileSync(overridePath, 'utf-8')) as Record<string, unknown>;
43
+ return deepMerge(defaultManifest, override);
44
+ }
45
+ }
46
+
47
+ return defaultManifest;
48
+ }
49
+
50
+ function deepMerge(defaultObj: Record<string, any>, overrideObj: Record<string, any>): Record<string, any> {
51
+ const result: Record<string, any> = { ...defaultObj };
52
+ for (const key of Object.keys(overrideObj)) {
53
+ if (typeof overrideObj[key] === 'object' && overrideObj[key] !== null && !Array.isArray(overrideObj[key])) {
54
+ result[key] = deepMerge((result[key] as Record<string, any>) || {}, overrideObj[key] as Record<string, any>);
55
+ } else {
56
+ result[key] = overrideObj[key];
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+
62
+ function clone<T>(value: T): T {
63
+ return JSON.parse(JSON.stringify(value));
64
+ }
@@ -0,0 +1,45 @@
1
+ const DEFAULT_PROXY_BASE_URL = 'https://cr.ai.enki.si';
2
+
3
+ export function getProxyBaseUrl(): string {
4
+ return process.env.CR_AIA_PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL;
5
+ }
6
+
7
+ export function resolveOpenRouterProxyUrl(): string {
8
+ const base = getProxyBaseUrl().replace(/\/$/, '');
9
+ if (base.endsWith('/openrouter-proxy')) {
10
+ return base;
11
+ }
12
+ if (base.endsWith('/proxy')) {
13
+ return `${base.replace(/\/proxy$/, '')}/openrouter-proxy`;
14
+ }
15
+ return `${base}/openrouter-proxy`;
16
+ }
17
+
18
+ export function buildOpenRouterProxyHeaders(): Record<string, string> {
19
+ const headers: Record<string, string> = {
20
+ 'Content-Type': 'application/json',
21
+ };
22
+
23
+ const sessionToken = process.env.CR_AIA_PROXY_SESSION_TOKEN;
24
+ if (!sessionToken) {
25
+ throw new Error(
26
+ 'Missing session token. Run `node dist/index.js`, sign in, or set CR_AIA_PROXY_SESSION_TOKEN before running reviews.',
27
+ );
28
+ }
29
+
30
+ headers.Authorization = `Bearer ${sessionToken}`;
31
+ headers['x-session-token'] = sessionToken;
32
+
33
+ return headers;
34
+ }
35
+
36
+ export function withProxyMetadata(payload: Record<string, unknown>): Record<string, unknown> {
37
+ const enriched = { ...payload };
38
+ if (process.env.CR_AIA_PROXY_HTTP_REFERER && enriched['http_referer'] === undefined) {
39
+ enriched['http_referer'] = process.env.CR_AIA_PROXY_HTTP_REFERER;
40
+ }
41
+ if (enriched['x_title'] === undefined) {
42
+ enriched['x_title'] = process.env.CR_AIA_PROXY_X_TITLE || 'cr-aia';
43
+ }
44
+ return enriched;
45
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import yaml from 'js-yaml';
4
+
5
+ export interface ProxySettings {
6
+ baseUrl: string;
7
+ httpReferer?: string;
8
+ xTitle?: string;
9
+ }
10
+
11
+ const CR_AIA_CONFIG_CANDIDATES = ['.cr-aia.yml'];
12
+ const DEFAULT_PROXY_BASE_URL = 'https://cr.ai.enki.si';
13
+
14
+ export function loadProxySettings(repoRoot: string): ProxySettings {
15
+ const config = loadCrAiaConfig(repoRoot);
16
+ const scopedConfig = config && typeof config === 'object' ? getScopedConfig(config) : undefined;
17
+
18
+ const envBase = sanitize(process.env.PROXY_BASE_URL);
19
+ const fileBase = getFirstDefined<string>(config, scopedConfig, ['proxy_base_url', 'proxyBaseUrl']);
20
+
21
+ const httpReferer =
22
+ sanitize(process.env.PROXY_HTTP_REFERER) ||
23
+ getFirstDefined<string>(config, scopedConfig, ['http_referer', 'httpReferer']);
24
+
25
+ const xTitle =
26
+ sanitize(process.env.PROXY_X_TITLE) ||
27
+ getFirstDefined<string>(config, scopedConfig, ['x_title', 'xTitle']);
28
+
29
+ const baseUrl = envBase || fileBase || DEFAULT_PROXY_BASE_URL;
30
+
31
+ const settings: ProxySettings = {
32
+ baseUrl,
33
+ };
34
+
35
+ if (httpReferer) settings.httpReferer = httpReferer;
36
+ if (xTitle) settings.xTitle = xTitle;
37
+ return settings;
38
+ }
39
+
40
+ function sanitize(value: string | undefined): string | undefined {
41
+ return value && value.trim() ? value.trim() : undefined;
42
+ }
43
+
44
+ function loadCrAiaConfig(repoRoot: string): any | undefined {
45
+ for (const candidate of CR_AIA_CONFIG_CANDIDATES) {
46
+ const filePath = join(repoRoot, candidate);
47
+ if (!existsSync(filePath)) continue;
48
+
49
+ try {
50
+ const raw = readFileSync(filePath, 'utf-8');
51
+ if (candidate.endsWith('.json')) {
52
+ return JSON.parse(raw);
53
+ }
54
+ return yaml.load(raw);
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ function getScopedConfig(config: Record<string, any>): Record<string, any> | undefined {
63
+ if (!config) return undefined;
64
+ if (typeof config.cr_aia === 'object') return config.cr_aia as Record<string, any>;
65
+ if (typeof config.crAia === 'object') return config.crAia as Record<string, any>;
66
+ if (typeof config['cr-aia'] === 'object') return config['cr-aia'] as Record<string, any>;
67
+ return undefined;
68
+ }
69
+
70
+ function getFirstDefined<T>(
71
+ rootConfig: Record<string, any> | undefined,
72
+ scopedConfig: Record<string, any> | undefined,
73
+ keys: string[],
74
+ ): T | undefined {
75
+ for (const key of keys) {
76
+ const scopedValue = scopedConfig ? getNested(scopedConfig, key) : undefined;
77
+ if (scopedValue !== undefined) return scopedValue as T;
78
+
79
+ const rootValue = rootConfig ? getNested(rootConfig, key) : undefined;
80
+ if (rootValue !== undefined) return rootValue as T;
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function getNested(obj: Record<string, any>, pathKey: string): any {
86
+ const segments = pathKey.split('.');
87
+ let current: any = obj;
88
+ for (const segment of segments) {
89
+ if (!current || typeof current !== 'object') return undefined;
90
+ if (!(segment in current)) return undefined;
91
+ current = current[segment];
92
+ }
93
+ return current;
94
+ }
@@ -0,0 +1,71 @@
1
+ import type { ProxySettings } from './proxyConfig';
2
+ import { loadProxySettings } from './proxyConfig';
3
+ import { getSessionToken } from './auth/session';
4
+
5
+ let cachedSettings: ProxySettings | null = null;
6
+
7
+ function proxySuppliesApiKey(settings: ProxySettings): boolean {
8
+ const override = process.env.CR_AIA_PROXY_SUPPLIES_API_KEY;
9
+ if (override && override.trim()) {
10
+ return override.trim().toLowerCase() !== 'false';
11
+ }
12
+ return settings.baseUrl.toLowerCase().includes('cr.ai.enki.si');
13
+ }
14
+
15
+ function ensureApiKeyPlaceholder(settings: ProxySettings): void {
16
+ if (!proxySuppliesApiKey(settings)) {
17
+ return;
18
+ }
19
+
20
+ const placeholder = process.env.CR_AIA_PROXY_API_KEY_PLACEHOLDER || 'provided-by-proxy';
21
+
22
+ if (!process.env.AI_CODE_REVIEW_OPENROUTER_API_KEY) {
23
+ process.env.AI_CODE_REVIEW_OPENROUTER_API_KEY = placeholder;
24
+ }
25
+
26
+ if (!process.env.OPENROUTER_API_KEY) {
27
+ process.env.OPENROUTER_API_KEY = placeholder;
28
+ }
29
+ }
30
+
31
+ function applyProxySettings(settings: ProxySettings): void {
32
+ process.env.CR_AIA_PROXY_BASE_URL = settings.baseUrl;
33
+
34
+ if (settings.httpReferer) {
35
+ process.env.CR_AIA_PROXY_HTTP_REFERER = settings.httpReferer;
36
+ } else {
37
+ delete process.env.CR_AIA_PROXY_HTTP_REFERER;
38
+ }
39
+
40
+ if (settings.xTitle) {
41
+ process.env.CR_AIA_PROXY_X_TITLE = settings.xTitle;
42
+ } else {
43
+ delete process.env.CR_AIA_PROXY_X_TITLE;
44
+ }
45
+
46
+ ensureApiKeyPlaceholder(settings);
47
+ ensureSessionToken();
48
+ }
49
+
50
+ function ensureSessionToken(): void {
51
+ if (process.env.CR_AIA_PROXY_SESSION_TOKEN && process.env.CR_AIA_PROXY_SESSION_TOKEN.trim()) {
52
+ return;
53
+ }
54
+
55
+ const token = getSessionToken();
56
+ if (token) {
57
+ process.env.CR_AIA_PROXY_SESSION_TOKEN = token;
58
+ }
59
+ }
60
+
61
+ export function ensureProxyEnvironmentInitialized(repoRoot: string): ProxySettings {
62
+ if (!cachedSettings) {
63
+ cachedSettings = loadProxySettings(repoRoot);
64
+ applyProxySettings(cachedSettings);
65
+ }
66
+ return cachedSettings;
67
+ }
68
+
69
+ export function getActiveProxySettings(): ProxySettings | null {
70
+ return cachedSettings;
71
+ }
@@ -0,0 +1,102 @@
1
+ import type { ReviewTotals } from './reviewPipeline';
2
+
3
+ export interface StructuredIssue {
4
+ priority?: string;
5
+ filePath?: string;
6
+ lineNumbers?: string;
7
+ type?: string;
8
+ description?: string;
9
+ suggestedFix?: string;
10
+ title?: string;
11
+ impact?: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export interface StructuredDataReport {
16
+ structuredData?: {
17
+ issues?: StructuredIssue[];
18
+ };
19
+ totals?: Record<string, number>;
20
+ metadata?: Record<string, unknown>;
21
+ costInfo?: Record<string, unknown>;
22
+ modelUsed?: string;
23
+ }
24
+
25
+ interface MergeResult {
26
+ findings: Record<string, unknown>[];
27
+ totals: ReviewTotals;
28
+ metadata?: Record<string, unknown>;
29
+ costInfo?: Record<string, unknown>;
30
+ modelUsed?: string;
31
+ }
32
+
33
+ export function mergeReports(reports: StructuredDataReport[]): MergeResult {
34
+ if (reports.length === 0) {
35
+ return { findings: [], totals: { critical: 0, high: 0, medium: 0, low: 0 } };
36
+ }
37
+
38
+ if (reports.length === 1) {
39
+ return normalizeReport(reports[0]);
40
+ }
41
+
42
+ const allFindings: Record<string, unknown>[] = [];
43
+ const totals: ReviewTotals = { critical: 0, high: 0, medium: 0, low: 0 };
44
+
45
+ for (const report of reports) {
46
+ const normalized = normalizeReport(report);
47
+ if (normalized.findings) {
48
+ allFindings.push(...(normalized.findings as Record<string, unknown>[]));
49
+ }
50
+ if (normalized.totals) {
51
+ totals.critical += normalized.totals.critical || 0;
52
+ totals.high += normalized.totals.high || 0;
53
+ totals.medium += normalized.totals.medium || 0;
54
+ totals.low += normalized.totals.low || 0;
55
+ }
56
+ }
57
+
58
+ return {
59
+ findings: allFindings,
60
+ totals,
61
+ metadata: {
62
+ mergedFrom: reports.length,
63
+ timestamp: new Date().toISOString(),
64
+ },
65
+ };
66
+ }
67
+
68
+ function normalizeReport(report: StructuredDataReport): MergeResult {
69
+ const findings: Record<string, unknown>[] = [];
70
+ const totals: ReviewTotals = { critical: 0, high: 0, medium: 0, low: 0 };
71
+
72
+ if (report.structuredData?.issues) {
73
+ for (const issue of report.structuredData.issues) {
74
+ const priority = issue.priority?.toLowerCase() || 'medium';
75
+ const finding = {
76
+ file: issue.filePath,
77
+ line: parseInt(issue.lineNumbers?.split('-')[0] || '0', 10),
78
+ rule: issue.type || 'general',
79
+ severity: priority,
80
+ msg: issue.description,
81
+ fix_hint: issue.suggestedFix,
82
+ title: issue.title,
83
+ impact: issue.impact,
84
+ raw: issue,
85
+ };
86
+ findings.push(finding);
87
+
88
+ if (priority === 'critical') totals.critical += 1;
89
+ else if (priority === 'high') totals.high += 1;
90
+ else if (priority === 'medium') totals.medium += 1;
91
+ else totals.low += 1;
92
+ }
93
+ }
94
+
95
+ return {
96
+ findings,
97
+ totals,
98
+ metadata: report.metadata,
99
+ costInfo: report.costInfo,
100
+ modelUsed: report.modelUsed,
101
+ };
102
+ }
@@ -0,0 +1,138 @@
1
+ import type { FileIssueGroup, TokenStats } from './reportDataCollector';
2
+ import type { ReviewTotals } from '../reviewPipeline';
3
+
4
+ export const SUMMARY_PLACEHOLDER = '<!--REPORT_SUMMARY_PLACEHOLDER-->';
5
+
6
+ export interface MarkdownReportContext {
7
+ repoName: string;
8
+ generatedAt: Date;
9
+ durationSeconds: number;
10
+ filesReviewed: number;
11
+ totals: ReviewTotals;
12
+ tokenStats?: TokenStats;
13
+ estimatedCostUSD?: number;
14
+ issueGroups: FileIssueGroup[];
15
+ }
16
+
17
+ function pad(num: number): string {
18
+ return num.toString().padStart(2, '0');
19
+ }
20
+
21
+ function formatEuropeanTimestamp(date: Date): string {
22
+ const day = pad(date.getDate());
23
+ const month = pad(date.getMonth() + 1);
24
+ const year = date.getFullYear();
25
+ const hours = pad(date.getHours());
26
+ const minutes = pad(date.getMinutes());
27
+ return `${day}.${month}.${year} ${hours}.${minutes}`;
28
+ }
29
+
30
+ function formatDuration(seconds: number): string {
31
+ const hrs = Math.floor(seconds / 3600);
32
+ const mins = Math.floor((seconds % 3600) / 60);
33
+ const secs = seconds % 60;
34
+ return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
35
+ }
36
+
37
+ function formatCost(amount?: number): string | undefined {
38
+ if (typeof amount !== 'number' || Number.isNaN(amount)) {
39
+ return undefined;
40
+ }
41
+ return `$${amount.toFixed(amount < 1 ? 4 : 2)} USD`;
42
+ }
43
+
44
+ function buildStatsSection(ctx: MarkdownReportContext): string {
45
+ const stats: string[] = ['### Stats'];
46
+
47
+ const cost = formatCost(ctx.estimatedCostUSD);
48
+ if (cost) {
49
+ stats.push(`- Spend: ${cost}`);
50
+ }
51
+
52
+ stats.push(`- Duration: ${formatDuration(ctx.durationSeconds)}`);
53
+ stats.push(`- Files reviewed: ${ctx.filesReviewed}`);
54
+
55
+ if (ctx.tokenStats) {
56
+ stats.push(
57
+ `- Total tokens: ${ctx.tokenStats.totalTokens.toLocaleString()} (input: ${ctx.tokenStats.inputTokens.toLocaleString()} / output: ${ctx.tokenStats.outputTokens.toLocaleString()})`,
58
+ );
59
+ }
60
+
61
+ stats.push(
62
+ `- Issues: Critical ${ctx.totals.critical} · High ${ctx.totals.high} · Medium ${ctx.totals.medium} · Low ${ctx.totals.low}`,
63
+ );
64
+
65
+ return stats.join('\n');
66
+ }
67
+
68
+ function formatIssueTitle(issue: FileIssueGroup['issues'][number]): string {
69
+ const capitalized = issue.severity.charAt(0).toUpperCase() + issue.severity.slice(1);
70
+ const linePart = issue.lineLabel ? ` (lines ${issue.lineLabel})` : '';
71
+ const title = issue.title || 'Issue';
72
+ return `##### [${capitalized}] ${title}${linePart}`;
73
+ }
74
+
75
+ function buildIssueBlock(group: FileIssueGroup): string {
76
+ const lines: string[] = [`#### ${group.filePath}`];
77
+
78
+ for (const issue of group.issues) {
79
+ lines.push(formatIssueTitle(issue));
80
+
81
+ if (issue.description) {
82
+ lines.push(`- **Problem:** ${issue.description.trim()}`);
83
+ }
84
+ if (issue.impact) {
85
+ lines.push(`- **Impact:** ${issue.impact.trim()}`);
86
+ }
87
+ if (issue.suggestedFix) {
88
+ lines.push(`- **Recommendation:** ${issue.suggestedFix.trim()}`);
89
+ }
90
+
91
+ lines.push('');
92
+ }
93
+
94
+ return lines.join('\n').trimEnd();
95
+ }
96
+
97
+ function buildIssuesSection(groups: FileIssueGroup[]): string {
98
+ if (groups.length === 0) {
99
+ return '### Issues\n_No actionable issues reported in this review._';
100
+ }
101
+
102
+ const blocks = groups.map((group) => buildIssueBlock(group));
103
+ return `### Issues\n\n${blocks.join('\n\n---\n\n')}`;
104
+ }
105
+
106
+ export function buildMarkdownReport(ctx: MarkdownReportContext): string {
107
+ const parts: string[] = [];
108
+ const timestamp = formatEuropeanTimestamp(ctx.generatedAt);
109
+
110
+ parts.push(`# Code Review Report — ${timestamp}`);
111
+ parts.push(`**Repository:** ${ctx.repoName}`);
112
+ parts.push('\n---\n');
113
+
114
+ parts.push(buildStatsSection(ctx));
115
+ parts.push('\n\n---\n');
116
+
117
+ parts.push('### Summary');
118
+ parts.push(SUMMARY_PLACEHOLDER);
119
+ parts.push('\n---\n');
120
+
121
+ parts.push(buildIssuesSection(ctx.issueGroups));
122
+ parts.push('\n\n---\n');
123
+ parts.push('_Generated by cr-aia_');
124
+
125
+ return parts.join('\n\n');
126
+ }
127
+
128
+ export function injectSummary(markdown: string, summary: string): string {
129
+ if (!markdown.includes(SUMMARY_PLACEHOLDER)) {
130
+ return markdown;
131
+ }
132
+
133
+ const formattedSummary = summary.trim() ? summary.trim() : '_Summary unavailable._';
134
+ return markdown.replace(
135
+ SUMMARY_PLACEHOLDER,
136
+ `>${formattedSummary.replace(/\n+/g, ' ')}`,
137
+ );
138
+ }