Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Unreleased
----------

* feat: expose the ``is_addon`` attribute in the pipelines REST API
* feat: [code-genetics] determine the origin of files from combined origin matches #1929 by @tdruez

v38.2.0 (2026-10-22)
--------------------
Expand Down
9 changes: 9 additions & 0 deletions docs/data-models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ OriginMatch
:undoc-members:
:member-order: groupwise

.. _data_models_origin_determination:

OriginDetermination
-------------------
.. autoclass:: scanpipe.models.OriginDetermination()
:members:
:undoc-members:
:member-order: groupwise

.. _data_models_project_message:

ProjectMessage
Expand Down
26 changes: 26 additions & 0 deletions scanpipe/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from scanpipe.models import CodebaseResource
from scanpipe.models import DiscoveredDependency
from scanpipe.models import DiscoveredPackage
from scanpipe.models import OriginDetermination
from scanpipe.models import OriginMatch
from scanpipe.models import Project

Expand Down Expand Up @@ -192,6 +193,30 @@ def has_delete_permission(self, request, obj=None):
return False


class OriginDeterminationAdmin(ScanPipeBaseAdmin):
list_display = [
"resource",
"purl",
"status",
"rule",
"project",
]
search_fields = [
"uuid",
"purl",
"resource__path",
]
list_filter = ["project", "status", "rule"]
list_select_related = ["resource", "project"]
ordering = ["project", "resource__path"]

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False


class ScanCodeIOAdminSite(admin.AdminSite):
site_header = "ScanCode.io administration"
site_title = "ScanCode.io administration"
Expand All @@ -203,3 +228,4 @@ class ScanCodeIOAdminSite(admin.AdminSite):
admin_site.register(DiscoveredPackage, DiscoveredPackageAdmin)
admin_site.register(DiscoveredDependency, DiscoveredDependencyAdmin)
admin_site.register(OriginMatch, OriginMatchAdmin)
admin_site.register(OriginDetermination, OriginDeterminationAdmin)
32 changes: 32 additions & 0 deletions scanpipe/migrations/0085_origindetermination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 6.1.2 on 2026-10-27 07:18

import django.db.models.deletion
import scanpipe.models
import uuid
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('scanpipe', '0084_originmatch'),
]

operations = [
migrations.CreateModel(
name='OriginDetermination',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='UUID')),
('purl', models.CharField(blank=True, help_text='Package URL (PURL) of the origin package. Empty when no open source origin is found, or when the origin needs review and no candidate is preferred.', max_length=2048)),
('status', models.CharField(choices=[('auto-confirmed', 'Auto Confirmed'), ('needs-review', 'Needs Review'), ('confirmed', 'Confirmed'), ('no-origin', 'No Origin')], help_text='Determination status: confirmed automatically by a rule, needs a review, confirmed by a user, or no open source origin.', max_length=20)),
('rule', models.CharField(blank=True, help_text='Name of the determination rule that set the status. Empty when the status is set by a user.', max_length=50)),
('project', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='scanpipe.project')),
('resource', models.OneToOneField(editable=False, help_text='The file or directory whose origin is determined.', on_delete=django.db.models.deletion.CASCADE, related_name='origin_determination', to='scanpipe.codebaseresource')),
],
options={
'indexes': [models.Index(fields=['purl'], name='scanpipe_or_purl_e28f4e_idx'), models.Index(fields=['status'], name='scanpipe_or_status_9ab697_idx')],
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('purl', ''), ('status', 'no-origin')), ('status', 'needs-review'), models.Q(('status__in', ['auto-confirmed', 'confirmed']), models.Q(('purl', ''), _negated=True)), _connector='OR'), name='scanpipe_origindetermination_purl_consistent_with_status')],
},
bases=(scanpipe.models.UpdateMixin, models.Model),
),
]
89 changes: 87 additions & 2 deletions scanpipe/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,7 @@ def delete_related_objects(self, keep_input=False, keep_webhook=False):
self.discovereddependencies,
self.discoveredlicenses,
self.originmatchs,
self.origindeterminations,
self.codebaseresources,
self.runs,
]
Expand Down Expand Up @@ -5068,8 +5069,8 @@ class OriginMatch(UUIDPKModel, ProjectRelatedModel, ExtraDataFieldMixin, AdminUR

An origin tool can report several candidates for the same resource, and
several tools can report candidates for the same resource. A candidate is not
a conclusion: only the origin selected after review becomes a discovered
package. Origin matches are not included in the output files yet.
a conclusion: only the confirmed origin, automatically or after review, becomes
a discovered package. Origin matches are not included in the output files yet.
"""

class MatchType(models.TextChoices):
Expand Down Expand Up @@ -5155,3 +5156,87 @@ class Meta:

def __str__(self):
return f"{self.purl} ({self.match_type} match by {self.tool})"


class OriginDeterminationQuerySet(ProjectRelatedQuerySet):
def automatic(self):
return self.filter(status__in=self.model.AUTOMATIC_STATUSES)

def confirmed(self):
return self.filter(status__in=self.model.CONFIRMED_STATUSES)


class OriginDetermination(UUIDPKModel, ProjectRelatedModel):
"""
The origin determined for a resource, selected among its origin matches by a
determination rule or by a user.

The origin is stored as a PURL, independently of the origin matches, so a
user decision is kept when the origin tools are run again.
"""

class Status(models.TextChoices):
AUTO_CONFIRMED = "auto-confirmed"
NEEDS_REVIEW = "needs-review"
CONFIRMED = "confirmed"
NO_ORIGIN = "no-origin"

CONFIRMED_STATUSES = [Status.AUTO_CONFIRMED, Status.CONFIRMED]
# Set by the determination rules, and replaced each time the rules run.
AUTOMATIC_STATUSES = [Status.AUTO_CONFIRMED, Status.NEEDS_REVIEW]

resource = models.OneToOneField(
CodebaseResource,
related_name="origin_determination",
on_delete=models.CASCADE,
editable=False,
help_text=_("The file or directory whose origin is determined."),
)
purl = models.CharField(
max_length=2048,
blank=True,
help_text=_(
"Package URL (PURL) of the origin package. Empty when no open source "
"origin is found, or when the origin needs review and no candidate is "
"preferred."
),
)
status = models.CharField(
max_length=20,
choices=Status.choices,
help_text=_(
"Determination status: confirmed automatically by a rule, needs a "
"review, confirmed by a user, or no open source origin."
),
)
rule = models.CharField(
max_length=50,
blank=True,
help_text=_(
"Name of the determination rule that set the status. Empty when the "
"status is set by a user."
),
)

objects = OriginDeterminationQuerySet.as_manager()

class Meta:
indexes = [
models.Index(fields=["purl"]),
models.Index(fields=["status"]),
]
constraints = [
models.CheckConstraint(
condition=(
Q(status="no-origin", purl="")
| Q(status="needs-review")
| (Q(status__in=["auto-confirmed", "confirmed"]) & ~Q(purl=""))
),
name="%(app_label)s_%(class)s_purl_consistent_with_status",
),
]

def __str__(self):
if self.purl:
return f"{self.purl} ({self.status})"
return self.status
21 changes: 19 additions & 2 deletions scanpipe/pipelines/find_origins.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from scanpipe.pipelines import Pipeline
from scanpipe.pipes import matchcode
from scanpipe.pipes import origin
from scanpipe.pipes import scanoss


Expand All @@ -32,10 +33,11 @@ class FindOrigins(Pipeline):
Find the origin of the codebase files using origin detection tools.

The candidate origin packages reported by each configured tool are stored as
origin matches, to be combined and reviewed. A tool that is not configured is
skipped.
origin matches, then combined to determine the origin of each matched file and
directory. A tool that is not configured is skipped.

Supported tools:

- MatchCode.io: the codebase scan, including file and directory
fingerprints, is sent to the configured MatchCode.io server.
- SCANOSS: the codebase files are fingerprinted locally, then their paths,
Expand All @@ -51,6 +53,7 @@ def steps(cls):
return (
cls.find_matchcode_origin_matches,
cls.find_scanoss_origin_matches,
cls.determine_origins,
)

@classmethod
Expand Down Expand Up @@ -97,3 +100,17 @@ def find_scanoss_origin_matches(self):
self.project, results_location
)
self.log(f"{origin_match_count:,d} SCANOSS origin matches processed.")

def determine_origins(self):
"""
Determine the origin of each matched file and directory from the origin
matches of all the tools. An origin is confirmed automatically when two
tools report the same package, with at least one exact match. Otherwise,
the origin needs a review. The origins confirmed by a user are kept.
The files and directories of a confirmed origin are assigned to the origin
package, created from the tools data when the codebase scan did not
detect it.
"""
determination_count, package_count = origin.determine_origins(self.project)
self.log(f"{determination_count:,d} origin determinations created.")
self.log(f"{package_count:,d} origin packages assigned.")
Loading
Loading