@dependabit/detector 0.1.2 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@dependabit/detector",
3
- "version": "0.1.2",
3
+ "version": "0.1.15",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/pradeepmouli/dependabit",
7
+ "directory": "packages/detector"
8
+ },
4
9
  "description": "LLM-based dependency detection for external resources",
5
10
  "type": "module",
6
11
  "main": "dist/index.js",
@@ -15,8 +20,8 @@
15
20
  "@actions/core": "^3.0.0",
16
21
  "ignore": "^7.0.5",
17
22
  "zod": "^4.3.6",
18
- "@dependabit/manifest": "0.1.2",
19
- "@dependabit/github-client": "0.1.2"
23
+ "@dependabit/github-client": "0.1.13",
24
+ "@dependabit/manifest": "0.1.13"
20
25
  },
21
26
  "devDependencies": {
22
27
  "@types/node": "^25.2.3",
@@ -33,7 +38,7 @@
33
38
  "license": "MIT",
34
39
  "scripts": {
35
40
  "build": "tsgo -p tsconfig.json",
36
- "clean": "rm -rf dist",
41
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
37
42
  "dev": "tsx watch src/index.ts",
38
43
  "type-check": "tsgo --noEmit -p tsconfig.json",
39
44
  "test": "vitest run",
package/src/detector.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { readdir, readFile } from 'node:fs/promises';
7
7
  import { execSync } from 'node:child_process';
8
8
  import { homedir } from 'node:os';
9
- import { dirname, join, relative, resolve, normalize, sep } from 'node:path';
9
+ import { basename, dirname, join, relative, resolve, normalize, sep } from 'node:path';
10
10
  import { randomUUID } from 'node:crypto';
11
11
  import ignore, { type Ignore } from 'ignore';
12
12
  import type { LLMProvider } from './llm/client.js';
@@ -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,22 +48,17 @@ 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
 
60
+ const ALLOWED_DOT_DIRECTORIES = new Set<string>();
61
+
65
62
  /**
66
63
  * Main detector class
67
64
  */
@@ -69,13 +66,38 @@ export class Detector {
69
66
  private options: Required<DetectorOptions>;
70
67
  private ignoreMatcher: Ignore | null = null;
71
68
  private ignoreMatcherLoaded = false;
69
+ private skipUrlPatterns: RegExp[];
72
70
 
73
71
  constructor(options: DetectorOptions) {
74
72
  this.options = {
75
73
  ...options,
76
74
  ignorePatterns: options.ignorePatterns || DEFAULT_IGNORE_PATTERNS,
77
- useGitExcludes: options.useGitExcludes ?? true
75
+ useGitExcludes: options.useGitExcludes ?? true,
76
+ repoOwner: options.repoOwner || '',
77
+ repoName: options.repoName || ''
78
78
  };
79
+
80
+ this.skipUrlPatterns = [
81
+ /example\.com/,
82
+ /example\.org/,
83
+ /localhost/,
84
+ /127\.0\.0\.1/,
85
+ /\[.*\]/, // template placeholders like [NUMBER]
86
+ /github\.com\/user\/repo/, // common placeholder in docs
87
+ /your-?username/i, // template placeholder: your-username or yourusername
88
+ /you\/your-project/i, // template: you/your-project
89
+ /github\.com\/YOUR-?USERNAME/i, // GitHub template: YOUR-USERNAME
90
+ /github\.com\/your-?username/i, // GitHub template: your-username
91
+ // Self-reference: skip if URL matches current repository
92
+ ...(this.options.repoOwner && this.options.repoName
93
+ ? [
94
+ new RegExp(
95
+ `github\\.com\\/${this.options.repoOwner}\\/${this.options.repoName}(?:\\.git)?(?:[/?#]|$)`,
96
+ 'i'
97
+ )
98
+ ]
99
+ : [])
100
+ ];
79
101
  }
80
102
 
81
103
  /**
@@ -128,7 +150,33 @@ export class Detector {
128
150
  filesScanned++;
129
151
  }
130
152
 
131
- // 1b. Parse package files for metadata (NOT dependencies)
153
+ // 1b. Parse non-README documentation files
154
+ const documentationFiles = await this.findFiles(this.options.repoPath, /\.(md|txt|rst|adoc)$/i);
155
+ for (const file of documentationFiles) {
156
+ if (/^README/i.test(basename(file))) {
157
+ continue;
158
+ }
159
+
160
+ const content = await readFile(file, 'utf-8');
161
+ const references = parseReadme(content, relative(this.options.repoPath, file));
162
+
163
+ for (const ref of references) {
164
+ this.addReference(
165
+ allReferences,
166
+ ref.url,
167
+ {
168
+ file: relative(this.options.repoPath, file),
169
+ ...(ref.line !== undefined && { line: ref.line }),
170
+ text: ref.context
171
+ },
172
+ 'llm-analysis'
173
+ );
174
+ }
175
+
176
+ filesScanned++;
177
+ }
178
+
179
+ // 1c. Parse package files for metadata (NOT dependencies)
132
180
  const packageFiles = await this.findPackageFiles(this.options.repoPath);
133
181
  for (const file of packageFiles) {
134
182
  const content = await readFile(file, 'utf-8');
@@ -154,7 +202,7 @@ export class Detector {
154
202
  filesScanned++;
155
203
  }
156
204
 
157
- // 1c. Parse code comments from source files
205
+ // 1d. Parse code comments from source files
158
206
  const sourceFiles = await this.findSourceFiles(this.options.repoPath);
159
207
  for (const file of sourceFiles.slice(0, 50)) {
160
208
  // Limit to 50 files for performance
@@ -232,6 +280,8 @@ Return as JSON with "dependencies" array.`;
232
280
  const now = new Date().toISOString();
233
281
 
234
282
  for (const [url, data] of allReferences) {
283
+ // Skip entries that will end up with low confidence
284
+ // (entries that can't be typed programmatically and LLM assigns low confidence)
235
285
  // Prepare context for potential LLM use
236
286
  const context = data.contexts
237
287
  .map((c) => `${c.file}${c.line ? `:${c.line}` : ''}: ${c.text}`)
@@ -269,6 +319,11 @@ Return as JSON with "dependencies" array.`;
269
319
  typeConfidence = 0.3;
270
320
  }
271
321
 
322
+ // Skip low-confidence entries
323
+ if (typeConfidence < 0.5) {
324
+ continue;
325
+ }
326
+
272
327
  // Step 5: Try programmatic access method determination
273
328
  let accessMethod: AccessMethod | null = this.determineAccessMethod(url);
274
329
 
@@ -364,6 +419,27 @@ Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
364
419
  context: { file: string; line?: number; text: string },
365
420
  detectionMethod: DetectionMethod
366
421
  ): void {
422
+ // Skip URLs that don't start with http:// or https://
423
+ if (!/^https?:\/\//.test(url)) {
424
+ return;
425
+ }
426
+
427
+ // Skip URLs matching skip patterns (placeholders, localhost, etc.)
428
+ if (this.skipUrlPatterns.some((pattern) => pattern.test(url))) {
429
+ return;
430
+ }
431
+
432
+ // Skip self-references (URLs pointing to the repo itself)
433
+ if (this.options.repoOwner && this.options.repoName) {
434
+ const selfPattern = new RegExp(
435
+ `github\\.com[/:]${this.escapeRegExp(this.options.repoOwner)}/${this.escapeRegExp(this.options.repoName)}(?:/|$|#|\\?)`,
436
+ 'i'
437
+ );
438
+ if (selfPattern.test(url)) {
439
+ return;
440
+ }
441
+ }
442
+
367
443
  if (!map.has(url)) {
368
444
  map.set(url, {
369
445
  url,
@@ -374,6 +450,10 @@ Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
374
450
  map.get(url)!.contexts.push(context);
375
451
  }
376
452
 
453
+ private escapeRegExp(str: string): string {
454
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
455
+ }
456
+
377
457
  /**
378
458
  * Programmatically determine access method based on URL patterns
379
459
  * Returns null if cannot be determined programmatically
@@ -412,6 +492,10 @@ Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
412
492
  const lowerUrl = url.toLowerCase();
413
493
  const lowerContext = context.toLowerCase();
414
494
 
495
+ const isGitHubRepositoryUrl = /^https?:\/\/github\.com\/[^/]+\/[^/#?]+(?:$|[/?#])/.test(
496
+ lowerUrl
497
+ );
498
+
415
499
  // Research papers
416
500
  if (
417
501
  lowerUrl.includes('arxiv.org') ||
@@ -447,10 +531,11 @@ Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
447
531
 
448
532
  // Reference implementations (GitHub repos)
449
533
  if (
450
- lowerUrl.includes('github.com') &&
451
- (lowerContext.includes('example') ||
452
- lowerContext.includes('implementation') ||
453
- lowerContext.includes('reference'))
534
+ isGitHubRepositoryUrl ||
535
+ (lowerUrl.includes('github.com') &&
536
+ (lowerContext.includes('example') ||
537
+ lowerContext.includes('implementation') ||
538
+ lowerContext.includes('reference')))
454
539
  ) {
455
540
  return 'reference-implementation';
456
541
  }
@@ -526,7 +611,7 @@ Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
526
611
  files.push(fullPath);
527
612
  }
528
613
  }
529
- } catch {
614
+ } catch {
530
615
  // Ignore errors (permission denied, etc.)
531
616
  }
532
617
 
@@ -560,11 +645,14 @@ Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
560
645
 
561
646
  private isDotDirectoryPath(filePath: string): boolean {
562
647
  const segments = normalize(filePath).split(sep);
563
- return segments.some((segment) => segment.startsWith('.') && segment.length > 1);
648
+ return segments.some(
649
+ (segment) =>
650
+ segment.startsWith('.') && segment.length > 1 && !ALLOWED_DOT_DIRECTORIES.has(segment)
651
+ );
564
652
  }
565
653
 
566
654
  private isDotDirectoryName(name: string): boolean {
567
- return name.startsWith('.') && name.length > 1;
655
+ return name.startsWith('.') && name.length > 1 && !ALLOWED_DOT_DIRECTORIES.has(name);
568
656
  }
569
657
 
570
658
  private async isGitIgnored(filePath: string, isDirectory: boolean): Promise<boolean> {
@@ -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,4 +1,20 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { Detector } from '../src/detector.js';
6
+
7
+ const createDetector = (): Detector =>
8
+ new Detector({
9
+ repoPath: '.',
10
+ llmProvider: {
11
+ analyze: vi.fn(async () => ({
12
+ dependencies: [],
13
+ usage: { totalTokens: 0, latencyMs: 0 },
14
+ rawResponse: '{}'
15
+ }))
16
+ }
17
+ });
2
18
 
3
19
  describe('Detector', () => {
4
20
  beforeEach(() => {
@@ -49,6 +65,47 @@ describe('Detector', () => {
49
65
  // Expected: Continue with parser results, log error
50
66
  expect(true).toBe(true);
51
67
  });
68
+
69
+ it('should keep GitHub repo links when LLM parser misses them', async () => {
70
+ const repoPath = await mkdtemp(join(tmpdir(), 'dependabit-detector-'));
71
+
72
+ try {
73
+ await mkdir(join(repoPath, 'docs'), { recursive: true });
74
+ await writeFile(
75
+ join(repoPath, 'docs', 'tooling.md'),
76
+ '- [spec-kit](https://github.com/github/spec-kit)\n'
77
+ );
78
+
79
+ const detector = new Detector({
80
+ repoPath,
81
+ llmProvider: {
82
+ analyze: vi.fn(async () => ({
83
+ dependencies: [],
84
+ usage: { totalTokens: 0, latencyMs: 0 },
85
+ rawResponse: '{}'
86
+ })),
87
+ getSupportedModels: vi.fn(() => ['test-model']),
88
+ getRateLimit: vi.fn(async () => ({
89
+ remaining: 100,
90
+ limit: 100,
91
+ resetAt: new Date(Date.now() + 60_000)
92
+ })),
93
+ validateConfig: vi.fn(() => true)
94
+ },
95
+ repoOwner: 'pradeepmouli',
96
+ repoName: 'dependabit'
97
+ });
98
+
99
+ const result = await detector.detectDependencies();
100
+ const dep = result.dependencies.find((d) => d.url === 'https://github.com/github/spec-kit');
101
+
102
+ expect(dep).toBeDefined();
103
+ expect(dep?.type).toBe('reference-implementation');
104
+ expect(dep?.accessMethod).toBe('github-api');
105
+ } finally {
106
+ await rm(repoPath, { recursive: true, force: true });
107
+ }
108
+ });
52
109
  });
53
110
 
54
111
  describe('classifyDependency', () => {
@@ -57,6 +114,16 @@ describe('Detector', () => {
57
114
  expect(url).toContain('github.com');
58
115
  });
59
116
 
117
+ it('should classify GitHub repository links as reference-implementation', () => {
118
+ const detector = createDetector();
119
+ const type = (detector as any).determineDependencyType(
120
+ 'https://github.com/github/spec-kit',
121
+ 'spec-kit'
122
+ );
123
+
124
+ expect(type).toBe('reference-implementation');
125
+ });
126
+
60
127
  it('should classify arXiv URLs as research-paper', () => {
61
128
  const url = 'https://arxiv.org/abs/1706.03762';
62
129
  expect(url).toContain('arxiv.org');