Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- Keyword search runs only when `--enable-fuzzy` was passed, and only when
nothing else identified the source (#59). It ran in phase 1, before the
manifest parsing in phase 2 that produces the exact match, so "when exact
matches fail", which is what the flag promises, was decided before the step
that finds the match had run. Against express@4.18.2 that returned the correct
match at 0.85 beside ten npm packages at 0.83, close enough that
`--confidence-threshold` cannot separate them, and carrying no PURL so nothing
downstream could act on them either.
- The same fallback inside the Software Heritage path is removed rather than
gated, so keyword search now happens once, in one place, for every route.

## [1.3.5] - 2026-07-24

### Fixed
Expand Down
45 changes: 27 additions & 18 deletions src2id/core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,6 @@ async def identify_packages(self, path: Path, enhance_licenses: bool = True) ->
if self.config.verbose:
console.print("[dim]Skipping Software Heritage (use --use-swh to enable)[/dim]")

if not all_matches:
# Try keyword search with GitHub and SCANOSS
if self.config.verbose:
console.print("[yellow]Trying web search (GitHub, SCANOSS)[/yellow]")

# Try keyword search
keyword_matches = await self._find_keyword_matches(path)
if keyword_matches:
all_matches = keyword_matches

# Step 1b: Process hash-based matches
if all_matches:
hash_based_matches = await self._process_matches(all_matches)
Expand Down Expand Up @@ -178,6 +168,27 @@ async def identify_packages(self, path: Path, enhance_licenses: bool = True) ->

# Final deduplication and sorting
final_matches = self._prioritize_and_deduplicate(enhanced_matches)

# Keyword search is the last resort, and only when it was asked for.
# It searches on the project name, so it answers with whatever else
# shares that name: running it for express returned ten npm packages
# scoring 0.83 against the real one at 0.85, close enough that a
# confidence threshold cannot separate them, and carrying no PURL so
# nothing downstream can act on them either.
#
# It also belongs here rather than in phase 1. The flag says fuzzy
# runs when exact matches fail, and whether they failed is not known
# until the manifest parsing in phase 2 has had its turn.
if not final_matches and self.config.enable_fuzzy_matching:
if self.config.verbose:
console.print(
"[yellow]Nothing identified - trying keyword search[/yellow]"
)
keyword_matches = await self._find_keyword_matches(path)
if keyword_matches:
final_matches = self._prioritize_and_deduplicate(
await self._process_matches(keyword_matches)
)

# Step 5: Optionally enhance with oslili license detection
if enhance_licenses:
Expand Down Expand Up @@ -588,14 +599,12 @@ async def _find_matches(self, dir_candidates: List[DirectoryCandidate], file_can
for candidate in non_existing_dirs:
console.print(f"[yellow]✗ No match for {candidate.path.name} ({candidate.swhid[:12]}...)[/yellow]")

# Try keyword search fallback if no exact matches and fuzzy is enabled
if not all_matches and self.config.enable_fuzzy_matching:
if self.config.verbose:
console.print("[yellow]No exact matches found - trying keyword search[/yellow]")
# Only try keyword search if fuzzy matching is enabled
keyword_matches = await self._find_keyword_matches(dir_candidates[0].path if dir_candidates else Path("."))
all_matches.extend(keyword_matches)

# Keyword search is not run here. It used to be, and that put it before
# the manifest parsing in phase 2, so "when exact matches fail", which
# is what the flag promises, was decided before the step that finds the
# exact match had run. identify_packages runs it once, after everything
# else has had its turn.

# Report match summary
if self.config.verbose and (child_matches > 0 or parent_matches > 0):
console.print("\n[bold]Match Summary:[/bold]")
Expand Down
165 changes: 165 additions & 0 deletions tests/unit/test_fuzzy_gating.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Keyword search runs only when asked for, and only when nothing else answered.

Issue #59. Run against the extracted express@4.18.2 tarball without
--enable-fuzzy, src2purl returned the correct express match at 0.85 alongside
ten npm packages at 0.83:

exact 0.85 pkg:npm/express@4.18.2 express
fuzzy 0.83 None senam5jari-975
fuzzy 0.83 None voiti-1xbet-rech-idet-urxfhkrm
...

Two things were wrong. The flag says fuzzy runs "when exact matches fail" and it
was never consulted at this call site, and the search ran in phase 1, before the
manifest parsing in phase 2 that produces the exact match, so "did the exact
match fail" could not have been known yet.

The 0.02 gap between the right answer and npm spam is what makes it costly:
anything consuming by --confidence-threshold gets both, and the spam carries
purl: null so it cannot be acted on downstream either.
"""

import asyncio
from unittest.mock import AsyncMock, MagicMock

from src2id.core.config import SWHPIConfig
from src2id.core.models import MatchType, PackageMatch
from src2id.core.orchestrator import SHPackageIdentifier


def _manifest_match():
"""What phase 2 finds for express, from package.json."""
return PackageMatch(
name="express",
version="4.18.2",
confidence_score=0.85,
match_type=MatchType.EXACT,
download_url="https://www.npmjs.com/package/express",
purl="pkg:npm/express@4.18.2",
license="MIT",
is_official_org=False,
)


def _spam_match(name):
"""What keyword search finds: anything sharing the project name."""
return PackageMatch(
name=name,
version=None,
confidence_score=0.83,
match_type=MatchType.FUZZY,
download_url=f"https://www.npmjs.com/package/{name}",
purl=None,
license="MIT",
is_official_org=False,
)


def _identifier(enable_fuzzy, manifest, keyword):
config = SWHPIConfig(enable_fuzzy_matching=enable_fuzzy, use_swh=False)
identifier = SHPackageIdentifier(config)
identifier._scan_directories = AsyncMock(return_value=([MagicMock()], []))
identifier._extract_with_upmex = MagicMock(return_value=list(manifest))
identifier._find_keyword_matches = AsyncMock(return_value=[MagicMock() for _ in keyword])
identifier._process_matches = AsyncMock(return_value=list(keyword))
return identifier


def _run(identifier, tmp_path):
return asyncio.run(identifier.identify_packages(tmp_path, enhance_licenses=False))


class TestFuzzyIsGatedOnTheFlag:
def test_not_requested_means_no_keyword_search(self, tmp_path):
"""The reported case: an exact match, and ten fuzzy ones nobody asked for."""
identifier = _identifier(
enable_fuzzy=False,
manifest=[_manifest_match()],
keyword=[_spam_match("senam5jari-975"), _spam_match("voiti-1xbet-rech-idet-urxfhkrm")],
)

result = _run(identifier, tmp_path)

identifier._find_keyword_matches.assert_not_called()
assert [m.name for m in result] == ["express"]
assert all(m.match_type is not MatchType.FUZZY for m in result)

def test_not_requested_and_nothing_found_still_means_no_keyword_search(self, tmp_path):
identifier = _identifier(enable_fuzzy=False, manifest=[], keyword=[_spam_match("whatever")])

result = _run(identifier, tmp_path)

identifier._find_keyword_matches.assert_not_called()
assert result == []


class TestFuzzyRunsOnlyWhenExactFailed:
def test_requested_but_something_answered_means_no_keyword_search(self, tmp_path):
""" "when exact matches fail" cannot be judged before phase 2 has run."""
identifier = _identifier(
enable_fuzzy=True,
manifest=[_manifest_match()],
keyword=[_spam_match("senam5jari-975")],
)

result = _run(identifier, tmp_path)

identifier._find_keyword_matches.assert_not_called()
assert [m.name for m in result] == ["express"]

def test_requested_and_nothing_answered_means_keyword_search_runs(self, tmp_path):
identifier = _identifier(
enable_fuzzy=True, manifest=[], keyword=[_spam_match("something-alike")]
)

result = _run(identifier, tmp_path)

identifier._find_keyword_matches.assert_called_once()
assert [m.name for m in result] == ["something-alike"]


class TestTheSoftwareHeritagePathIsGatedToo:
"""--use-swh took a different route with its own ungated keyword search."""

def _identifier(self, enable_fuzzy, manifest, keyword, sh_matches=None):
config = SWHPIConfig(enable_fuzzy_matching=enable_fuzzy, use_swh=True)
identifier = SHPackageIdentifier(config)
identifier._scan_directories = AsyncMock(return_value=([MagicMock()], []))
identifier._find_matches = AsyncMock(return_value=list(sh_matches or []))
identifier._extract_with_upmex = MagicMock(return_value=list(manifest))
identifier._find_keyword_matches = AsyncMock(return_value=[MagicMock() for _ in keyword])
identifier._process_matches = AsyncMock(return_value=list(keyword))
return identifier

def test_no_keyword_search_when_the_manifest_answered(self, tmp_path):
"""Software Heritage found nothing, but package.json did."""
identifier = self._identifier(
enable_fuzzy=True,
manifest=[_manifest_match()],
keyword=[_spam_match("senam5jari-975")],
)

result = asyncio.run(identifier.identify_packages(tmp_path, enhance_licenses=False))

identifier._find_keyword_matches.assert_not_called()
assert [m.name for m in result] == ["express"]

def test_no_keyword_search_without_the_flag(self, tmp_path):
identifier = self._identifier(
enable_fuzzy=False, manifest=[], keyword=[_spam_match("whatever")]
)

result = asyncio.run(identifier.identify_packages(tmp_path, enhance_licenses=False))

identifier._find_keyword_matches.assert_not_called()
assert result == []

def test_keyword_search_runs_when_asked_and_nothing_answered(self, tmp_path):
identifier = self._identifier(
enable_fuzzy=True, manifest=[], keyword=[_spam_match("something-alike")]
)

result = asyncio.run(identifier.identify_packages(tmp_path, enhance_licenses=False))

identifier._find_keyword_matches.assert_called_once()
assert [m.name for m in result] == ["something-alike"]