@dependabit/detector 0.1.2 → 0.1.14

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/src/detector.ts CHANGED
@@ -31,6 +31,8 @@ export interface DetectorOptions {
31
31
  llmProvider: LLMProvider;
32
32
  ignorePatterns?: string[];
33
33
  useGitExcludes?: boolean;
34
+ repoOwner?: string;
35
+ repoName?: string;
34
36
  }
35
37
 
36
38
  export interface DetectionResult {
@@ -46,20 +48,13 @@ export interface DetectionResult {
46
48
 
47
49
  const DEFAULT_IGNORE_PATTERNS = [
48
50
  'node_modules',
49
- '.git',
50
- '.github',
51
- '.claude',
52
- '.codex',
53
51
  'dist',
54
52
  'build',
55
53
  'target',
56
54
  'vendor',
57
- '.venv',
58
55
  'venv',
59
56
  '__pycache__',
60
- 'coverage',
61
- '.next',
62
- '.nuxt'
57
+ 'coverage'
63
58
  ];
64
59
 
65
60
  /**
@@ -69,13 +64,25 @@ export class Detector {
69
64
  private options: Required<DetectorOptions>;
70
65
  private ignoreMatcher: Ignore | null = null;
71
66
  private ignoreMatcherLoaded = false;
67
+ private skipUrlPatterns: RegExp[];
72
68
 
73
69
  constructor(options: DetectorOptions) {
74
70
  this.options = {
75
71
  ...options,
76
72
  ignorePatterns: options.ignorePatterns || DEFAULT_IGNORE_PATTERNS,
77
- useGitExcludes: options.useGitExcludes ?? true
73
+ useGitExcludes: options.useGitExcludes ?? true,
74
+ repoOwner: options.repoOwner || '',
75
+ repoName: options.repoName || ''
78
76
  };
77
+
78
+ this.skipUrlPatterns = [
79
+ /example\.com/,
80
+ /example\.org/,
81
+ /localhost/,
82
+ /127\.0\.0\.1/,
83
+ /\[.*\]/, // template placeholders like [NUMBER]
84
+ /github\.com\/user\/repo/ // common placeholder in docs
85
+ ];
79
86
  }
80
87
 
81
88
  /**
@@ -232,6 +239,8 @@ Return as JSON with "dependencies" array.`;
232
239
  const now = new Date().toISOString();
233
240
 
234
241
  for (const [url, data] of allReferences) {
242
+ // Skip entries that will end up with low confidence
243
+ // (entries that can't be typed programmatically and LLM assigns low confidence)
235
244
  // Prepare context for potential LLM use
236
245
  const context = data.contexts
237
246
  .map((c) => `${c.file}${c.line ? `:${c.line}` : ''}: ${c.text}`)
@@ -269,6 +278,11 @@ Return as JSON with "dependencies" array.`;
269
278
  typeConfidence = 0.3;
270
279
  }
271
280
 
281
+ // Skip low-confidence entries
282
+ if (typeConfidence < 0.5) {
283
+ continue;
284
+ }
285
+
272
286
  // Step 5: Try programmatic access method determination
273
287
  let accessMethod: AccessMethod | null = this.determineAccessMethod(url);
274
288
 
@@ -364,6 +378,27 @@ Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
364
378
  context: { file: string; line?: number; text: string },
365
379
  detectionMethod: DetectionMethod
366
380
  ): void {
381
+ // Skip URLs that don't start with http:// or https://
382
+ if (!/^https?:\/\//.test(url)) {
383
+ return;
384
+ }
385
+
386
+ // Skip URLs matching skip patterns (placeholders, localhost, etc.)
387
+ if (this.skipUrlPatterns.some((pattern) => pattern.test(url))) {
388
+ return;
389
+ }
390
+
391
+ // Skip self-references (URLs pointing to the repo itself)
392
+ if (this.options.repoOwner && this.options.repoName) {
393
+ const selfPattern = new RegExp(
394
+ `github\\.com[/:]${this.escapeRegExp(this.options.repoOwner)}/${this.escapeRegExp(this.options.repoName)}(?:/|$|#|\\?)`,
395
+ 'i'
396
+ );
397
+ if (selfPattern.test(url)) {
398
+ return;
399
+ }
400
+ }
401
+
367
402
  if (!map.has(url)) {
368
403
  map.set(url, {
369
404
  url,
@@ -374,6 +409,10 @@ Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
374
409
  map.get(url)!.contexts.push(context);
375
410
  }
376
411
 
412
+ private escapeRegExp(str: string): string {
413
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
414
+ }
415
+
377
416
  /**
378
417
  * Programmatically determine access method based on URL patterns
379
418
  * Returns null if cannot be determined programmatically
@@ -526,7 +565,7 @@ Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
526
565
  files.push(fullPath);
527
566
  }
528
567
  }
529
- } catch {
568
+ } catch {
530
569
  // Ignore errors (permission denied, etc.)
531
570
  }
532
571
 
@@ -3,7 +3,7 @@
3
3
  * Integrates with GitHub Copilot via CLI commands
4
4
  */
5
5
 
6
- import { exec } from 'node:child_process';
6
+ import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import type {
9
9
  LLMProvider,
@@ -15,7 +15,7 @@ import type {
15
15
  } from './client.js';
16
16
  import { SYSTEM_PROMPT } from './prompts.js';
17
17
 
18
- const execAsync = promisify(exec);
18
+ const execFileAsync = promisify(execFile);
19
19
 
20
20
  export class GitHubCopilotProvider implements LLMProvider {
21
21
  private config: Required<LLMProviderConfig>;
@@ -44,14 +44,17 @@ export class GitHubCopilotProvider implements LLMProvider {
44
44
  // Combine system prompt and user prompt for CLI
45
45
  const fullPrompt = `${SYSTEM_PROMPT}\n\n${prompt}`;
46
46
 
47
- // Escape the prompt for shell safety (basic escaping)
48
- const escapedPrompt = fullPrompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
49
-
50
- // Use gh copilot suggest command to get AI response
51
- // The --yes flag auto-accepts the suggestion, --shell-out returns raw output
52
- const command = `echo "${escapedPrompt}" | gh copilot suggest --yes 2>&1`;
53
-
54
- const { stdout, stderr } = await execAsync(command, {
47
+ // Use execFile to avoid shell escaping issues and command injection
48
+ // Pass prompt directly as an argument without manual escaping
49
+ const args = [
50
+ 'copilot',
51
+ '-p',
52
+ fullPrompt,
53
+ '--silent',
54
+ '--allow-all-tools'
55
+ ];
56
+
57
+ const { stdout, stderr } = await execFileAsync('gh', args, {
55
58
  maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large responses
56
59
  timeout: 60000 // 60 second timeout
57
60
  });
@@ -17,11 +17,20 @@ INCLUDE these types of dependencies:
17
17
 
18
18
  EXCLUDE these (handled by dependabot):
19
19
  - NPM packages in package.json
20
- - Python packages in requirements.txt
20
+ - Python packages in requirements.txt
21
21
  - Rust crates in Cargo.toml
22
22
  - Docker images in Dockerfile
23
23
  - Any declared package manager dependencies
24
24
 
25
+ ALSO EXCLUDE (false positives):
26
+ - URLs that reference the repository itself (self-references)
27
+ - Relative file paths (e.g., CONTRIBUTING.md, docs/guide.md, ./src/utils)
28
+ - Placeholder URLs used in documentation examples (example.com, example.org, localhost)
29
+ - Internal documentation links within the same repository
30
+ - URLs with template variables or placeholders (e.g., issues/[NUMBER], user/repo)
31
+
32
+ Only return dependencies with confidence >= 0.7.
33
+
25
34
  For each dependency found, provide:
26
35
  1. url: The complete URL
27
36
  2. name: A descriptive name
@@ -10,7 +10,7 @@ export interface ExtractedReference {
10
10
  type: 'markdown-link' | 'bare-url' | 'reference-link';
11
11
  }
12
12
 
13
- // Patterns to skip (package managers, CI badges, shields.io)
13
+ // Patterns to skip (package managers, CI badges, shields.io, placeholders)
14
14
  const SKIP_PATTERNS = [
15
15
  /npmjs\.com\/package/,
16
16
  /pypi\.org\/project/,
@@ -21,7 +21,13 @@ const SKIP_PATTERNS = [
21
21
  /badge(s)?\..*\.svg/,
22
22
  /travis-ci\.(org|com)/,
23
23
  /circleci\.com/,
24
- /github\.com\/.*\/actions/ // GitHub Actions badges
24
+ /github\.com\/.*\/actions/, // GitHub Actions badges
25
+ /example\.com/, // placeholder domain
26
+ /example\.org/, // placeholder domain
27
+ /localhost/, // local dev URLs
28
+ /127\.0\.0\.1/, // loopback address
29
+ /\[.*\]/, // template placeholders (e.g., issues/[NUMBER])
30
+ /github\.com\/user\/repo/ // common placeholder in docs
25
31
  ];
26
32
 
27
33
  /**
@@ -1,55 +1,334 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { promisify } from 'node:util';
3
+ import { GitHubCopilotProvider } from '../../src/llm/copilot.js';
4
+ import type { DetectedDependency } from '../../src/llm/client.js';
5
+
6
+ // Mock child_process with proper execFile behavior
7
+ vi.mock('node:child_process', () => {
8
+ const mockCallback = vi.fn();
9
+
10
+ // The custom promisify implementation that mimics Node.js behavior
11
+ const customPromisify = vi.fn((...execArgs) => {
12
+ return new Promise((resolve, reject) => {
13
+ // Call the mock with a callback that converts to { stdout, stderr }
14
+ mockCallback(...execArgs, (err: Error | null, stdout: string, stderr: string) => {
15
+ if (err) {
16
+ const error = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
17
+ error.stdout = stdout;
18
+ error.stderr = stderr;
19
+ reject(error);
20
+ } else {
21
+ resolve({ stdout, stderr });
22
+ }
23
+ });
24
+ });
25
+ });
26
+
27
+ mockCallback[promisify.custom] = customPromisify;
28
+
29
+ return {
30
+ execFile: mockCallback
31
+ };
32
+ });
33
+
34
+ import { execFile } from 'node:child_process';
35
+ const mockExecFileCallback = vi.mocked(execFile);
2
36
 
3
37
  describe('GitHubCopilotProvider', () => {
38
+ let provider: GitHubCopilotProvider;
39
+
4
40
  beforeEach(() => {
5
41
  vi.clearAllMocks();
42
+ provider = new GitHubCopilotProvider();
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.restoreAllMocks();
6
47
  });
7
48
 
8
- describe('constructor', () => {
9
- it('should initialize with API configuration', () => {
10
- expect(true).toBe(true);
49
+ describe('CLI invocation', () => {
50
+ it('should use execFile with correct command and flags', async () => {
51
+ const mockResponse = JSON.stringify({ dependencies: [] });
52
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
53
+ if (typeof callback === 'function') {
54
+ // Use correct callback signature: callback(error, stdout, stderr)
55
+ callback(null, mockResponse, '');
56
+ }
57
+ return {} as any;
58
+ });
59
+
60
+ await provider.analyze('test content', 'test prompt');
61
+
62
+ expect(mockExecFileCallback).toHaveBeenCalledWith(
63
+ 'gh',
64
+ expect.arrayContaining([
65
+ 'copilot',
66
+ '-p',
67
+ expect.any(String),
68
+ '--silent',
69
+ '--allow-all-tools'
70
+ ]),
71
+ expect.objectContaining({
72
+ maxBuffer: 10 * 1024 * 1024,
73
+ timeout: 60000
74
+ }),
75
+ expect.any(Function)
76
+ );
77
+ });
78
+
79
+ it('should pass prompt directly without shell escaping', async () => {
80
+ const mockResponse = JSON.stringify({ dependencies: [] });
81
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
82
+ if (typeof callback === 'function') {
83
+ callback(null, mockResponse, '');
84
+ }
85
+ return {} as any;
86
+ });
87
+
88
+ const promptWithSpecialChars = 'test "quotes" $dollar `backtick`';
89
+ await provider.analyze('content', promptWithSpecialChars);
90
+
91
+ const callArgs = mockExecFileCallback.mock.calls[0];
92
+ const args = callArgs?.[1] as string[];
93
+ const fullPrompt = args?.[2];
94
+
95
+ // Verify prompt contains special characters unescaped
96
+ expect(fullPrompt).toContain('test "quotes" $dollar `backtick`');
11
97
  });
12
98
 
13
- it('should use environment variable for API key', () => {
14
- expect(true).toBe(true);
99
+ it('should handle prompts with newlines', async () => {
100
+ const mockResponse = JSON.stringify({ dependencies: [] });
101
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
102
+ if (typeof callback === 'function') {
103
+ callback(null, mockResponse, '');
104
+ }
105
+ return {} as any;
106
+ });
107
+
108
+ const promptWithNewlines = 'line1\nline2\nline3';
109
+ await provider.analyze('content', promptWithNewlines);
110
+
111
+ const callArgs = mockExecFileCallback.mock.calls[0];
112
+ const args = callArgs?.[1] as string[];
113
+ const fullPrompt = args?.[2];
114
+
115
+ expect(fullPrompt).toContain('line1\nline2\nline3');
116
+ });
117
+
118
+ it('should handle prompts ending with backslash', async () => {
119
+ const mockResponse = JSON.stringify({ dependencies: [] });
120
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
121
+ if (typeof callback === 'function') {
122
+ callback(null, mockResponse, '');
123
+ }
124
+ return {} as any;
125
+ });
126
+
127
+ const promptWithBackslash = 'path\\to\\file\\';
128
+ await provider.analyze('content', promptWithBackslash);
129
+
130
+ const callArgs = mockExecFileCallback.mock.calls[0];
131
+ const args = callArgs?.[1] as string[];
132
+ const fullPrompt = args?.[2];
133
+
134
+ expect(fullPrompt).toContain('path\\to\\file\\');
15
135
  });
16
136
  });
17
137
 
18
- describe('analyze', () => {
19
- it('should call Azure OpenAI API with correct parameters', async () => {
20
- expect(true).toBe(true);
138
+ describe('response parsing', () => {
139
+ it('should parse JSON response correctly', async () => {
140
+ const mockDeps: DetectedDependency[] = [
141
+ {
142
+ name: 'test-package',
143
+ version: '1.0.0',
144
+ type: 'npm',
145
+ confidence: 0.9,
146
+ context: 'test context'
147
+ }
148
+ ];
149
+ const mockResponse = JSON.stringify({ dependencies: mockDeps });
150
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
151
+ if (typeof callback === 'function') {
152
+ callback(null, mockResponse, '');
153
+ }
154
+ return {} as any;
155
+ });
156
+
157
+ const result = await provider.analyze('content', 'prompt');
158
+
159
+ expect(result.dependencies).toEqual(mockDeps);
21
160
  });
22
161
 
23
- it('should parse LLM response into structured dependencies', async () => {
24
- expect(true).toBe(true);
162
+ it('should parse JSON wrapped in markdown code blocks', async () => {
163
+ const mockDeps: DetectedDependency[] = [
164
+ {
165
+ name: 'test-package',
166
+ version: '1.0.0',
167
+ type: 'npm',
168
+ confidence: 0.9,
169
+ context: 'test context'
170
+ }
171
+ ];
172
+ const mockResponse = '```json\n' + JSON.stringify({ dependencies: mockDeps }) + '\n```';
173
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
174
+ if (typeof callback === 'function') {
175
+ callback(null, mockResponse, '');
176
+ }
177
+ return {} as any;
178
+ });
179
+
180
+ const result = await provider.analyze('content', 'prompt');
181
+
182
+ expect(result.dependencies).toEqual(mockDeps);
25
183
  });
26
184
 
27
- it('should include confidence scores for each detection', async () => {
28
- expect(true).toBe(true);
185
+ it('should handle malformed JSON gracefully', async () => {
186
+ const mockResponse = 'not valid json';
187
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
188
+ if (typeof callback === 'function') {
189
+ callback(null, mockResponse, '');
190
+ }
191
+ return {} as any;
192
+ });
193
+
194
+ const result = await provider.analyze('content', 'prompt');
195
+
196
+ expect(result.dependencies).toEqual([]);
29
197
  });
30
198
 
31
- it('should handle API errors gracefully', async () => {
32
- expect(true).toBe(true);
199
+ it('should return empty dependencies on empty response', async () => {
200
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
201
+ if (typeof callback === 'function') {
202
+ callback(null, '', '');
203
+ }
204
+ return {} as any;
205
+ });
206
+
207
+ const result = await provider.analyze('content', 'prompt');
208
+
209
+ expect(result.dependencies).toEqual([]);
33
210
  });
211
+ });
212
+
213
+ describe('error handling', () => {
214
+ it('should handle CLI execution errors', async () => {
215
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
216
+ if (typeof callback === 'function') {
217
+ callback(new Error('CLI error'), '', 'Error message');
218
+ }
219
+ return {} as any;
220
+ });
221
+
222
+ const result = await provider.analyze('content', 'prompt');
34
223
 
35
- it('should respect rate limits', async () => {
36
- expect(true).toBe(true);
224
+ expect(result.dependencies).toEqual([]);
225
+ expect(result.rawResponse).toContain('CLI error');
37
226
  });
38
227
 
39
- it('should log request/response metadata', async () => {
40
- expect(true).toBe(true);
228
+ it('should handle stderr without stdout as error', async () => {
229
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
230
+ if (typeof callback === 'function') {
231
+ callback(null, '', 'Authentication failed');
232
+ }
233
+ return {} as any;
234
+ });
235
+
236
+ const result = await provider.analyze('content', 'prompt');
237
+
238
+ expect(result.dependencies).toEqual([]);
239
+ });
240
+
241
+ it('should handle timeout errors', async () => {
242
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
243
+ if (typeof callback === 'function') {
244
+ const error = new Error('Command timeout') as NodeJS.ErrnoException;
245
+ error.code = 'ETIMEDOUT';
246
+ callback(error, '', '');
247
+ }
248
+ return {} as any;
249
+ });
250
+
251
+ const result = await provider.analyze('content', 'prompt');
252
+
253
+ expect(result.dependencies).toEqual([]);
254
+ });
255
+
256
+ it('should handle buffer overflow errors', async () => {
257
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
258
+ if (typeof callback === 'function') {
259
+ const error = new Error('maxBuffer exceeded') as NodeJS.ErrnoException;
260
+ error.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
261
+ callback(error, '', '');
262
+ }
263
+ return {} as any;
264
+ });
265
+
266
+ const result = await provider.analyze('content', 'prompt');
267
+
268
+ expect(result.dependencies).toEqual([]);
269
+ });
270
+ });
271
+
272
+ describe('usage metadata', () => {
273
+ it('should include usage metadata in response', async () => {
274
+ const mockResponse = JSON.stringify({ dependencies: [] });
275
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
276
+ if (typeof callback === 'function') {
277
+ callback(null, mockResponse, '');
278
+ }
279
+ return {} as any;
280
+ });
281
+
282
+ const result = await provider.analyze('content', 'prompt');
283
+
284
+ expect(result.usage).toBeDefined();
285
+ expect(result.usage.promptTokens).toBeGreaterThan(0);
286
+ expect(result.usage.completionTokens).toBeGreaterThan(0);
287
+ expect(result.usage.totalTokens).toBeGreaterThan(0);
288
+ expect(result.usage.latencyMs).toBeGreaterThanOrEqual(0);
289
+ });
290
+
291
+ it('should use configured model in usage metadata', async () => {
292
+ const mockResponse = JSON.stringify({ dependencies: [] });
293
+ mockExecFileCallback.mockImplementation((file, args, options, callback) => {
294
+ if (typeof callback === 'function') {
295
+ callback(null, mockResponse, '');
296
+ }
297
+ return {} as any;
298
+ });
299
+
300
+ const customProvider = new GitHubCopilotProvider({ model: 'gpt-4-turbo' });
301
+ const result = await customProvider.analyze('content', 'prompt');
302
+
303
+ expect(result.usage.model).toBe('gpt-4-turbo');
41
304
  });
42
305
  });
43
306
 
44
307
  describe('getSupportedModels', () => {
45
- it('should return available GPT models', () => {
46
- expect(true).toBe(true);
308
+ it('should return list of supported models', () => {
309
+ const models = provider.getSupportedModels();
310
+
311
+ expect(models).toBeInstanceOf(Array);
312
+ expect(models.length).toBeGreaterThan(0);
313
+ expect(models).toContain('github-copilot');
47
314
  });
48
315
  });
49
316
 
50
317
  describe('getRateLimit', () => {
51
- it('should return current rate limit status', () => {
52
- expect(true).toBe(true);
318
+ it('should return rate limit info', async () => {
319
+ const rateLimit = await provider.getRateLimit();
320
+
321
+ expect(rateLimit).toBeDefined();
322
+ expect(rateLimit.remaining).toBe(-1);
323
+ expect(rateLimit.limit).toBe(-1);
324
+ });
325
+ });
326
+
327
+ describe('validateConfig', () => {
328
+ it('should validate configuration', () => {
329
+ const isValid = provider.validateConfig();
330
+
331
+ expect(typeof isValid).toBe('boolean');
53
332
  });
54
333
  });
55
334
  });