@in-the-loop-labs/pair-review 1.0.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.
- package/LICENSE +674 -0
- package/README.md +371 -0
- package/bin/git-diff-lines +146 -0
- package/bin/pair-review.js +49 -0
- package/package.json +71 -0
- package/public/css/ai-summary-modal.css +183 -0
- package/public/css/pr.css +8698 -0
- package/public/css/repo-settings.css +891 -0
- package/public/css/styles.css +479 -0
- package/public/favicon.png +0 -0
- package/public/index.html +1104 -0
- package/public/js/components/AIPanel.js +1639 -0
- package/public/js/components/AISummaryModal.js +278 -0
- package/public/js/components/AnalysisConfigModal.js +684 -0
- package/public/js/components/ConfirmDialog.js +227 -0
- package/public/js/components/PreviewModal.js +344 -0
- package/public/js/components/ProgressModal.js +678 -0
- package/public/js/components/ReviewModal.js +531 -0
- package/public/js/components/SplitButton.js +382 -0
- package/public/js/components/StatusIndicator.js +265 -0
- package/public/js/components/SuggestionNavigator.js +489 -0
- package/public/js/components/Toast.js +166 -0
- package/public/js/local.js +1580 -0
- package/public/js/modules/analysis-history.js +940 -0
- package/public/js/modules/comment-manager.js +643 -0
- package/public/js/modules/diff-renderer.js +585 -0
- package/public/js/modules/file-comment-manager.js +1242 -0
- package/public/js/modules/gap-coordinates.js +190 -0
- package/public/js/modules/hunk-parser.js +358 -0
- package/public/js/modules/line-tracker.js +386 -0
- package/public/js/modules/panel-resizer.js +228 -0
- package/public/js/modules/storage-cleanup.js +36 -0
- package/public/js/modules/suggestion-manager.js +692 -0
- package/public/js/pr.js +3503 -0
- package/public/js/repo-settings.js +691 -0
- package/public/js/utils/file-order.js +87 -0
- package/public/js/utils/markdown.js +97 -0
- package/public/js/utils/suggestion-ui.js +55 -0
- package/public/js/utils/tier-icons.js +25 -0
- package/public/local.html +460 -0
- package/public/pr.html +329 -0
- package/public/repo-settings.html +243 -0
- package/src/ai/analyzer.js +2592 -0
- package/src/ai/claude-cli.js +153 -0
- package/src/ai/claude-provider.js +261 -0
- package/src/ai/codex-provider.js +361 -0
- package/src/ai/copilot-provider.js +345 -0
- package/src/ai/gemini-provider.js +375 -0
- package/src/ai/index.js +47 -0
- package/src/ai/prompts/baseline/_meta.json +14 -0
- package/src/ai/prompts/baseline/level1/balanced.js +239 -0
- package/src/ai/prompts/baseline/level1/fast.js +194 -0
- package/src/ai/prompts/baseline/level1/thorough.js +319 -0
- package/src/ai/prompts/baseline/level2/balanced.js +248 -0
- package/src/ai/prompts/baseline/level2/fast.js +201 -0
- package/src/ai/prompts/baseline/level2/thorough.js +367 -0
- package/src/ai/prompts/baseline/level3/balanced.js +280 -0
- package/src/ai/prompts/baseline/level3/fast.js +220 -0
- package/src/ai/prompts/baseline/level3/thorough.js +459 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
- package/src/ai/prompts/config.js +52 -0
- package/src/ai/prompts/index.js +267 -0
- package/src/ai/prompts/shared/diff-instructions.js +50 -0
- package/src/ai/prompts/shared/output-schema.js +179 -0
- package/src/ai/prompts/shared/valid-files.js +37 -0
- package/src/ai/provider.js +260 -0
- package/src/config.js +139 -0
- package/src/database.js +2284 -0
- package/src/git/gitattributes.js +207 -0
- package/src/git/worktree.js +688 -0
- package/src/github/client.js +893 -0
- package/src/github/parser.js +247 -0
- package/src/local-review.js +691 -0
- package/src/main.js +987 -0
- package/src/routes/analysis.js +897 -0
- package/src/routes/comments.js +534 -0
- package/src/routes/config.js +250 -0
- package/src/routes/local.js +1728 -0
- package/src/routes/pr.js +1164 -0
- package/src/routes/shared.js +218 -0
- package/src/routes/worktrees.js +500 -0
- package/src/server.js +295 -0
- package/src/utils/diff-annotator.js +414 -0
- package/src/utils/instructions.js +33 -0
- package/src/utils/json-extractor.js +107 -0
- package/src/utils/line-validation.js +183 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/paths.js +161 -0
- package/src/utils/stats-calculator.js +86 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Instructions Utility
|
|
4
|
+
*
|
|
5
|
+
* Shared utilities for handling custom instructions merging across analysis modes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Merge repository and request instructions with XML-like tags for AI clarity
|
|
10
|
+
* Server is the single source of truth for how instructions are merged.
|
|
11
|
+
*
|
|
12
|
+
* @param {string|null} repoInstructions - Default instructions from repository settings
|
|
13
|
+
* @param {string|null} requestInstructions - Custom instructions from the analysis request
|
|
14
|
+
* @returns {string|null} Merged instructions with XML tags, or null if both inputs are empty
|
|
15
|
+
*/
|
|
16
|
+
function mergeInstructions(repoInstructions, requestInstructions) {
|
|
17
|
+
if (!repoInstructions && !requestInstructions) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const parts = [];
|
|
22
|
+
if (repoInstructions) {
|
|
23
|
+
parts.push(`These are default instructions for this repository:\n<repo_instructions>\n${repoInstructions}\n</repo_instructions>`);
|
|
24
|
+
}
|
|
25
|
+
if (requestInstructions) {
|
|
26
|
+
parts.push(`These are custom instructions for this analysis run. The following instructions take precedence over the repo_instructions in areas where they overlap or conflict:\n<custom_instructions>\n${requestInstructions}\n</custom_instructions>`);
|
|
27
|
+
}
|
|
28
|
+
return parts.join('\n\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = {
|
|
32
|
+
mergeInstructions
|
|
33
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
const logger = require('./logger');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract JSON from text responses using multiple strategies
|
|
6
|
+
* This is a shared utility to ensure consistent JSON extraction across the application
|
|
7
|
+
* @param {string} response - Raw response text
|
|
8
|
+
* @param {string|number} level - Level identifier for logging (e.g., 1, 2, 3, 'orchestration', 'unknown')
|
|
9
|
+
* @returns {Object} Extraction result with success flag and data/error
|
|
10
|
+
*/
|
|
11
|
+
function extractJSON(response, level = 'unknown') {
|
|
12
|
+
const levelPrefix = `[Level ${level}]`;
|
|
13
|
+
|
|
14
|
+
if (!response || !response.trim()) {
|
|
15
|
+
return { success: false, error: 'Empty response' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const strategies = [
|
|
19
|
+
// Strategy 1: Look for markdown code blocks with 'json' label
|
|
20
|
+
() => {
|
|
21
|
+
// First, try to find ```json specifically (more precise)
|
|
22
|
+
let codeBlockMatch = response.match(/```json\s*\n([\s\S]*?)\n```/);
|
|
23
|
+
|
|
24
|
+
// If not found, try generic ``` blocks
|
|
25
|
+
if (!codeBlockMatch) {
|
|
26
|
+
codeBlockMatch = response.match(/```\s*\n([\s\S]*?)\n```/);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (codeBlockMatch && codeBlockMatch[1]) {
|
|
30
|
+
const content = codeBlockMatch[1].trim();
|
|
31
|
+
// Verify it looks like JSON before parsing
|
|
32
|
+
if (content.startsWith('{') && content.endsWith('}')) {
|
|
33
|
+
return JSON.parse(content);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
throw new Error('No JSON code block found');
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// Strategy 2: Look for JSON between first { and last }
|
|
40
|
+
() => {
|
|
41
|
+
const firstBrace = response.indexOf('{');
|
|
42
|
+
const lastBrace = response.lastIndexOf('}');
|
|
43
|
+
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace >= firstBrace) {
|
|
44
|
+
return JSON.parse(response.substring(firstBrace, lastBrace + 1));
|
|
45
|
+
}
|
|
46
|
+
throw new Error('No valid JSON braces found');
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// Strategy 3: Try to find JSON-like structure with bracket matching
|
|
50
|
+
() => {
|
|
51
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
52
|
+
if (jsonMatch) {
|
|
53
|
+
// Try to find the complete JSON by matching brackets
|
|
54
|
+
const jsonStr = jsonMatch[0];
|
|
55
|
+
let braceCount = 0;
|
|
56
|
+
let endIndex = -1;
|
|
57
|
+
const maxIterations = Math.min(jsonStr.length, 100000); // Prevent infinite loops
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
60
|
+
if (jsonStr[i] === '{') braceCount++;
|
|
61
|
+
else if (jsonStr[i] === '}') {
|
|
62
|
+
braceCount--;
|
|
63
|
+
if (braceCount === 0) {
|
|
64
|
+
endIndex = i;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (endIndex > -1) {
|
|
71
|
+
return JSON.parse(jsonStr.substring(0, endIndex + 1));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
throw new Error('No balanced JSON structure found');
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// Strategy 4: Try the entire response as JSON (for simple cases)
|
|
78
|
+
() => {
|
|
79
|
+
return JSON.parse(response.trim());
|
|
80
|
+
}
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < strategies.length; i++) {
|
|
84
|
+
try {
|
|
85
|
+
const data = strategies[i]();
|
|
86
|
+
if (data && typeof data === 'object') {
|
|
87
|
+
logger.info(`${levelPrefix} JSON extraction successful using strategy ${i + 1}`);
|
|
88
|
+
return { success: true, data };
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// Continue to next strategy
|
|
92
|
+
if (i === strategies.length - 1) {
|
|
93
|
+
// Last strategy failed, log the error
|
|
94
|
+
logger.warn(`${levelPrefix} All JSON extraction strategies failed`);
|
|
95
|
+
logger.warn(`${levelPrefix} Response preview: ${response.substring(0, 200)}...`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
success: false,
|
|
102
|
+
error: 'Failed to extract JSON from response',
|
|
103
|
+
response: response.substring(0, 500) // Include preview for debugging
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { extractJSON };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
const logger = require('./logger');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if a file is binary using the `file` command
|
|
9
|
+
* @param {string} fullPath - Full path to the file
|
|
10
|
+
* @returns {boolean} True if file is binary
|
|
11
|
+
*/
|
|
12
|
+
function isBinaryFile(fullPath) {
|
|
13
|
+
try {
|
|
14
|
+
// First check if file is empty - empty files are reported as "binary" by the file command
|
|
15
|
+
// but we want to treat them as text files (with 0 lines)
|
|
16
|
+
const stats = fs.statSync(fullPath);
|
|
17
|
+
if (stats.size === 0) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Use 'file --mime-encoding' to detect encoding
|
|
22
|
+
// Binary files will show "binary" in the output
|
|
23
|
+
const result = execSync(`file --mime-encoding "${fullPath}"`, {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
|
|
26
|
+
});
|
|
27
|
+
return result.includes('binary');
|
|
28
|
+
} catch {
|
|
29
|
+
// If command fails, fall back to assuming not binary
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a map of file paths to their line counts
|
|
36
|
+
* @param {string} worktreePath - Path to the git worktree
|
|
37
|
+
* @param {Array<string>} validFiles - List of changed file paths
|
|
38
|
+
* @returns {Promise<Map<string, number>>} Map of filePath -> lineCount
|
|
39
|
+
*/
|
|
40
|
+
async function buildFileLineCountMap(worktreePath, validFiles) {
|
|
41
|
+
if (!validFiles || !Array.isArray(validFiles) || validFiles.length === 0) {
|
|
42
|
+
return new Map();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Read all files in parallel
|
|
46
|
+
const results = await Promise.all(validFiles.map(async (filePath) => {
|
|
47
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fullPath = path.join(worktreePath, filePath);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// Skip binary files - can't meaningfully count their "lines"
|
|
55
|
+
if (isBinaryFile(fullPath)) {
|
|
56
|
+
return { filePath, lineCount: -1 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const content = await fs.promises.readFile(fullPath, 'utf-8');
|
|
60
|
+
// Count lines by splitting on newlines
|
|
61
|
+
// Empty file has 0 lines, file with "a" has 1 line, file with "a\n" has 1 line,
|
|
62
|
+
// file with "a\nb" has 2 lines
|
|
63
|
+
let lineCount;
|
|
64
|
+
if (content.length === 0) {
|
|
65
|
+
// Empty file has 0 lines
|
|
66
|
+
lineCount = 0;
|
|
67
|
+
} else {
|
|
68
|
+
const lines = content.split('\n');
|
|
69
|
+
// If file ends with newline, last element is empty string - don't count it as a line
|
|
70
|
+
lineCount = content.endsWith('\n') && lines.length > 0
|
|
71
|
+
? lines.length - 1
|
|
72
|
+
: lines.length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { filePath, lineCount };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// File doesn't exist or can't be read - mark as -1
|
|
78
|
+
return { filePath, lineCount: -1 };
|
|
79
|
+
}
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// Build the map from results
|
|
83
|
+
const fileLineCountMap = new Map();
|
|
84
|
+
for (const result of results) {
|
|
85
|
+
if (result !== null) {
|
|
86
|
+
fileLineCountMap.set(result.filePath, result.lineCount);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return fileLineCountMap;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate suggestion line numbers against file lengths
|
|
95
|
+
* @param {Array} suggestions - Array of suggestion objects with file, line_start, line_end
|
|
96
|
+
* @param {Map<string, number>} fileLineCountMap - Map of file paths to line counts
|
|
97
|
+
* @param {Object} options - { convertToFileLevel: boolean }
|
|
98
|
+
* @returns {Object} { valid: [], converted: [], dropped: [] }
|
|
99
|
+
*/
|
|
100
|
+
function validateSuggestionLineNumbers(suggestions, fileLineCountMap, options = {}) {
|
|
101
|
+
const { convertToFileLevel = false } = options;
|
|
102
|
+
const result = {
|
|
103
|
+
valid: [],
|
|
104
|
+
converted: [],
|
|
105
|
+
dropped: []
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (!suggestions || !Array.isArray(suggestions)) {
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const suggestion of suggestions) {
|
|
113
|
+
// File-level suggestions (line_start === null) always pass through
|
|
114
|
+
if (suggestion.line_start === null || suggestion.line_start === undefined) {
|
|
115
|
+
result.valid.push(suggestion);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const filePath = suggestion.file;
|
|
120
|
+
const lineCount = fileLineCountMap.get(filePath);
|
|
121
|
+
|
|
122
|
+
// If file not in map, pass through (might be deleted file or file we couldn't process)
|
|
123
|
+
if (lineCount === undefined) {
|
|
124
|
+
result.valid.push(suggestion);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Binary files (lineCount === -1) - pass through since we can't validate line numbers
|
|
129
|
+
if (lineCount === -1) {
|
|
130
|
+
result.valid.push(suggestion);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Validate line numbers
|
|
135
|
+
const lineStart = suggestion.line_start;
|
|
136
|
+
const lineEnd = suggestion.line_end !== undefined && suggestion.line_end !== null
|
|
137
|
+
? suggestion.line_end
|
|
138
|
+
: lineStart;
|
|
139
|
+
|
|
140
|
+
let isValid = true;
|
|
141
|
+
let reason = '';
|
|
142
|
+
|
|
143
|
+
// Check line_start is valid
|
|
144
|
+
if (lineStart <= 0) {
|
|
145
|
+
isValid = false;
|
|
146
|
+
reason = `line_start ${lineStart} is <= 0`;
|
|
147
|
+
} else if (lineStart > lineCount) {
|
|
148
|
+
isValid = false;
|
|
149
|
+
reason = `line_start ${lineStart} exceeds file length ${lineCount}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check line_end is valid
|
|
153
|
+
if (isValid && lineEnd < lineStart) {
|
|
154
|
+
isValid = false;
|
|
155
|
+
reason = `line_end ${lineEnd} is less than line_start ${lineStart}`;
|
|
156
|
+
} else if (isValid && lineEnd > lineCount) {
|
|
157
|
+
isValid = false;
|
|
158
|
+
reason = `line_end ${lineEnd} exceeds file length ${lineCount}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (isValid) {
|
|
162
|
+
result.valid.push(suggestion);
|
|
163
|
+
} else if (convertToFileLevel) {
|
|
164
|
+
// Convert to file-level suggestion
|
|
165
|
+
const convertedSuggestion = {
|
|
166
|
+
...suggestion,
|
|
167
|
+
line_start: null,
|
|
168
|
+
line_end: null,
|
|
169
|
+
is_file_level: true
|
|
170
|
+
};
|
|
171
|
+
result.converted.push(convertedSuggestion);
|
|
172
|
+
logger.warn(`[Line Validation] Converting suggestion to file-level: "${suggestion.title}" (${reason})`);
|
|
173
|
+
} else {
|
|
174
|
+
// Drop the suggestion
|
|
175
|
+
result.dropped.push(suggestion);
|
|
176
|
+
logger.warn(`[Line Validation] Dropping suggestion: "${suggestion.title}" (${reason})`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { buildFileLineCountMap, validateSuggestionLineNumbers };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Logger utility for AI analysis
|
|
4
|
+
* Provides formatted console output that stands out
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const COLORS = {
|
|
8
|
+
reset: '\x1b[0m',
|
|
9
|
+
bright: '\x1b[1m',
|
|
10
|
+
dim: '\x1b[2m',
|
|
11
|
+
|
|
12
|
+
// Foreground colors
|
|
13
|
+
red: '\x1b[31m',
|
|
14
|
+
green: '\x1b[32m',
|
|
15
|
+
yellow: '\x1b[33m',
|
|
16
|
+
blue: '\x1b[34m',
|
|
17
|
+
magenta: '\x1b[35m',
|
|
18
|
+
cyan: '\x1b[36m',
|
|
19
|
+
white: '\x1b[37m',
|
|
20
|
+
|
|
21
|
+
// Background colors
|
|
22
|
+
bgBlue: '\x1b[44m',
|
|
23
|
+
bgGreen: '\x1b[42m',
|
|
24
|
+
bgYellow: '\x1b[43m',
|
|
25
|
+
bgRed: '\x1b[41m'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
class AILogger {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.enabled = true;
|
|
31
|
+
this.debugEnabled = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Enable or disable debug logging
|
|
36
|
+
* @param {boolean} enabled - Whether debug logging should be enabled
|
|
37
|
+
*/
|
|
38
|
+
setDebugEnabled(enabled) {
|
|
39
|
+
this.debugEnabled = enabled;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if debug logging is enabled
|
|
44
|
+
* @returns {boolean} Whether debug logging is enabled
|
|
45
|
+
*/
|
|
46
|
+
isDebugEnabled() {
|
|
47
|
+
return this.debugEnabled;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Log debug message (only shown when debug is enabled)
|
|
52
|
+
*/
|
|
53
|
+
debug(message) {
|
|
54
|
+
if (!this.enabled || !this.debugEnabled) return;
|
|
55
|
+
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
56
|
+
process.stdout.write(
|
|
57
|
+
`${COLORS.cyan}[${timestamp}]${COLORS.reset} ` +
|
|
58
|
+
`${COLORS.dim}[AI DBG]${COLORS.reset} ` +
|
|
59
|
+
`${COLORS.dim}${message}${COLORS.reset}\n`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Log AI analysis info
|
|
65
|
+
*/
|
|
66
|
+
info(message) {
|
|
67
|
+
if (!this.enabled) return;
|
|
68
|
+
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
69
|
+
process.stdout.write(
|
|
70
|
+
`${COLORS.cyan}[${timestamp}]${COLORS.reset} ` +
|
|
71
|
+
`${COLORS.bright}${COLORS.blue}[AI]${COLORS.reset} ` +
|
|
72
|
+
`${message}\n`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Log AI analysis success
|
|
78
|
+
*/
|
|
79
|
+
success(message) {
|
|
80
|
+
if (!this.enabled) return;
|
|
81
|
+
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
82
|
+
process.stdout.write(
|
|
83
|
+
`${COLORS.cyan}[${timestamp}]${COLORS.reset} ` +
|
|
84
|
+
`${COLORS.bright}${COLORS.green}[AI ✓]${COLORS.reset} ` +
|
|
85
|
+
`${COLORS.green}${message}${COLORS.reset}\n`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Log AI analysis error
|
|
91
|
+
*/
|
|
92
|
+
error(message) {
|
|
93
|
+
if (!this.enabled) return;
|
|
94
|
+
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
95
|
+
process.stderr.write(
|
|
96
|
+
`${COLORS.cyan}[${timestamp}]${COLORS.reset} ` +
|
|
97
|
+
`${COLORS.bright}${COLORS.red}[AI ✗]${COLORS.reset} ` +
|
|
98
|
+
`${COLORS.red}${message}${COLORS.reset}\n`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Log AI analysis warning
|
|
104
|
+
*/
|
|
105
|
+
warn(message) {
|
|
106
|
+
if (!this.enabled) return;
|
|
107
|
+
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
108
|
+
process.stdout.write(
|
|
109
|
+
`${COLORS.cyan}[${timestamp}]${COLORS.reset} ` +
|
|
110
|
+
`${COLORS.bright}${COLORS.yellow}[AI ⚠]${COLORS.reset} ` +
|
|
111
|
+
`${COLORS.yellow}${message}${COLORS.reset}\n`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Log with custom prefix
|
|
117
|
+
*/
|
|
118
|
+
log(prefix, message, color = 'blue') {
|
|
119
|
+
if (!this.enabled) return;
|
|
120
|
+
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
121
|
+
const prefixColor = COLORS[color] || COLORS.blue;
|
|
122
|
+
process.stdout.write(
|
|
123
|
+
`${COLORS.cyan}[${timestamp}]${COLORS.reset} ` +
|
|
124
|
+
`${COLORS.bright}${prefixColor}[${prefix}]${COLORS.reset} ` +
|
|
125
|
+
`${message}\n`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Start a progress section
|
|
131
|
+
*/
|
|
132
|
+
section(title) {
|
|
133
|
+
if (!this.enabled) return;
|
|
134
|
+
process.stdout.write(
|
|
135
|
+
`\n${COLORS.bright}${COLORS.cyan}${'─'.repeat(60)}${COLORS.reset}\n` +
|
|
136
|
+
`${COLORS.bright}${COLORS.cyan}▶ ${title}${COLORS.reset}\n` +
|
|
137
|
+
`${COLORS.cyan}${'─'.repeat(60)}${COLORS.reset}\n`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = new AILogger();
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Path normalization utilities for consistent path comparison
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize a file path for consistent comparison
|
|
8
|
+
*
|
|
9
|
+
* This function:
|
|
10
|
+
* - Removes leading/trailing whitespace
|
|
11
|
+
* - Removes leading './' prefix (handles repeated patterns like '././')
|
|
12
|
+
* - Removes leading '/' prefix (handles repeated patterns like '//')
|
|
13
|
+
* - Handles interleaved patterns like '/./src' by iterating
|
|
14
|
+
* - Normalizes multiple consecutive slashes to single slash
|
|
15
|
+
* - Does NOT modify case (paths are case-sensitive on most systems)
|
|
16
|
+
*
|
|
17
|
+
* @param {string} filePath - The file path to normalize
|
|
18
|
+
* @returns {string} Normalized path
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* normalizePath('./src/foo.js') // => 'src/foo.js'
|
|
22
|
+
* normalizePath('/src/foo.js') // => 'src/foo.js'
|
|
23
|
+
* normalizePath('src//foo.js') // => 'src/foo.js'
|
|
24
|
+
* normalizePath(' src/foo.js ') // => 'src/foo.js'
|
|
25
|
+
* normalizePath('././src/foo.js') // => 'src/foo.js'
|
|
26
|
+
* normalizePath('//./src/foo.js') // => 'src/foo.js'
|
|
27
|
+
* normalizePath(null) // => ''
|
|
28
|
+
* normalizePath(undefined) // => ''
|
|
29
|
+
*/
|
|
30
|
+
function normalizePath(filePath) {
|
|
31
|
+
// Handle null, undefined, and non-string inputs
|
|
32
|
+
if (filePath == null || typeof filePath !== 'string') {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let result = filePath;
|
|
37
|
+
|
|
38
|
+
// Trim whitespace
|
|
39
|
+
result = result.trim();
|
|
40
|
+
|
|
41
|
+
// Return early if empty after trimming
|
|
42
|
+
if (result === '') {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Collapse multiple consecutive slashes into single slashes
|
|
47
|
+
// Do this before removing leading slashes to handle cases like '//src/foo.js'
|
|
48
|
+
result = result.replace(/\/+/g, '/');
|
|
49
|
+
|
|
50
|
+
// Remove leading './' and '/' iteratively
|
|
51
|
+
// This handles cases like '/./src/foo.js' which need both removed
|
|
52
|
+
let prevLength;
|
|
53
|
+
do {
|
|
54
|
+
prevLength = result.length;
|
|
55
|
+
|
|
56
|
+
// Remove leading './'
|
|
57
|
+
while (result.startsWith('./')) {
|
|
58
|
+
result = result.slice(2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Remove leading '/'
|
|
62
|
+
while (result.startsWith('/')) {
|
|
63
|
+
result = result.slice(1);
|
|
64
|
+
}
|
|
65
|
+
} while (result.length !== prevLength);
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if two paths are equivalent after normalization
|
|
72
|
+
*
|
|
73
|
+
* @param {string} path1 - First path to compare
|
|
74
|
+
* @param {string} path2 - Second path to compare
|
|
75
|
+
* @returns {boolean} True if paths are equivalent
|
|
76
|
+
*/
|
|
77
|
+
function pathsEqual(path1, path2) {
|
|
78
|
+
return normalizePath(path1) === normalizePath(path2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if a path exists in a list of paths (using normalized comparison)
|
|
83
|
+
*
|
|
84
|
+
* @deprecated Use pathExistsInSet() with a pre-normalized Set for O(1) lookups
|
|
85
|
+
* @param {string} needle - Path to search for
|
|
86
|
+
* @param {Array<string>} haystack - Array of paths to search in
|
|
87
|
+
* @returns {boolean} True if path exists in the array
|
|
88
|
+
*/
|
|
89
|
+
function pathExistsInList(needle, haystack) {
|
|
90
|
+
if (!needle || !Array.isArray(haystack)) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const normalizedNeedle = normalizePath(needle);
|
|
95
|
+
return haystack.some(path => normalizePath(path) === normalizedNeedle);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if a path exists in a Set of pre-normalized paths (O(1) lookup)
|
|
100
|
+
*
|
|
101
|
+
* This is the preferred method for checking path existence when performing
|
|
102
|
+
* multiple lookups against the same set of valid paths.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} needle - Path to search for (will be normalized)
|
|
105
|
+
* @param {Set<string>} normalizedPathsSet - Set of pre-normalized paths
|
|
106
|
+
* @returns {boolean} True if path exists in the Set
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // Pre-normalize paths once
|
|
110
|
+
* const validPathsSet = new Set(validPaths.map(p => normalizePath(p)));
|
|
111
|
+
* // Then use O(1) lookups
|
|
112
|
+
* pathExistsInSet('./src/foo.js', validPathsSet) // => true
|
|
113
|
+
*/
|
|
114
|
+
function pathExistsInSet(needle, normalizedPathsSet) {
|
|
115
|
+
if (!needle || !(normalizedPathsSet instanceof Set)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const normalizedNeedle = normalizePath(needle);
|
|
120
|
+
return normalizedPathsSet.has(normalizedNeedle);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Normalize a GitHub repository identifier for case-insensitive matching.
|
|
125
|
+
*
|
|
126
|
+
* GitHub repository names and owner names are case-insensitive. URLs like
|
|
127
|
+
* github.com/Owner/Repo and github.com/owner/repo refer to the same repository.
|
|
128
|
+
* This function normalizes repository identifiers to lowercase to ensure
|
|
129
|
+
* consistent database lookups regardless of URL casing.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} owner - Repository owner (GitHub username or org)
|
|
132
|
+
* @param {string} repo - Repository name
|
|
133
|
+
* @returns {string} Normalized repository identifier in "owner/repo" format (lowercase)
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* normalizeRepository('Owner', 'Repo') // => 'owner/repo'
|
|
137
|
+
* normalizeRepository('OWNER', 'REPO') // => 'owner/repo'
|
|
138
|
+
* normalizeRepository('owner', 'repo') // => 'owner/repo'
|
|
139
|
+
*/
|
|
140
|
+
function normalizeRepository(owner, repo) {
|
|
141
|
+
if (!owner || typeof owner !== 'string' || !repo || typeof repo !== 'string') {
|
|
142
|
+
throw new Error('owner and repo must be non-empty strings');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const trimmedOwner = owner.trim();
|
|
146
|
+
const trimmedRepo = repo.trim();
|
|
147
|
+
|
|
148
|
+
if (!trimmedOwner || !trimmedRepo) {
|
|
149
|
+
throw new Error('owner and repo must be non-empty strings');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return `${trimmedOwner.toLowerCase()}/${trimmedRepo.toLowerCase()}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
normalizePath,
|
|
157
|
+
pathsEqual,
|
|
158
|
+
pathExistsInList,
|
|
159
|
+
pathExistsInSet,
|
|
160
|
+
normalizeRepository
|
|
161
|
+
};
|