@dependabit/detector 0.1.15 → 0.1.17

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 (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/detector.d.ts +179 -12
  3. package/dist/detector.d.ts.map +1 -1
  4. package/dist/detector.js +121 -12
  5. package/dist/detector.js.map +1 -1
  6. package/dist/diff-parser.js.map +1 -1
  7. package/dist/llm/client.d.ts +111 -2
  8. package/dist/llm/client.d.ts.map +1 -1
  9. package/dist/llm/client.js +33 -1
  10. package/dist/llm/client.js.map +1 -1
  11. package/dist/llm/copilot.d.ts.map +1 -1
  12. package/dist/llm/copilot.js +1 -7
  13. package/dist/llm/copilot.js.map +1 -1
  14. package/dist/llm/prompts.d.ts +42 -2
  15. package/dist/llm/prompts.d.ts.map +1 -1
  16. package/dist/llm/prompts.js +42 -2
  17. package/dist/llm/prompts.js.map +1 -1
  18. package/dist/parsers/code-comments.js.map +1 -1
  19. package/dist/parsers/package-files.js.map +1 -1
  20. package/dist/parsers/readme.js.map +1 -1
  21. package/package.json +26 -9
  22. package/src/detector.ts +0 -1043
  23. package/src/diff-parser.ts +0 -257
  24. package/src/index.ts +0 -43
  25. package/src/llm/client.ts +0 -85
  26. package/src/llm/copilot.ts +0 -150
  27. package/src/llm/prompts.ts +0 -111
  28. package/src/parsers/code-comments.ts +0 -178
  29. package/src/parsers/package-files.ts +0 -156
  30. package/src/parsers/readme.ts +0 -191
  31. package/test/detector.test.ts +0 -169
  32. package/test/diff-parser.test.ts +0 -187
  33. package/test/llm/client.test.ts +0 -31
  34. package/test/llm/copilot.test.ts +0 -334
  35. package/test/parsers/code-comments.test.ts +0 -98
  36. package/test/parsers/package-files.test.ts +0 -52
  37. package/test/parsers/readme.test.ts +0 -52
  38. package/tsconfig.json +0 -10
  39. package/tsconfig.tsbuildinfo +0 -1
package/src/detector.ts DELETED
@@ -1,1043 +0,0 @@
1
- /**
2
- * Detector Orchestrator
3
- * Coordinates content parsers and LLM analysis to detect external dependencies
4
- */
5
-
6
- import { readdir, readFile } from 'node:fs/promises';
7
- import { execSync } from 'node:child_process';
8
- import { homedir } from 'node:os';
9
- import { basename, dirname, join, relative, resolve, normalize, sep } from 'node:path';
10
- import { randomUUID } from 'node:crypto';
11
- import ignore, { type Ignore } from 'ignore';
12
- import type { LLMProvider } from './llm/client.js';
13
- import { createClassificationPrompt } from './llm/prompts.js';
14
- import { parseReadme } from './parsers/readme.js';
15
- import { parseCodeComments } from './parsers/code-comments.js';
16
- import {
17
- parsePackageJson,
18
- parseRequirementsTxt,
19
- parseCargoToml,
20
- parseGoMod
21
- } from './parsers/package-files.js';
22
- import type {
23
- DependencyEntry,
24
- DependencyType,
25
- AccessMethod,
26
- DetectionMethod
27
- } from '@dependabit/manifest';
28
-
29
- export interface DetectorOptions {
30
- repoPath: string;
31
- llmProvider: LLMProvider;
32
- ignorePatterns?: string[];
33
- useGitExcludes?: boolean;
34
- repoOwner?: string;
35
- repoName?: string;
36
- }
37
-
38
- export interface DetectionResult {
39
- dependencies: DependencyEntry[];
40
- statistics: {
41
- filesScanned: number;
42
- urlsFound: number;
43
- llmCalls: number;
44
- totalTokens: number;
45
- totalLatencyMs: number;
46
- };
47
- }
48
-
49
- const DEFAULT_IGNORE_PATTERNS = [
50
- 'node_modules',
51
- 'dist',
52
- 'build',
53
- 'target',
54
- 'vendor',
55
- 'venv',
56
- '__pycache__',
57
- 'coverage'
58
- ];
59
-
60
- const ALLOWED_DOT_DIRECTORIES = new Set<string>();
61
-
62
- /**
63
- * Main detector class
64
- */
65
- export class Detector {
66
- private options: Required<DetectorOptions>;
67
- private ignoreMatcher: Ignore | null = null;
68
- private ignoreMatcherLoaded = false;
69
- private skipUrlPatterns: RegExp[];
70
-
71
- constructor(options: DetectorOptions) {
72
- this.options = {
73
- ...options,
74
- ignorePatterns: options.ignorePatterns || DEFAULT_IGNORE_PATTERNS,
75
- useGitExcludes: options.useGitExcludes ?? true,
76
- repoOwner: options.repoOwner || '',
77
- repoName: options.repoName || ''
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
- ];
101
- }
102
-
103
- /**
104
- * Detect all external dependencies in the repository
105
- *
106
- * Implementation follows a hybrid approach:
107
- * 1. Programmatic parsing of repository files (README, code comments, package files)
108
- * 2. LLM analysis only for documents not fully parsed in step 1 (future enhancement)
109
- * 3. Programmatic type categorization based on URL patterns and context
110
- * 4. LLM fallback for uncategorized dependencies
111
- * 5. Programmatic access method determination based on URL patterns
112
- * 6. LLM fallback for access methods that can't be determined (future enhancement)
113
- * 7. Manifest entry creation with references and versioning
114
- */
115
- async detectDependencies(): Promise<DetectionResult> {
116
- const allReferences: Map<
117
- string,
118
- {
119
- url: string;
120
- contexts: Array<{ file: string; line?: number; text: string }>;
121
- detectionMethod: DetectionMethod;
122
- }
123
- > = new Map();
124
-
125
- let filesScanned = 0;
126
- let llmCalls = 0;
127
- let totalTokens = 0;
128
- let totalLatencyMs = 0;
129
-
130
- // Step 1: Parse repository for dependencies (programmatic)
131
- // 1a. Parse README files
132
- const readmeFiles = await this.findFiles(this.options.repoPath, /^README/i);
133
- for (const file of readmeFiles) {
134
- const content = await readFile(file, 'utf-8');
135
- const references = parseReadme(content, relative(this.options.repoPath, file));
136
-
137
- for (const ref of references) {
138
- this.addReference(
139
- allReferences,
140
- ref.url,
141
- {
142
- file: relative(this.options.repoPath, file),
143
- ...(ref.line !== undefined && { line: ref.line }),
144
- text: ref.context
145
- },
146
- 'llm-analysis'
147
- );
148
- }
149
-
150
- filesScanned++;
151
- }
152
-
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)
180
- const packageFiles = await this.findPackageFiles(this.options.repoPath);
181
- for (const file of packageFiles) {
182
- const content = await readFile(file, 'utf-8');
183
- const metadata = this.parsePackageFile(file, content);
184
-
185
- for (const url of [
186
- ...(metadata.urls || []),
187
- metadata.repository,
188
- metadata.homepage,
189
- metadata.documentation
190
- ].filter(Boolean)) {
191
- this.addReference(
192
- allReferences,
193
- url!,
194
- {
195
- file: relative(this.options.repoPath, file),
196
- text: 'Package metadata'
197
- },
198
- 'package-json'
199
- );
200
- }
201
-
202
- filesScanned++;
203
- }
204
-
205
- // 1d. Parse code comments from source files
206
- const sourceFiles = await this.findSourceFiles(this.options.repoPath);
207
- for (const file of sourceFiles.slice(0, 50)) {
208
- // Limit to 50 files for performance
209
- const content = await readFile(file, 'utf-8');
210
- const references = parseCodeComments(content, relative(this.options.repoPath, file));
211
-
212
- for (const ref of references) {
213
- this.addReference(
214
- allReferences,
215
- ref.url,
216
- {
217
- file: ref.file,
218
- line: ref.line,
219
- text: ref.context
220
- },
221
- 'code-comment'
222
- );
223
- }
224
-
225
- filesScanned++;
226
- }
227
-
228
- // Step 2: LLM 2nd pass for documents not fully parsed in step 1
229
- // Analyze README files for dependency context that parsers might have missed
230
- const llmEnhancedReferences = new Set<string>();
231
-
232
- for (const file of readmeFiles.slice(0, 5)) {
233
- // Limit to 5 READMEs for LLM analysis
234
- try {
235
- const content = await readFile(file, 'utf-8');
236
- const relPath = relative(this.options.repoPath, file);
237
-
238
- // Use LLM to extract additional context from README
239
- const detectionPrompt = `Analyze this README file and identify any external dependencies or resources that might be referenced but not explicitly linked:
240
-
241
- File: ${relPath}
242
- Content:
243
- ${content.slice(0, 5000)}
244
-
245
- Identify:
246
- 1. Documentation sites mentioned but not linked
247
- 2. Tools or libraries referenced in text
248
- 3. API services mentioned
249
- 4. Research papers or specifications cited
250
-
251
- Return as JSON with "dependencies" array.`;
252
-
253
- const response = await this.options.llmProvider.analyze(content, detectionPrompt);
254
- llmCalls++;
255
- totalTokens += response.usage.totalTokens;
256
- totalLatencyMs += response.usage.latencyMs;
257
-
258
- // Add LLM-discovered references
259
- for (const dep of response.dependencies) {
260
- if (dep.url && !allReferences.has(dep.url)) {
261
- llmEnhancedReferences.add(dep.url);
262
- this.addReference(
263
- allReferences,
264
- dep.url,
265
- {
266
- file: relPath,
267
- text: dep.description || 'Discovered by LLM analysis'
268
- },
269
- 'llm-analysis'
270
- );
271
- }
272
- }
273
- } catch (error) {
274
- console.error(`LLM document analysis failed for ${file}:`, error);
275
- }
276
- }
277
-
278
- // Steps 3-6: Categorize dependencies (programmatic first, LLM fallback)
279
- const dependencies: DependencyEntry[] = [];
280
- const now = new Date().toISOString();
281
-
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)
285
- // Prepare context for potential LLM use
286
- const context = data.contexts
287
- .map((c) => `${c.file}${c.line ? `:${c.line}` : ''}: ${c.text}`)
288
- .join('\n');
289
- const firstContext = data.contexts[0]?.text || '';
290
-
291
- // Step 3: Try programmatic type categorization
292
- let type: DependencyType | null = this.determineDependencyType(url, firstContext);
293
- let typeConfidence = type ? 0.9 : 0.5; // High confidence for programmatic
294
-
295
- // Step 4: If type couldn't be determined, use LLM fallback
296
- if (!type) {
297
- try {
298
- const classificationPrompt = createClassificationPrompt(url, context);
299
- const response = await this.options.llmProvider.analyze('', classificationPrompt);
300
- llmCalls++;
301
- totalTokens += response.usage.totalTokens;
302
- totalLatencyMs += response.usage.latencyMs;
303
-
304
- if (response.dependencies.length > 0) {
305
- const dep = response.dependencies[0];
306
- if (dep) {
307
- type = dep.type as DependencyType;
308
- typeConfidence = dep.confidence;
309
- }
310
- }
311
- } catch (error) {
312
- console.error(`LLM classification failed for ${url}:`, error);
313
- }
314
- }
315
-
316
- // Default to 'other' if still not determined
317
- if (!type) {
318
- type = 'other';
319
- typeConfidence = 0.3;
320
- }
321
-
322
- // Skip low-confidence entries
323
- if (typeConfidence < 0.5) {
324
- continue;
325
- }
326
-
327
- // Step 5: Try programmatic access method determination
328
- let accessMethod: AccessMethod | null = this.determineAccessMethod(url);
329
-
330
- // Step 6: If access method couldn't be determined, use LLM fallback
331
- if (!accessMethod) {
332
- try {
333
- const accessMethodPrompt = `Determine the best access method for this URL: ${url}
334
-
335
- Context: ${firstContext}
336
-
337
- Choose ONE of these access methods:
338
- - "github-api": For GitHub repositories
339
- - "arxiv": For arXiv papers
340
- - "openapi": For API specifications
341
- - "context7": For Context7 documentation
342
- - "http": For general web resources
343
-
344
- Return as JSON: {"accessMethod": "...", "confidence": 0.0-1.0}`;
345
-
346
- const response = await this.options.llmProvider.analyze('', accessMethodPrompt);
347
- llmCalls++;
348
- totalTokens += response.usage.totalTokens;
349
- totalLatencyMs += response.usage.latencyMs;
350
-
351
- // Parse LLM response for access method
352
- const content = response.rawResponse || '{}';
353
- try {
354
- const parsed = JSON.parse(content);
355
- if (parsed.accessMethod) {
356
- accessMethod = parsed.accessMethod as AccessMethod;
357
- }
358
- } catch {
359
- // If parsing fails, fall back to http
360
- accessMethod = 'http';
361
- }
362
- } catch (error) {
363
- console.error(`LLM access method determination failed for ${url}:`, error);
364
- accessMethod = 'http';
365
- }
366
- }
367
-
368
- // Ensure we have a valid access method
369
- if (!accessMethod) {
370
- accessMethod = 'http';
371
- }
372
-
373
- // Step 7: Create manifest entry with references and versioning
374
- const entry: DependencyEntry = {
375
- id: randomUUID(),
376
- url,
377
- type,
378
- accessMethod,
379
- name: this.extractName(url),
380
- description: firstContext,
381
- currentVersion: undefined,
382
- currentStateHash: '', // Will be populated by monitor
383
- detectionMethod: data.detectionMethod,
384
- detectionConfidence: typeConfidence,
385
- detectedAt: now,
386
- lastChecked: now,
387
- auth: undefined,
388
- monitoring: {
389
- enabled: true,
390
- checkFrequency: 'daily',
391
- ignoreChanges: false
392
- },
393
- referencedIn: data.contexts.map((c) => ({
394
- file: c.file,
395
- line: c.line,
396
- context: c.text
397
- })),
398
- changeHistory: []
399
- };
400
-
401
- dependencies.push(entry);
402
- }
403
-
404
- return {
405
- dependencies,
406
- statistics: {
407
- filesScanned,
408
- urlsFound: allReferences.size,
409
- llmCalls,
410
- totalTokens,
411
- totalLatencyMs
412
- }
413
- };
414
- }
415
-
416
- private addReference(
417
- map: Map<string, any>,
418
- url: string,
419
- context: { file: string; line?: number; text: string },
420
- detectionMethod: DetectionMethod
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
-
443
- if (!map.has(url)) {
444
- map.set(url, {
445
- url,
446
- contexts: [],
447
- detectionMethod
448
- });
449
- }
450
- map.get(url)!.contexts.push(context);
451
- }
452
-
453
- private escapeRegExp(str: string): string {
454
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
455
- }
456
-
457
- /**
458
- * Programmatically determine access method based on URL patterns
459
- * Returns null if cannot be determined programmatically
460
- */
461
- private determineAccessMethod(url: string): AccessMethod | null {
462
- // GitHub URLs
463
- if (url.includes('github.com')) return 'github-api';
464
-
465
- // arXiv papers
466
- if (url.includes('arxiv.org')) return 'arxiv';
467
-
468
- // OpenAPI/Swagger specs
469
- if (
470
- url.includes('openapi') ||
471
- url.includes('swagger') ||
472
- url.endsWith('.yaml') ||
473
- url.endsWith('.json') ||
474
- url.includes('/api/spec') ||
475
- url.includes('/api-docs')
476
- ) {
477
- return 'openapi';
478
- }
479
-
480
- // Context7 documentation
481
- if (url.includes('context7')) return 'context7';
482
-
483
- // Cannot determine programmatically - needs LLM
484
- return null;
485
- }
486
-
487
- /**
488
- * Programmatically determine dependency type based on URL patterns and context
489
- * Returns null if cannot be determined programmatically
490
- */
491
- private determineDependencyType(url: string, context: string): DependencyType | null {
492
- const lowerUrl = url.toLowerCase();
493
- const lowerContext = context.toLowerCase();
494
-
495
- const isGitHubRepositoryUrl = /^https?:\/\/github\.com\/[^/]+\/[^/#?]+(?:$|[/?#])/.test(
496
- lowerUrl
497
- );
498
-
499
- // Research papers
500
- if (
501
- lowerUrl.includes('arxiv.org') ||
502
- lowerContext.includes('paper') ||
503
- lowerContext.includes('research')
504
- ) {
505
- return 'research-paper';
506
- }
507
-
508
- // Schemas
509
- if (
510
- lowerUrl.includes('schema') ||
511
- lowerUrl.includes('openapi') ||
512
- lowerUrl.includes('swagger') ||
513
- lowerUrl.includes('graphql') ||
514
- lowerUrl.includes('protobuf')
515
- ) {
516
- return 'schema';
517
- }
518
-
519
- // Documentation
520
- if (
521
- lowerUrl.includes('/docs') ||
522
- lowerUrl.includes('/documentation') ||
523
- lowerUrl.includes('/guide') ||
524
- lowerUrl.includes('/tutorial') ||
525
- lowerUrl.includes('/reference') ||
526
- lowerContext.includes('documentation') ||
527
- lowerContext.includes('docs')
528
- ) {
529
- return 'documentation';
530
- }
531
-
532
- // Reference implementations (GitHub repos)
533
- if (
534
- isGitHubRepositoryUrl ||
535
- (lowerUrl.includes('github.com') &&
536
- (lowerContext.includes('example') ||
537
- lowerContext.includes('implementation') ||
538
- lowerContext.includes('reference')))
539
- ) {
540
- return 'reference-implementation';
541
- }
542
-
543
- // API examples
544
- if (
545
- lowerContext.includes('example') &&
546
- (lowerContext.includes('api') || lowerContext.includes('endpoint'))
547
- ) {
548
- return 'api-example';
549
- }
550
-
551
- // Cannot determine programmatically - needs LLM
552
- return null;
553
- }
554
-
555
- private extractName(url: string): string {
556
- // Extract a reasonable name from URL
557
- try {
558
- const urlObj = new URL(url);
559
- const pathParts = urlObj.pathname.split('/').filter(Boolean);
560
- if (pathParts.length > 0) {
561
- const lastPart = pathParts[pathParts.length - 1];
562
- if (lastPart) {
563
- return lastPart.replace(/\.[^.]+$/, '');
564
- }
565
- }
566
- return urlObj.hostname;
567
- } catch {
568
- return url;
569
- }
570
- }
571
-
572
- private parsePackageFile(
573
- filePath: string,
574
- content: string
575
- ): { urls: string[]; repository?: string; homepage?: string; documentation?: string } {
576
- const fileName = filePath.split('/').pop() || '';
577
-
578
- if (fileName === 'package.json') {
579
- return parsePackageJson(content);
580
- }
581
- if (fileName === 'requirements.txt') {
582
- return parseRequirementsTxt(content);
583
- }
584
- if (fileName === 'Cargo.toml') {
585
- return parseCargoToml(content);
586
- }
587
- if (fileName === 'go.mod') {
588
- return parseGoMod(content);
589
- }
590
-
591
- return { urls: [] };
592
- }
593
-
594
- private async findFiles(dir: string, pattern: RegExp): Promise<string[]> {
595
- const files: string[] = [];
596
-
597
- try {
598
- const entries = await readdir(dir, { withFileTypes: true });
599
-
600
- for (const entry of entries) {
601
- const fullPath = join(dir, entry.name);
602
-
603
- if (await this.shouldIgnorePath(fullPath, entry.isDirectory())) {
604
- continue;
605
- }
606
-
607
- if (entry.isDirectory()) {
608
- const subFiles = await this.findFiles(fullPath, pattern);
609
- files.push(...subFiles);
610
- } else if (pattern.test(entry.name)) {
611
- files.push(fullPath);
612
- }
613
- }
614
- } catch {
615
- // Ignore errors (permission denied, etc.)
616
- }
617
-
618
- return files;
619
- }
620
-
621
- private async findPackageFiles(dir: string): Promise<string[]> {
622
- return this.findFiles(dir, /^(package\.json|requirements\.txt|Cargo\.toml|go\.mod)$/);
623
- }
624
-
625
- private async findSourceFiles(dir: string): Promise<string[]> {
626
- return this.findFiles(dir, /\.(ts|js|tsx|jsx|py|rs|go|java|kt|cs|rb|php)$/);
627
- }
628
-
629
- private shouldIgnore(name: string): boolean {
630
- return this.options.ignorePatterns.some((pattern) => name.includes(pattern));
631
- }
632
-
633
- private async shouldIgnorePath(filePath: string, isDirectory = false): Promise<boolean> {
634
- if (this.isDotDirectoryPath(filePath)) {
635
- return true;
636
- }
637
-
638
- const segments = normalize(filePath).split(sep);
639
- if (segments.some((segment) => this.shouldIgnore(segment))) {
640
- return true;
641
- }
642
-
643
- return await this.isGitIgnored(filePath, isDirectory);
644
- }
645
-
646
- private isDotDirectoryPath(filePath: string): boolean {
647
- const segments = normalize(filePath).split(sep);
648
- return segments.some(
649
- (segment) =>
650
- segment.startsWith('.') && segment.length > 1 && !ALLOWED_DOT_DIRECTORIES.has(segment)
651
- );
652
- }
653
-
654
- private isDotDirectoryName(name: string): boolean {
655
- return name.startsWith('.') && name.length > 1 && !ALLOWED_DOT_DIRECTORIES.has(name);
656
- }
657
-
658
- private async isGitIgnored(filePath: string, isDirectory: boolean): Promise<boolean> {
659
- if (!this.options.useGitExcludes) {
660
- return false;
661
- }
662
- const matcher = await this.getIgnoreMatcher();
663
- if (!matcher) {
664
- return false;
665
- }
666
-
667
- const relativePath = this.getRepoRelativePath(filePath);
668
- if (!relativePath) {
669
- return false;
670
- }
671
-
672
- const normalized = relativePath.split(sep).join('/');
673
- const testPath = isDirectory ? `${normalized}/` : normalized;
674
- return matcher.ignores(testPath);
675
- }
676
-
677
- private getRepoRelativePath(filePath: string): string | null {
678
- const repoPath = resolve(normalize(this.options.repoPath));
679
- const normalizedPath = resolve(normalize(filePath));
680
-
681
- if (normalizedPath === repoPath) {
682
- return '';
683
- }
684
-
685
- if (normalizedPath.startsWith(repoPath + sep)) {
686
- return relative(repoPath, normalizedPath);
687
- }
688
-
689
- return normalize(filePath);
690
- }
691
-
692
- private async getIgnoreMatcher(): Promise<Ignore | null> {
693
- if (this.ignoreMatcherLoaded) {
694
- return this.ignoreMatcher;
695
- }
696
-
697
- this.ignoreMatcherLoaded = true;
698
-
699
- if (!this.options.useGitExcludes) {
700
- this.ignoreMatcher = null;
701
- return this.ignoreMatcher;
702
- }
703
-
704
- try {
705
- const matcher = ignore();
706
- const gitignoreFiles = await this.collectGitignoreFiles(this.options.repoPath);
707
- const extraIgnoreFiles = this.collectExtraIgnoreFiles();
708
-
709
- for (const filePath of gitignoreFiles) {
710
- const content = await readFile(filePath, 'utf-8');
711
- const rules = this.prefixGitignoreRules(filePath, content);
712
- if (rules.length > 0) {
713
- matcher.add(rules);
714
- }
715
- }
716
-
717
- for (const filePath of extraIgnoreFiles) {
718
- try {
719
- const content = await readFile(filePath, 'utf-8');
720
- const rules = this.prefixGitignoreRules(filePath, content, '');
721
- if (rules.length > 0) {
722
- matcher.add(rules);
723
- }
724
- } catch {
725
- // Ignore missing or unreadable global ignore files
726
- }
727
- }
728
-
729
- this.ignoreMatcher = matcher;
730
- } catch {
731
- this.ignoreMatcher = null;
732
- }
733
-
734
- return this.ignoreMatcher;
735
- }
736
-
737
- private async collectGitignoreFiles(dir: string): Promise<string[]> {
738
- const files: string[] = [];
739
-
740
- try {
741
- const entries = await readdir(dir, { withFileTypes: true });
742
- for (const entry of entries) {
743
- if (entry.isDirectory()) {
744
- if (this.isDotDirectoryName(entry.name) || this.shouldIgnore(entry.name)) {
745
- continue;
746
- }
747
- files.push(...(await this.collectGitignoreFiles(join(dir, entry.name))));
748
- } else if (entry.isFile() && entry.name === '.gitignore') {
749
- files.push(join(dir, entry.name));
750
- }
751
- }
752
- } catch {
753
- // Ignore errors
754
- }
755
-
756
- return files;
757
- }
758
-
759
- private prefixGitignoreRules(filePath: string, content: string, basePrefix?: string): string[] {
760
- const repoPath = resolve(normalize(this.options.repoPath));
761
- const ignoreDir = dirname(filePath);
762
- const relativeDir = relative(repoPath, ignoreDir).split(sep).join('/');
763
- const prefix = basePrefix !== undefined ? basePrefix : relativeDir ? `${relativeDir}/` : '';
764
-
765
- return content
766
- .split('\n')
767
- .map((line) => line.trim())
768
- .filter((line) => line && !line.startsWith('#'))
769
- .map((line) => {
770
- const negated = line.startsWith('!');
771
- const raw = negated ? line.slice(1) : line;
772
- const trimmed = raw.startsWith('/') ? raw.slice(1) : raw;
773
- const scoped = `${prefix}${trimmed}`;
774
- return negated ? `!${scoped}` : scoped;
775
- });
776
- }
777
-
778
- private collectExtraIgnoreFiles(): string[] {
779
- const files = new Set<string>();
780
-
781
- files.add(join(this.options.repoPath, '.git', 'info', 'exclude'));
782
-
783
- const globalConfig = this.getGlobalExcludeFileFromGit();
784
- if (globalConfig) {
785
- files.add(globalConfig);
786
- }
787
-
788
- for (const fallback of this.getDefaultGlobalExcludeFiles()) {
789
- files.add(fallback);
790
- }
791
-
792
- return Array.from(files);
793
- }
794
-
795
- private getGlobalExcludeFileFromGit(): string | null {
796
- try {
797
- const output = execSync('git config --get core.excludesfile', {
798
- stdio: ['ignore', 'pipe', 'ignore']
799
- })
800
- .toString()
801
- .trim();
802
-
803
- if (!output) {
804
- return null;
805
- }
806
-
807
- return this.expandHomePath(output);
808
- } catch {
809
- return null;
810
- }
811
- }
812
-
813
- private getDefaultGlobalExcludeFiles(): string[] {
814
- const home = homedir();
815
- return [
816
- join(home, '.config', 'git', 'ignore'),
817
- join(home, '.gitignore_global'),
818
- join(home, '.gitignore')
819
- ];
820
- }
821
-
822
- private expandHomePath(filePath: string): string {
823
- if (filePath.startsWith('~/')) {
824
- return join(homedir(), filePath.slice(2));
825
- }
826
- if (filePath === '~') {
827
- return homedir();
828
- }
829
- return filePath;
830
- }
831
-
832
- /**
833
- * Analyze only specific files for dependencies (for incremental updates)
834
- * This is more efficient than full repository scan when only few files changed
835
- */
836
- async analyzeFiles(filePaths: string[]): Promise<DetectionResult> {
837
- const allReferences: Map<
838
- string,
839
- {
840
- url: string;
841
- contexts: Array<{ file: string; line?: number; text: string }>;
842
- detectionMethod: DetectionMethod;
843
- }
844
- > = new Map();
845
-
846
- let filesScanned = 0;
847
- let llmCalls = 0;
848
- let totalTokens = 0;
849
- let totalLatencyMs = 0;
850
-
851
- for (const filePath of filePaths) {
852
- if (await this.shouldIgnorePath(filePath)) {
853
- continue;
854
- }
855
- // Validate that the file path is safe before joining
856
- const normalizedRepoPath = resolve(normalize(this.options.repoPath));
857
- const normalizedFilePath = normalize(filePath);
858
- const fullPath = resolve(normalizedRepoPath, normalizedFilePath);
859
-
860
- // Ensure the resolved path is within the repository boundaries
861
- // Use path.sep for cross-platform compatibility
862
- const repoPathWithSep = normalizedRepoPath.endsWith(sep)
863
- ? normalizedRepoPath
864
- : normalizedRepoPath + sep;
865
-
866
- if (!fullPath.startsWith(repoPathWithSep) && fullPath !== normalizedRepoPath) {
867
- // Skip files outside the repository to prevent path traversal
868
- if (process.env['DEBUG']) {
869
- console.warn(`Skipping file outside repository: ${filePath}`);
870
- }
871
- continue;
872
- }
873
-
874
- try {
875
- const content = await readFile(fullPath, 'utf-8');
876
- const relativePath = relative(this.options.repoPath, fullPath);
877
- const fileName = filePath.split('/').pop() || '';
878
-
879
- // Parse based on file type
880
- if (/^README/i.test(fileName)) {
881
- // README file
882
- const references = parseReadme(content, relativePath);
883
- for (const ref of references) {
884
- this.addReference(
885
- allReferences,
886
- ref.url,
887
- {
888
- file: relativePath,
889
- ...(ref.line !== undefined && { line: ref.line }),
890
- text: ref.context
891
- },
892
- 'llm-analysis'
893
- );
894
- }
895
- } else if (/^(package\.json|requirements\.txt|Cargo\.toml|go\.mod)$/i.test(fileName)) {
896
- // Package file
897
- const metadata = this.parsePackageFile(fullPath, content);
898
- for (const url of [
899
- ...(metadata.urls || []),
900
- metadata.repository,
901
- metadata.homepage,
902
- metadata.documentation
903
- ].filter(Boolean)) {
904
- this.addReference(
905
- allReferences,
906
- url!,
907
- {
908
- file: relativePath,
909
- text: 'Package metadata'
910
- },
911
- 'package-json'
912
- );
913
- }
914
- } else if (/\.(ts|js|tsx|jsx|py|rs|go|java|kt|cs|rb|php)$/.test(fileName)) {
915
- // Source file
916
- const references = parseCodeComments(content, relativePath);
917
- for (const ref of references) {
918
- this.addReference(
919
- allReferences,
920
- ref.url,
921
- {
922
- file: ref.file,
923
- line: ref.line,
924
- text: ref.context
925
- },
926
- 'code-comment'
927
- );
928
- }
929
- } else if (/\.(md|txt|rst|adoc)$/.test(fileName)) {
930
- // Documentation file
931
- const references = parseReadme(content, relativePath);
932
- for (const ref of references) {
933
- this.addReference(
934
- allReferences,
935
- ref.url,
936
- {
937
- file: relativePath,
938
- ...(ref.line !== undefined && { line: ref.line }),
939
- text: ref.context
940
- },
941
- 'llm-analysis'
942
- );
943
- }
944
- }
945
-
946
- filesScanned++;
947
- } catch (error) {
948
- // Skip files that can't be read - log but don't throw
949
- // Using console.warn since we don't have a logger instance here
950
- const message = error instanceof Error ? error.message : String(error);
951
- console.warn(`Failed to analyze ${filePath}: ${message}`);
952
- if (process.env['DEBUG']) {
953
- // Log full error details when DEBUG is enabled
954
- console.debug('Full error while analyzing %s:', filePath, error);
955
- }
956
- }
957
- }
958
-
959
- // Create dependency entries
960
- const dependencies: DependencyEntry[] = [];
961
-
962
- for (const [url, refData] of allReferences.entries()) {
963
- const contextText = refData.contexts.map((c) => c.text).join(' ');
964
-
965
- // Step 3: Programmatic type categorization
966
- let type = this.determineDependencyType(url, contextText);
967
-
968
- // Step 4: LLM fallback for type categorization (if needed)
969
- if (!type && refData.contexts.length > 0) {
970
- const startTime = Date.now();
971
- try {
972
- const prompt = createClassificationPrompt(url, contextText);
973
- const response = await this.options.llmProvider.analyze('', prompt);
974
-
975
- llmCalls++;
976
- totalTokens += response.usage?.totalTokens || 0;
977
- totalLatencyMs += Date.now() - startTime;
978
-
979
- // Use rawResponse for classification
980
- const responseText = (response.rawResponse || '').toLowerCase();
981
- type = (
982
- responseText.includes('schema')
983
- ? 'schema'
984
- : responseText.includes('documentation')
985
- ? 'documentation'
986
- : responseText.includes('research') || responseText.includes('paper')
987
- ? 'research-paper'
988
- : responseText.includes('implementation')
989
- ? 'reference-implementation'
990
- : responseText.includes('example')
991
- ? 'api-example'
992
- : 'other'
993
- ) as DependencyType;
994
- } catch {
995
- type = 'other';
996
- }
997
- }
998
-
999
- if (!type) {
1000
- type = 'other';
1001
- }
1002
-
1003
- // Step 5: Programmatic access method determination
1004
- let accessMethod = this.determineAccessMethod(url);
1005
- if (!accessMethod) {
1006
- accessMethod = 'http'; // Default fallback
1007
- }
1008
-
1009
- const dependency: DependencyEntry = {
1010
- id: randomUUID(),
1011
- url,
1012
- type,
1013
- accessMethod,
1014
- name: this.extractName(url),
1015
- currentStateHash: `sha256:pending`,
1016
- detectionMethod: refData.detectionMethod,
1017
- detectionConfidence: refData.detectionMethod === 'manual' ? 1.0 : 0.85,
1018
- detectedAt: new Date().toISOString(),
1019
- lastChecked: new Date().toISOString(),
1020
- auth: undefined,
1021
- referencedIn: refData.contexts.map((ctx) => ({
1022
- file: ctx.file,
1023
- line: ctx.line,
1024
- context: ctx.text
1025
- })),
1026
- changeHistory: []
1027
- };
1028
-
1029
- dependencies.push(dependency);
1030
- }
1031
-
1032
- return {
1033
- dependencies,
1034
- statistics: {
1035
- filesScanned,
1036
- urlsFound: allReferences.size,
1037
- llmCalls,
1038
- totalTokens,
1039
- totalLatencyMs
1040
- }
1041
- };
1042
- }
1043
- }