src2purl 1.3.2__py3-none-any.whl

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.
@@ -0,0 +1,796 @@
1
+ """Main orchestrator for the SH Package Identifier."""
2
+
3
+ import asyncio
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import List, Optional, Tuple
7
+
8
+ from rich.console import Console
9
+ from rich.progress import Progress, SpinnerColumn, TextColumn
10
+
11
+ from src2id.core.config import SWHPIConfig
12
+ from src2id.core.models import DirectoryCandidate, ContentCandidate, MatchType, PackageMatch, SHOriginMatch
13
+ from src2id.search import SourceIdentifier, create_default_registry
14
+
15
+ console = Console()
16
+
17
+
18
+ class SHPackageIdentifier:
19
+ """Main orchestrator class that coordinates all components."""
20
+
21
+ def __init__(self, config: Optional[SWHPIConfig] = None):
22
+ """Initialize the package identifier with configuration."""
23
+ self.config = config or SWHPIConfig()
24
+
25
+ # Components will be lazily initialized
26
+ self._swhid_generator = None
27
+ self._sh_client = None
28
+ self._scanner = None
29
+ self._coordinate_extractor = None
30
+ self._confidence_scorer = None
31
+ self._purl_generator = None
32
+ self._source_identifier = None
33
+ self._search_registry = None
34
+ self._upmex_integration = None
35
+
36
+ @property
37
+ def swhid_generator(self):
38
+ """Lazy load SWHID generator."""
39
+ if self._swhid_generator is None:
40
+ from src2id.core.swhid import SWHIDGenerator
41
+ self._swhid_generator = SWHIDGenerator()
42
+ return self._swhid_generator
43
+
44
+ @property
45
+ def sh_client(self):
46
+ """Lazy load Software Heritage client."""
47
+ if self._sh_client is None:
48
+ from src2id.core.client import SoftwareHeritageClient
49
+ self._sh_client = SoftwareHeritageClient(self.config)
50
+ return self._sh_client
51
+
52
+ @property
53
+ def scanner(self):
54
+ """Lazy load directory scanner."""
55
+ if self._scanner is None:
56
+ from src2id.core.scanner import DirectoryScanner
57
+ self._scanner = DirectoryScanner(self.config, self.swhid_generator)
58
+ return self._scanner
59
+
60
+ @property
61
+ def coordinate_extractor(self):
62
+ """Lazy load package coordinate extractor."""
63
+ if self._coordinate_extractor is None:
64
+ from src2id.core.extractor import PackageCoordinateExtractor
65
+ self._coordinate_extractor = PackageCoordinateExtractor()
66
+ return self._coordinate_extractor
67
+
68
+ @property
69
+ def confidence_scorer(self):
70
+ """Lazy load confidence scorer."""
71
+ if self._confidence_scorer is None:
72
+ from src2id.core.scorer import ConfidenceScorer
73
+ self._confidence_scorer = ConfidenceScorer(self.config)
74
+ return self._confidence_scorer
75
+
76
+ @property
77
+ def purl_generator(self):
78
+ """Lazy load PURL generator."""
79
+ if self._purl_generator is None:
80
+ from src2id.core.purl import PURLGenerator
81
+ self._purl_generator = PURLGenerator()
82
+ return self._purl_generator
83
+
84
+ @property
85
+ def source_identifier(self):
86
+ """Lazy load source identifier."""
87
+ if self._source_identifier is None:
88
+ self._source_identifier = SourceIdentifier(
89
+ swh_client=self.sh_client,
90
+ search_registry=self.search_registry,
91
+ verbose=self.config.verbose
92
+ )
93
+ return self._source_identifier
94
+
95
+ @property
96
+ def search_registry(self):
97
+ """Lazy load search provider registry."""
98
+ if self._search_registry is None:
99
+ self._search_registry = create_default_registry(verbose=self.config.verbose)
100
+ return self._search_registry
101
+
102
+ @property
103
+ def upmex_integration(self):
104
+ """Lazy load UPMEX integration."""
105
+ if self._upmex_integration is None:
106
+ from src2id.integrations.upmex import UpmexIntegration
107
+ self._upmex_integration = UpmexIntegration(enabled=True)
108
+ return self._upmex_integration
109
+
110
+ async def identify_packages(self, path: Path, enhance_licenses: bool = True) -> List[PackageMatch]:
111
+ """
112
+ Main entry point for package identification.
113
+
114
+ Args:
115
+ path: Directory path to analyze
116
+ enhance_licenses: Whether to use oslili for license enhancement
117
+
118
+ Returns:
119
+ List of package matches found
120
+ """
121
+ try:
122
+ if self.config.verbose:
123
+ console.print("[bold blue]Starting package identification...[/bold blue]")
124
+
125
+ # Step 1: PRIMARY - Hash-based discovery (SWHIDs + Software Heritage + Web Search)
126
+ if self.config.verbose:
127
+ console.print("[bold blue]Phase 1: Hash-based package discovery[/bold blue]")
128
+
129
+ dir_candidates, file_candidates = await self._scan_directories(path)
130
+
131
+ # Phase 1: Hash-based discovery
132
+ hash_based_matches = []
133
+
134
+ if dir_candidates or file_candidates:
135
+ # Step 1a: Query for matches based on configuration
136
+ if self.config.use_swh:
137
+ # Query Software Heritage for matches (both dirs and files)
138
+ all_matches = await self._find_matches(dir_candidates, file_candidates)
139
+ else:
140
+ all_matches = []
141
+ if self.config.verbose:
142
+ console.print("[dim]Skipping Software Heritage (use --use-swh to enable)[/dim]")
143
+
144
+ if not all_matches:
145
+ # Try keyword search with GitHub and SCANOSS
146
+ if self.config.verbose:
147
+ console.print("[yellow]Trying web search (GitHub, SCANOSS)[/yellow]")
148
+
149
+ # Try keyword search
150
+ keyword_matches = await self._find_keyword_matches(path)
151
+ if keyword_matches:
152
+ all_matches = keyword_matches
153
+
154
+ # Step 1b: Process hash-based matches
155
+ if all_matches:
156
+ hash_based_matches = await self._process_matches(all_matches)
157
+ if self.config.verbose:
158
+ console.print(f"[green]✓ Phase 1 complete: Found {len(hash_based_matches)} packages via hash-based discovery[/green]")
159
+ else:
160
+ if self.config.verbose:
161
+ console.print("[yellow]Phase 1 complete: No packages found via hash-based discovery[/yellow]")
162
+
163
+ # Phase 2: Manifest-based enhancement and supplementation
164
+ if self.config.verbose:
165
+ console.print("[bold blue]Phase 2: Manifest-based validation and enhancement[/bold blue]")
166
+
167
+ # Extract package metadata directly using UPMEX
168
+ manifest_matches = self._extract_with_upmex(path)
169
+
170
+ # Merge and enhance with manifest data
171
+ enhanced_matches = self._merge_and_enhance_matches(hash_based_matches, manifest_matches, path)
172
+
173
+ if self.config.verbose:
174
+ hash_count = len(hash_based_matches)
175
+ manifest_count = len(manifest_matches)
176
+ final_count = len(enhanced_matches)
177
+ console.print(f"[green]✓ Phase 2 complete: {hash_count} hash-based + {manifest_count} manifest-based = {final_count} total packages[/green]")
178
+
179
+ # Final deduplication and sorting
180
+ final_matches = self._prioritize_and_deduplicate(enhanced_matches)
181
+
182
+ # Step 5: Optionally enhance with oslili license detection
183
+ if enhance_licenses:
184
+ try:
185
+ from src2id.integrations.oslili import enhance_with_oslili
186
+ final_matches = enhance_with_oslili(final_matches, path)
187
+ # License enhancement is now silent by default
188
+ except ImportError:
189
+ if self.config.verbose:
190
+ console.print("[yellow]oslili not available for license enhancement[/yellow]")
191
+
192
+ # Match count is now shown in CLI output, not here
193
+
194
+ return final_matches
195
+ finally:
196
+ # Clean up the session if it was created
197
+ if self._sh_client is not None:
198
+ await self._sh_client.close_session()
199
+
200
+ def _extract_with_upmex(self, path: Path) -> List[PackageMatch]:
201
+ """
202
+ Try to extract package metadata directly using UPMEX.
203
+
204
+ Args:
205
+ path: Directory path to analyze
206
+
207
+ Returns:
208
+ List of package matches found via direct metadata extraction
209
+ """
210
+ if not self.upmex_integration.enabled:
211
+ return []
212
+
213
+ try:
214
+ matches = self.upmex_integration.extract_metadata_from_directory(path)
215
+
216
+ if self.config.verbose and matches:
217
+ console.print("[green]Found package metadata files:[/green]")
218
+ for match in matches:
219
+ if match.name:
220
+ console.print(f" [green]📦 {match.name} v{match.version or 'unknown'}[/green]")
221
+ if match.license:
222
+ console.print(f" License: {match.license}")
223
+ if match.purl:
224
+ console.print(f" PURL: {match.purl}")
225
+
226
+ return matches
227
+
228
+ except Exception as e:
229
+ if self.config.verbose:
230
+ console.print(f"[yellow]UPMEX extraction failed: {e}[/yellow]")
231
+ return []
232
+
233
+ def _merge_and_enhance_matches(
234
+ self,
235
+ hash_based_matches: List[PackageMatch],
236
+ manifest_matches: List[PackageMatch],
237
+ path: Path
238
+ ) -> List[PackageMatch]:
239
+ """
240
+ Intelligently merge hash-based and manifest-based matches.
241
+
242
+ Strategy:
243
+ 1. Use manifest data to enhance/validate hash-based matches
244
+ 2. Add manifest-only packages as supplementary findings
245
+ 3. Prefer hash-based discovery but enhance with manifest precision
246
+
247
+ Args:
248
+ hash_based_matches: Packages found via SWHID/Software Heritage
249
+ manifest_matches: Packages found via manifest parsing
250
+ path: The scanned directory path
251
+
252
+ Returns:
253
+ Enhanced and merged list of package matches
254
+ """
255
+ if not manifest_matches:
256
+ return hash_based_matches
257
+
258
+ if not hash_based_matches:
259
+ # Only manifest matches found - add them all as supplementary
260
+ if self.config.verbose:
261
+ console.print(f"[blue]Adding {len(manifest_matches)} manifest-only packages[/blue]")
262
+ return manifest_matches
263
+
264
+ # Create enhanced results starting with hash-based matches
265
+ enhanced_matches = list(hash_based_matches)
266
+
267
+ # Create lookup for hash-based matches by normalized name/URL
268
+ hash_lookup = {}
269
+ for match in hash_based_matches:
270
+ # Try multiple keys for matching
271
+ keys = []
272
+ if match.name:
273
+ keys.append(match.name.lower())
274
+ # Handle different name formats (e.g., "org:artifact" vs "artifact")
275
+ if ':' in match.name:
276
+ keys.append(match.name.split(':')[-1].lower())
277
+ if match.download_url:
278
+ # Normalize URL for matching
279
+ normalized_url = self._normalize_url_for_matching(match.download_url)
280
+ keys.append(normalized_url)
281
+
282
+ for key in keys:
283
+ hash_lookup[key] = match
284
+
285
+ # Process manifest matches
286
+ supplementary_count = 0
287
+ enhanced_count = 0
288
+
289
+ for manifest_match in manifest_matches:
290
+ # Try to find corresponding hash-based match
291
+ corresponding_hash_match = None
292
+
293
+ # Check by name
294
+ if manifest_match.name:
295
+ name_key = manifest_match.name.lower()
296
+ if name_key in hash_lookup:
297
+ corresponding_hash_match = hash_lookup[name_key]
298
+ elif ':' in manifest_match.name:
299
+ # Try just the artifact name
300
+ artifact_key = manifest_match.name.split(':')[-1].lower()
301
+ if artifact_key in hash_lookup:
302
+ corresponding_hash_match = hash_lookup[artifact_key]
303
+
304
+ # Check by URL if no name match
305
+ if not corresponding_hash_match and manifest_match.download_url:
306
+ url_key = self._normalize_url_for_matching(manifest_match.download_url)
307
+ if url_key in hash_lookup:
308
+ corresponding_hash_match = hash_lookup[url_key]
309
+
310
+ if corresponding_hash_match:
311
+ # ENHANCE: Update hash-based match with manifest precision
312
+ enhanced_count += 1
313
+ if self.config.verbose:
314
+ console.print(f"[cyan]Enhanced {corresponding_hash_match.name or 'package'} with manifest data[/cyan]")
315
+
316
+ # Keep hash-based match but enhance with manifest data
317
+ if manifest_match.version and not corresponding_hash_match.version:
318
+ corresponding_hash_match.version = manifest_match.version
319
+
320
+ if manifest_match.license and not corresponding_hash_match.license:
321
+ corresponding_hash_match.license = manifest_match.license
322
+ elif manifest_match.license and corresponding_hash_match.license:
323
+ # Combine licenses if different
324
+ if manifest_match.license not in corresponding_hash_match.license:
325
+ corresponding_hash_match.license = f"{corresponding_hash_match.license}, {manifest_match.license}"
326
+
327
+ if manifest_match.purl and not corresponding_hash_match.purl:
328
+ corresponding_hash_match.purl = manifest_match.purl
329
+
330
+ # Boost confidence slightly for validation
331
+ corresponding_hash_match.confidence_score = min(1.0, corresponding_hash_match.confidence_score + 0.05)
332
+
333
+ else:
334
+ # SUPPLEMENT: Add manifest-only package
335
+ supplementary_count += 1
336
+ if self.config.verbose:
337
+ console.print(f"[blue]Added supplementary package: {manifest_match.name or 'unknown'}[/blue]")
338
+
339
+ # Slightly lower confidence for manifest-only packages
340
+ manifest_match.confidence_score = min(0.85, manifest_match.confidence_score)
341
+ enhanced_matches.append(manifest_match)
342
+
343
+ if self.config.verbose and (enhanced_count > 0 or supplementary_count > 0):
344
+ console.print(f"[green]✓ Enhanced {enhanced_count} packages, added {supplementary_count} supplementary packages[/green]")
345
+
346
+ return enhanced_matches
347
+
348
+ def _normalize_url_for_matching(self, url: str) -> str:
349
+ """Normalize URL for matching between hash-based and manifest-based results."""
350
+ if not url:
351
+ return ""
352
+
353
+ # Remove protocol and common suffixes
354
+ normalized = url.lower()
355
+ normalized = normalized.replace('https://', '').replace('http://', '')
356
+ normalized = normalized.replace('.git', '')
357
+ normalized = normalized.rstrip('/')
358
+
359
+ # Extract key parts for matching using proper URL parsing
360
+ try:
361
+ from urllib.parse import urlparse
362
+ parsed = urlparse(url if url.startswith(('http://', 'https://')) else f'https://{url}')
363
+ hostname = parsed.hostname.lower() if parsed.hostname else ''
364
+ path_parts = parsed.path.strip('/').split('/')
365
+
366
+ if hostname == 'github.com' and len(path_parts) >= 2:
367
+ return f"github.com/{path_parts[0]}/{path_parts[1]}"
368
+ elif hostname == 'gitlab.com' and len(path_parts) >= 2:
369
+ return f"gitlab.com/{path_parts[0]}/{path_parts[1]}"
370
+ except Exception:
371
+ pass
372
+
373
+ return normalized
374
+
375
+ async def _scan_directories(self, path: Path) -> Tuple[List[DirectoryCandidate], List[ContentCandidate]]:
376
+ """Scan directories and files to generate SWHID candidates."""
377
+ if self.config.verbose:
378
+ with Progress(
379
+ SpinnerColumn(),
380
+ TextColumn("[progress.description]{task.description}"),
381
+ console=console,
382
+ ) as progress:
383
+ task = progress.add_task("Scanning directories and files...", total=None)
384
+
385
+ # Scan the main path - returns both dirs and files
386
+ dir_candidates, file_candidates = self.scanner.scan_recursive(path)
387
+
388
+ # Also scan subdirectories for better matching
389
+ progress.update(task, description="Scanning subdirectories...")
390
+ subdirs = await self._scan_subdirectories(path)
391
+ dir_candidates.extend(subdirs)
392
+
393
+ progress.update(task, completed=True)
394
+ else:
395
+ dir_candidates, file_candidates = self.scanner.scan_recursive(path)
396
+ subdirs = await self._scan_subdirectories(path)
397
+ dir_candidates.extend(subdirs)
398
+
399
+ # Remove duplicate directories based on SWHID
400
+ seen_swhids = set()
401
+ unique_dirs = []
402
+ for candidate in dir_candidates:
403
+ if candidate.swhid not in seen_swhids:
404
+ seen_swhids.add(candidate.swhid)
405
+ unique_dirs.append(candidate)
406
+
407
+ if self.config.verbose:
408
+ console.print(f"[dim]Generated {len(unique_dirs)} directory candidates and {len(file_candidates)} file candidates[/dim]")
409
+ # Show breakdown
410
+ target_count = sum(1 for c in unique_dirs if c.path == path)
411
+ parent_count = sum(1 for c in unique_dirs if path in c.path.parents)
412
+ child_count = sum(1 for c in unique_dirs if c.path.parent == path)
413
+
414
+ if child_count > 0 or file_candidates:
415
+ console.print(f"[dim] → Directories: {len(unique_dirs)} (Target: {target_count}, Parents: {parent_count}, Children: {child_count})[/dim]")
416
+ console.print(f"[dim] → Files: {len(file_candidates)} collected[/dim]")
417
+
418
+ return unique_dirs, file_candidates
419
+
420
+ async def _scan_subdirectories(self, path: Path) -> List[DirectoryCandidate]:
421
+ """Scan immediate subdirectories for better matching."""
422
+ candidates = []
423
+
424
+ # Priority directories that rarely change
425
+ priority_dirs = ['cmake', 'docs', 'doc', 'tools', 'packaging',
426
+ 'data', 'po', 'translations', 'config', 'scripts']
427
+
428
+ subdirs_checked = 0
429
+ max_subdirs = 10
430
+
431
+ # Check priority directories first
432
+ for dir_name in priority_dirs:
433
+ if subdirs_checked >= max_subdirs:
434
+ break
435
+
436
+ subdir = path / dir_name
437
+ if subdir.exists() and subdir.is_dir():
438
+ try:
439
+ from src2id.core.models import DirectoryCandidate
440
+
441
+ file_count = sum(1 for _ in subdir.rglob('*') if _.is_file())
442
+ if file_count >= 3: # Low threshold for subdirs
443
+ swhid = self.swhid_generator.generate_directory_swhid(subdir)
444
+
445
+ candidate = DirectoryCandidate(
446
+ path=subdir,
447
+ swhid=swhid,
448
+ depth=1,
449
+ specificity_score=0.8, # High score for stable dirs
450
+ file_count=file_count
451
+ )
452
+ candidates.append(candidate)
453
+ subdirs_checked += 1
454
+
455
+ except (PermissionError, OSError):
456
+ continue
457
+
458
+ return candidates
459
+
460
+ async def _find_matches(self, dir_candidates: List[DirectoryCandidate], file_candidates: List[ContentCandidate]) -> List[SHOriginMatch]:
461
+ """Find matches in Software Heritage for all candidates (dirs and files)."""
462
+ all_matches = []
463
+ # Find the most specific path (highest specificity score) as target
464
+ target_path = max(dir_candidates, key=lambda c: c.specificity_score).path if dir_candidates else None
465
+
466
+ # Track match statistics
467
+ parent_matches = 0
468
+ child_matches = 0
469
+ target_match = False
470
+
471
+ with Progress(
472
+ SpinnerColumn(),
473
+ TextColumn("[progress.description]{task.description}"),
474
+ console=console,
475
+ disable=not self.config.verbose,
476
+ ) as progress:
477
+ task = progress.add_task(
478
+ "Checking archive status (batch)...",
479
+ total=len(dir_candidates) + len(file_candidates)
480
+ )
481
+
482
+ # Batch check all SWHIDs (both dirs and files) for efficiency
483
+ all_swhids = []
484
+ swhid_to_candidate = {}
485
+
486
+ # Add directory SWHIDs
487
+ for candidate in dir_candidates:
488
+ all_swhids.append(candidate.swhid)
489
+ swhid_to_candidate[candidate.swhid] = ("dir", candidate)
490
+
491
+ # Add file SWHIDs
492
+ for candidate in file_candidates:
493
+ all_swhids.append(candidate.swhid)
494
+ swhid_to_candidate[candidate.swhid] = ("file", candidate)
495
+
496
+ if self.config.verbose:
497
+ console.print(f"[dim]Batch checking {len(all_swhids)} SWHIDs ({len(dir_candidates)} dirs, {len(file_candidates)} files)...[/dim]")
498
+
499
+ known_status = await self.sh_client.check_swhids_known(all_swhids)
500
+
501
+ # Filter to only items that exist in archive
502
+ existing_dirs = []
503
+ existing_files = []
504
+
505
+ for swhid, is_known in known_status.items():
506
+ if is_known:
507
+ # Convert CoreSWHID back to string if needed
508
+ swhid_str = str(swhid) if not isinstance(swhid, str) else swhid
509
+ if swhid_str in swhid_to_candidate:
510
+ item_type, candidate = swhid_to_candidate[swhid_str]
511
+ if item_type == "dir":
512
+ existing_dirs.append(candidate)
513
+ else:
514
+ existing_files.append(candidate)
515
+
516
+ if self.config.verbose:
517
+ dirs_found = len(existing_dirs)
518
+ files_found = len(existing_files)
519
+ total_dirs = len(dir_candidates)
520
+ total_files = len(file_candidates)
521
+ console.print(f"[dim]Found {dirs_found}/{total_dirs} directories and {files_found}/{total_files} files in archive[/dim]")
522
+
523
+ # Now get detailed origins info for existing directories
524
+ # Note: Files don't have origins, but we can report them as found
525
+ progress.update(task, description="Getting origin details...")
526
+
527
+ # Report found files
528
+ if self.config.verbose and existing_files:
529
+ console.print(f"[green]✓ Found {len(existing_files)} files in archive:[/green]")
530
+ for file_candidate in existing_files[:10]: # Show first 10
531
+ rel_path = file_candidate.path.name
532
+ console.print(f" [green]📄 {rel_path}[/green]")
533
+ if len(existing_files) > 10:
534
+ console.print(f" [dim]... and {len(existing_files) - 10} more files[/dim]")
535
+
536
+ # Process directories for origin information
537
+ for candidate in existing_dirs:
538
+ # Determine relationship
539
+ if target_path:
540
+ if candidate.path == target_path:
541
+ relationship = "target"
542
+ elif candidate.path.parent == target_path:
543
+ relationship = "child"
544
+ elif target_path in candidate.path.parents:
545
+ relationship = "parent"
546
+ else:
547
+ relationship = "other"
548
+ else:
549
+ relationship = "unknown"
550
+
551
+ # Get detailed origin information for known directories
552
+ if self.config.verbose:
553
+ console.print(f"[dim]Getting origins: {candidate.swhid}[/dim]")
554
+ exact_matches = await self._find_exact_matches(candidate)
555
+
556
+ if exact_matches:
557
+ all_matches.extend(exact_matches)
558
+
559
+ # Update statistics
560
+ if relationship == "target":
561
+ target_match = True
562
+ elif relationship == "child":
563
+ child_matches += 1
564
+ elif relationship == "parent":
565
+ parent_matches += 1
566
+
567
+ if self.config.verbose:
568
+ if relationship == "child":
569
+ console.print(
570
+ f"[green]✓ Found exact match for subdirectory: {candidate.path.name}[/green]"
571
+ )
572
+ else:
573
+ console.print(
574
+ f"[green]✓ Found exact match for {candidate.path.name}[/green]"
575
+ )
576
+ # Early termination on high-confidence exact match
577
+ if any(self._is_high_confidence_match(m) for m in exact_matches):
578
+ break
579
+
580
+ progress.advance(task)
581
+
582
+ # Log non-existing directories in verbose mode
583
+ if self.config.verbose:
584
+ non_existing_dirs = [
585
+ candidate for candidate in dir_candidates
586
+ if not known_status.get(candidate.swhid, False)
587
+ ]
588
+ for candidate in non_existing_dirs:
589
+ console.print(f"[yellow]✗ No match for {candidate.path.name} ({candidate.swhid[:12]}...)[/yellow]")
590
+
591
+ # Try keyword search fallback if no exact matches and fuzzy is enabled
592
+ if not all_matches and self.config.enable_fuzzy_matching:
593
+ if self.config.verbose:
594
+ console.print("[yellow]No exact matches found - trying keyword search[/yellow]")
595
+ # Only try keyword search if fuzzy matching is enabled
596
+ keyword_matches = await self._find_keyword_matches(dir_candidates[0].path if dir_candidates else Path("."))
597
+ all_matches.extend(keyword_matches)
598
+
599
+ # Report match summary
600
+ if self.config.verbose and (child_matches > 0 or parent_matches > 0):
601
+ console.print("\n[bold]Match Summary:[/bold]")
602
+ if target_match:
603
+ console.print(" [green]✓ Target directory found in archive[/green]")
604
+ else:
605
+ console.print(" [yellow]✗ Target directory not found[/yellow]")
606
+
607
+ if child_matches > 0:
608
+ console.print(f" [green]✓ {child_matches} subdirectories found in archive[/green]")
609
+ console.print(" [dim]→ Subdirectory matches indicate partial repository presence[/dim]")
610
+
611
+ if parent_matches > 0:
612
+ console.print(f" [blue]ℹ {parent_matches} parent directories checked[/blue]")
613
+
614
+ return all_matches
615
+
616
+ async def _find_exact_matches(self, candidate: DirectoryCandidate) -> List[SHOriginMatch]:
617
+ """Find exact SWHID matches in Software Heritage."""
618
+ try:
619
+ return await self.sh_client.get_directory_origins(candidate.swhid)
620
+ except Exception as e:
621
+ if self.config.verbose:
622
+ console.print(f"[red]Error querying SH for {candidate.swhid}: {e}[/red]")
623
+ return []
624
+
625
+ async def _find_fuzzy_matches(self, candidate: DirectoryCandidate) -> List[SHOriginMatch]:
626
+ """Find fuzzy matches using similarity algorithms."""
627
+ # Not implemented yet - placeholder for Issue #9
628
+ return []
629
+
630
+ async def _find_keyword_matches(self, path: Path) -> List[SHOriginMatch]:
631
+ """Find matches by searching for project name keywords."""
632
+ # Extract potential project name from path
633
+ # Walk up the path to find the most likely project name
634
+ parts = path.parts
635
+
636
+ # Common subdirectory names to skip
637
+ skip_dirs = {'packaging', 'src', 'lib', 'bin', 'build', 'dist', 'test',
638
+ 'tests', 'test_data', 'Projects', 'tmp', 'temp', 'vendor',
639
+ 'node_modules', '.git', 'docs', 'doc', 'cmake', 'po', 'data'}
640
+
641
+ keywords_to_try = []
642
+
643
+ # Try to find the project name by walking up the path
644
+ for i in range(len(parts) - 1, -1, -1):
645
+ part = parts[i]
646
+ if part not in skip_dirs and not part.startswith('.'):
647
+ keywords_to_try.append(part)
648
+ break
649
+
650
+ # If we didn't find anything, use the immediate parent if it's not a skip dir
651
+ if not keywords_to_try and path.parent.name not in skip_dirs:
652
+ keywords_to_try.append(path.parent.name)
653
+
654
+ # As a last resort, use the current directory name if it's not generic
655
+ if not keywords_to_try and path.name not in skip_dirs:
656
+ keywords_to_try.append(path.name)
657
+
658
+ all_origin_urls = set()
659
+ origin_matches = []
660
+
661
+ for keyword in keywords_to_try:
662
+ if self.config.verbose:
663
+ console.print(f"[dim]Searching for origins with keyword: {keyword}[/dim]")
664
+
665
+ try:
666
+ origins = await self.sh_client.search_origins_by_keyword(keyword)
667
+
668
+ for origin_data in origins:
669
+ url = origin_data.get('url', '')
670
+ if url and url not in all_origin_urls:
671
+ all_origin_urls.add(url)
672
+
673
+ # Calculate similarity score based on URL matching
674
+ similarity_score = 0.5 # Base score for keyword match
675
+
676
+ # Boost score for official organization matches
677
+ if f"{keyword}-org" in url.lower() or f"/{keyword}/{keyword}" in url.lower():
678
+ similarity_score = 0.9
679
+ elif f"/{keyword}/" in url.lower() or url.lower().endswith(f"/{keyword}"):
680
+ similarity_score = 0.7
681
+ elif keyword.lower() in url.lower():
682
+ similarity_score = 0.6
683
+
684
+ # Create a match for each origin found
685
+ origin_match = SHOriginMatch(
686
+ origin_url=url,
687
+ swhid="", # No specific SWHID since this is keyword search
688
+ last_seen=datetime.now(),
689
+ visit_count=1,
690
+ metadata={'keyword_match': keyword},
691
+ match_type=MatchType.FUZZY,
692
+ similarity_score=similarity_score
693
+ )
694
+ origin_matches.append(origin_match)
695
+
696
+ if self.config.verbose and len(origin_matches) <= 3:
697
+ console.print(f" [green]→ Found: {url} (score: {similarity_score:.2f})[/green]")
698
+ except Exception as e:
699
+ if self.config.verbose:
700
+ console.print(f"[red]Error searching for keyword {keyword}: {e}[/red]")
701
+
702
+ if self.config.verbose and origin_matches:
703
+ console.print(f"[green]Found {len(origin_matches)} potential matches via keyword search[/green]")
704
+
705
+ # Sort by similarity score (highest first) and return top matches
706
+ origin_matches.sort(key=lambda x: x.similarity_score, reverse=True)
707
+ return origin_matches[:10] # Limit to top 10 matches
708
+
709
+ async def _process_matches(
710
+ self, matches: List[SHOriginMatch]
711
+ ) -> List[PackageMatch]:
712
+ """Process matches to extract package information."""
713
+ package_matches = []
714
+
715
+ for match in matches:
716
+ # Extract package coordinates
717
+ coordinates = self.coordinate_extractor.extract_coordinates(match)
718
+
719
+ # Calculate confidence score
720
+ confidence = self.confidence_scorer.calculate_confidence({
721
+ 'match_type': match.match_type,
722
+ 'similarity_score': getattr(match, 'similarity_score', 1.0),
723
+ 'frequency_rank': match.visit_count,
724
+ 'is_official_org': self.coordinate_extractor.is_official_organization(
725
+ match.origin_url
726
+ ),
727
+ 'last_activity': match.last_seen
728
+ })
729
+
730
+ # Skip low confidence matches
731
+ if confidence < self.config.report_match_threshold:
732
+ continue
733
+
734
+ # Generate PURL if confidence is high enough
735
+ purl = None
736
+ if confidence >= self.config.purl_generation_threshold:
737
+ purl = self.purl_generator.generate_purl(coordinates, confidence)
738
+
739
+ package_match = PackageMatch(
740
+ download_url=coordinates.get('download_url', match.origin_url),
741
+ name=coordinates.get('name'),
742
+ version=coordinates.get('version'),
743
+ license=coordinates.get('license'),
744
+ sh_url=f"{self.config.sh_api_base}/directory/{match.swhid}/",
745
+ match_type=match.match_type,
746
+ confidence_score=confidence,
747
+ frequency_count=match.visit_count,
748
+ is_official_org=self.coordinate_extractor.is_official_organization(
749
+ match.origin_url
750
+ ),
751
+ purl=purl
752
+ )
753
+ package_matches.append(package_match)
754
+
755
+ return package_matches
756
+
757
+ def _prioritize_and_deduplicate(
758
+ self, matches: List[PackageMatch]
759
+ ) -> List[PackageMatch]:
760
+ """Sort by confidence and remove duplicates."""
761
+ # Group by base repository URL
762
+ grouped = {}
763
+ for match in matches:
764
+ base_url = self._extract_base_repo_url(match.download_url)
765
+ if base_url not in grouped or match.confidence_score > grouped[base_url].confidence_score:
766
+ grouped[base_url] = match
767
+
768
+ # Sort by official orgs first, then confidence
769
+ result = list(grouped.values())
770
+ result.sort(key=lambda m: (-1 if m.is_official_org else 0, -m.confidence_score))
771
+
772
+ return result
773
+
774
+ def _extract_base_repo_url(self, url: str) -> str:
775
+ """Extract base repository URL for deduplication."""
776
+ try:
777
+ from urllib.parse import urlparse
778
+ parsed = urlparse(url)
779
+ hostname = parsed.hostname.lower() if parsed.hostname else ''
780
+
781
+ if hostname in ('github.com', 'gitlab.com'):
782
+ path_parts = parsed.path.strip('/').split('/')
783
+ if len(path_parts) >= 2:
784
+ return f"{parsed.scheme}://{hostname}/{path_parts[0]}/{path_parts[1]}"
785
+ except Exception:
786
+ pass
787
+ return url
788
+
789
+ def _is_high_confidence_match(self, match: SHOriginMatch) -> bool:
790
+ """Check if a match is high confidence for early termination."""
791
+ # Simple heuristic - can be improved
792
+ return (
793
+ match.match_type.value == "exact" and
794
+ match.visit_count > 10 and
795
+ self.coordinate_extractor.is_official_organization(match.origin_url)
796
+ )