@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.
Files changed (91) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +371 -0
  3. package/bin/git-diff-lines +146 -0
  4. package/bin/pair-review.js +49 -0
  5. package/package.json +71 -0
  6. package/public/css/ai-summary-modal.css +183 -0
  7. package/public/css/pr.css +8698 -0
  8. package/public/css/repo-settings.css +891 -0
  9. package/public/css/styles.css +479 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +1104 -0
  12. package/public/js/components/AIPanel.js +1639 -0
  13. package/public/js/components/AISummaryModal.js +278 -0
  14. package/public/js/components/AnalysisConfigModal.js +684 -0
  15. package/public/js/components/ConfirmDialog.js +227 -0
  16. package/public/js/components/PreviewModal.js +344 -0
  17. package/public/js/components/ProgressModal.js +678 -0
  18. package/public/js/components/ReviewModal.js +531 -0
  19. package/public/js/components/SplitButton.js +382 -0
  20. package/public/js/components/StatusIndicator.js +265 -0
  21. package/public/js/components/SuggestionNavigator.js +489 -0
  22. package/public/js/components/Toast.js +166 -0
  23. package/public/js/local.js +1580 -0
  24. package/public/js/modules/analysis-history.js +940 -0
  25. package/public/js/modules/comment-manager.js +643 -0
  26. package/public/js/modules/diff-renderer.js +585 -0
  27. package/public/js/modules/file-comment-manager.js +1242 -0
  28. package/public/js/modules/gap-coordinates.js +190 -0
  29. package/public/js/modules/hunk-parser.js +358 -0
  30. package/public/js/modules/line-tracker.js +386 -0
  31. package/public/js/modules/panel-resizer.js +228 -0
  32. package/public/js/modules/storage-cleanup.js +36 -0
  33. package/public/js/modules/suggestion-manager.js +692 -0
  34. package/public/js/pr.js +3503 -0
  35. package/public/js/repo-settings.js +691 -0
  36. package/public/js/utils/file-order.js +87 -0
  37. package/public/js/utils/markdown.js +97 -0
  38. package/public/js/utils/suggestion-ui.js +55 -0
  39. package/public/js/utils/tier-icons.js +25 -0
  40. package/public/local.html +460 -0
  41. package/public/pr.html +329 -0
  42. package/public/repo-settings.html +243 -0
  43. package/src/ai/analyzer.js +2592 -0
  44. package/src/ai/claude-cli.js +153 -0
  45. package/src/ai/claude-provider.js +261 -0
  46. package/src/ai/codex-provider.js +361 -0
  47. package/src/ai/copilot-provider.js +345 -0
  48. package/src/ai/gemini-provider.js +375 -0
  49. package/src/ai/index.js +47 -0
  50. package/src/ai/prompts/baseline/_meta.json +14 -0
  51. package/src/ai/prompts/baseline/level1/balanced.js +239 -0
  52. package/src/ai/prompts/baseline/level1/fast.js +194 -0
  53. package/src/ai/prompts/baseline/level1/thorough.js +319 -0
  54. package/src/ai/prompts/baseline/level2/balanced.js +248 -0
  55. package/src/ai/prompts/baseline/level2/fast.js +201 -0
  56. package/src/ai/prompts/baseline/level2/thorough.js +367 -0
  57. package/src/ai/prompts/baseline/level3/balanced.js +280 -0
  58. package/src/ai/prompts/baseline/level3/fast.js +220 -0
  59. package/src/ai/prompts/baseline/level3/thorough.js +459 -0
  60. package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
  61. package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
  62. package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
  63. package/src/ai/prompts/config.js +52 -0
  64. package/src/ai/prompts/index.js +267 -0
  65. package/src/ai/prompts/shared/diff-instructions.js +50 -0
  66. package/src/ai/prompts/shared/output-schema.js +179 -0
  67. package/src/ai/prompts/shared/valid-files.js +37 -0
  68. package/src/ai/provider.js +260 -0
  69. package/src/config.js +139 -0
  70. package/src/database.js +2284 -0
  71. package/src/git/gitattributes.js +207 -0
  72. package/src/git/worktree.js +688 -0
  73. package/src/github/client.js +893 -0
  74. package/src/github/parser.js +247 -0
  75. package/src/local-review.js +691 -0
  76. package/src/main.js +987 -0
  77. package/src/routes/analysis.js +897 -0
  78. package/src/routes/comments.js +534 -0
  79. package/src/routes/config.js +250 -0
  80. package/src/routes/local.js +1728 -0
  81. package/src/routes/pr.js +1164 -0
  82. package/src/routes/shared.js +218 -0
  83. package/src/routes/worktrees.js +500 -0
  84. package/src/server.js +295 -0
  85. package/src/utils/diff-annotator.js +414 -0
  86. package/src/utils/instructions.js +33 -0
  87. package/src/utils/json-extractor.js +107 -0
  88. package/src/utils/line-validation.js +183 -0
  89. package/src/utils/logger.js +142 -0
  90. package/src/utils/paths.js +161 -0
  91. 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
+ };