Skip to content
Open
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
78 changes: 77 additions & 1 deletion src/packagedcode/go_mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class GoModule(object):
module = attr.ib(default=None)
require = attr.ib(default=None)
exclude = attr.ib(default=None)
local_replacements = attr.ib(default=None)

def purl(self, include_version=True):
version = None
Expand Down Expand Up @@ -50,6 +51,13 @@ def purl(self, include_version=True):
r'(?P<version>(.*))'
).match

parse_rep_link = re.compile(
r"(?P<ns_name>\S+)"
r"(?:\s+(?P<version>\S+))?"
r"\s*=>\s*"
r"(?P<replacement_ns_name>\S+)"
r"(?:\s+(?P<replacement_version>\S+))?"
).match

def preprocess(line):
"""
Expand All @@ -60,6 +68,60 @@ def preprocess(line):
line = line.strip()
return line

def parse_replace_directive(line):
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring is minimal and doesn't explain the behavior for different types of replace directives (local vs. remote, with/without versions). Consider adding examples and explaining the return value structure for better maintainability.

Suggested change
def parse_replace_directive(line):
def parse_replace_directive(line):
"""
Parse a single Go ``replace`` directive line into original and replacement
module descriptors.
This expects a line in the canonical Go syntax (typically already
preprocessed by :func:`preprocess`) of the form::
replace <ns_name> [<version>] => <replacement_ns_name> [<replacement_version>]
where:
- ``<ns_name>`` and ``<replacement_ns_name>`` are either:
- a Go module path such as ``example.com/mod/submod``, or
- a local filesystem path starting with ``./`` or ``../`` on the
right-hand side only.
- ``<version>`` and ``<replacement_version>`` are optional version
strings (for example ``v1.2.3``). If omitted, the corresponding
``version`` field in the result will be ``None``.
The returned value is a 2-tuple ``(original_module, replacement_module)``,
where each element is a ``dict`` with the following keys:
``original_module``:
- ``namespace``: the module path prefix before the last ``/`` in
``<ns_name>`` (empty string if there is no ``/``).
- ``name``: the last path segment of ``<ns_name>``.
- ``version``: the optional version string following ``<ns_name>``,
or ``None`` if not present.
``replacement_module``:
- ``namespace``: for remote replacements, the module path prefix
before the last ``/`` in ``<replacement_ns_name>``; for local
path replacements, this is ``None``.
- ``name``: for remote replacements, the last path segment of
``<replacement_ns_name>``; for local path replacements, the
full local path (for example ``./local/module``).
- ``version``: the optional ``<replacement_version>`` string, or
``None`` if not present.
- ``is_local``: ``True`` if ``<replacement_ns_name>`` is a local
path starting with ``./`` or ``../``, otherwise ``False``.
- ``local_path``: the original ``<replacement_ns_name>`` string if
``is_local`` is ``True``, otherwise ``None``.
Examples::
# Remote -> remote, both with versions
# line: 'replace example.com/mod v1.0.0 => example.com/mod v1.1.0'
# original_module: {'namespace': 'example.com', 'name': 'mod', 'version': 'v1.0.0'}
# replacement_module:{'namespace': 'example.com', 'name': 'mod',
# 'version': 'v1.1.0', 'is_local': False, 'local_path': None}
# Remote -> local, original with version, replacement without version
# line: 'replace example.com/mod v1.0.0 => ./local/mod'
# original_module: {'namespace': 'example.com', 'name': 'mod', 'version': 'v1.0.0'}
# replacement_module:{'namespace': None, 'name': './local/mod',
# 'version': None, 'is_local': True,
# 'local_path': './local/mod'}
"""

Copilot uses AI. Check for mistakes.
parsed_replace = parse_rep_link(line)
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parse_replace_directive function calls parse_rep_link(line) but doesn't check if the match is None before calling .group() on it. If the regex doesn't match, this will raise an AttributeError when trying to call .group() on None. Add a check to handle cases where the regex doesn't match the line.

Suggested change
parsed_replace = parse_rep_link(line)
parsed_replace = parse_rep_link(line)
if not parsed_replace:
return None, None

Copilot uses AI. Check for mistakes.
ns_name = parsed_replace.group("ns_name")
version = parsed_replace.group("version")
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a module path doesn't contain any slashes (e.g., a simple module name), rpartition('/') will return ('', '', 'modulename'), resulting in an empty namespace and the full name in the name field. This is the expected behavior for single-component module names, but consider adding a comment to clarify this edge case handling.

Suggested change
version = parsed_replace.group("version")
version = parsed_replace.group("version")
# When ns_name has no '/', rpartition('/') returns ('', '', ns_name),
# resulting in an empty namespace and the full module name in `name`.
# This is the expected behavior for single-component module names.

Copilot uses AI. Check for mistakes.
namespace, _, name = ns_name.rpartition("/")
original_module = {
"namespace": namespace,
"name": name,
"version": version
}

replacement_ns_name = parsed_replace.group("replacement_ns_name")
replacement_version = parsed_replace.group("replacement_version")
is_local = replacement_ns_name.startswith("./") or replacement_ns_name.startswith("../")

if is_local:
replacement_namespace = None
replacement_name = replacement_ns_name
else:
replacement_namespace, _, replacement_name = replacement_ns_name.rpartition("/")

replacement_module = {
"namespace": replacement_namespace,
"name": replacement_name,
"version": replacement_version,
"is_local": is_local,
"local_path": replacement_ns_name if is_local else None
}

return original_module, replacement_module

def handle_replace_directive(line, require, exclude, local_replacements):
original, replacement = parse_replace_directive(line)
exclude.append(
GoModule(
namespace=original.get('namespace'),
name=original.get('name'),
version=original.get('version'),
)
)

if replacement.get('is_local'):
local_replacements.append({
'replaces': f"{original.get('namespace')}/{original.get('name')}",
Comment on lines +113 to +114
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When namespace is empty (e.g., for single-word modules without slashes), the replaces string will start with a slash like "/modulename". This happens when original.get('namespace') returns an empty string. Consider handling this case to avoid creating malformed module paths.

Suggested change
local_replacements.append({
'replaces': f"{original.get('namespace')}/{original.get('name')}",
original_namespace = original.get('namespace') or ''
original_name = original.get('name') or ''
if original_namespace:
replaces = f"{original_namespace}/{original_name}"
else:
replaces = original_name
local_replacements.append({
'replaces': replaces,

Copilot uses AI. Check for mistakes.
'local_path': replacement.get('local_path')
})
else:
require.append(
GoModule(
namespace=replacement.get('namespace'),
name=replacement.get('name'),
version=replacement.get('version')
)
)

def parse_gomod(location):
"""
Expand Down Expand Up @@ -120,6 +182,7 @@ def parse_gomod(location):
gomods = GoModule()
require = []
exclude = []
local_replacements = []

for i, line in enumerate(lines):
line = preprocess(line)
Expand Down Expand Up @@ -158,6 +221,19 @@ def parse_gomod(location):
)
continue

if 'replace' in line and '(' in line:
for rep in lines[i + 1:]:
rep = preprocess(rep)
if ')' in rep:
break
handle_replace_directive(rep, require, exclude, local_replacements)
continue

if 'replace' in line and '=>' in line:
line = line.lstrip("replace").strip()
Comment on lines +228 to +233
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block-style parsing doesn't handle empty lines or check if the regex match succeeds before calling handle_replace_directive. Empty lines within the block or lines that don't match the expected format will cause failures. Add checks to skip empty lines and validate the match before processing.

Suggested change
break
handle_replace_directive(rep, require, exclude, local_replacements)
continue
if 'replace' in line and '=>' in line:
line = line.lstrip("replace").strip()
break
# Skip empty or non-directive lines inside a replace block
if not rep or '=>' not in rep:
continue
handle_replace_directive(rep, require, exclude, local_replacements)
continue
if 'replace' in line and '=>' in line:
line = line.lstrip("replace").strip()
# Ensure we only process non-empty replace directives
if not line:
continue

Copilot uses AI. Check for mistakes.
handle_replace_directive(line, require, exclude, local_replacements)
continue

parsed_module_name = parse_module(line)
if parsed_module_name:
ns_name = parsed_module_name.group('ns_name')
Expand Down Expand Up @@ -188,6 +264,7 @@ def parse_gomod(location):

gomods.require = require
gomods.exclude = exclude
gomods.local_replacements = local_replacements

return gomods

Expand All @@ -202,7 +279,6 @@ def parse_gomod(location):
r'h1:(?P<checksum>[^\s]*)'
).match


def parse_gosum(location):
"""
Return a list of GoSum from parsing the go.sum file at `location`.
Expand Down
108 changes: 108 additions & 0 deletions src/packagedcode/golang.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
# See https://aboutcode.org for more information about nexB OSS projects.
#

import os
import posixpath
from packagedcode import go_mod
from packagedcode import models

Expand All @@ -24,6 +26,27 @@
# TODO: use the LICENSE file convention!
# TODO: support "vendor" and "workspace" layouts

# Tracing flags
TRACE = False or os.environ.get("SCANCODE_DEBUG_PACKAGE", False)


# Tracing flags
def logger_debug(*args):
pass


if TRACE:
import logging
import sys

logger = logging.getLogger(__name__)
logging.basicConfig(stream=sys.stdout)
logger.setLevel(logging.DEBUG)

def logger_debug(*args):
return logger.debug(" ".join(isinstance(a, str) and a or repr(a) for a in args))



class BaseGoModuleHandler(models.DatafileHandler):

Expand All @@ -32,13 +55,93 @@ def assemble(cls, package_data, resource, codebase, package_adder):
"""
Always use go.mod first then go.sum
"""

if not codebase.has_single_resource:
cls.resolve_local_replacements(
package_data=package_data,
resource=resource,
codebase=codebase,
)

resource.package_data[0] = package_data.to_dict()

yield from cls.assemble_from_many_datafiles(
datafile_name_patterns=('go.mod', 'go.sum',),
directory=resource.parent(codebase),
codebase=codebase,
package_adder=package_adder,
)

@classmethod
def resolve_local_replacements(cls, package_data, resource, codebase):
"""
Resolve local paths present in replace directives
"""

local_replacements = package_data.extra_data.get('local_replacements', [])
if not local_replacements:
logger_debug(f"resolve_local_replacements: No local replacements found")
return

base_dir = resource.parent(codebase)
base_path = base_dir.path

for idx, replacement in enumerate(local_replacements):
local_path = replacement. get('local_path')
if not local_path:
logger_debug(f"resolve_local_replacements: Skipping replacement {idx + 1} - no local_path found")
continue

full_path = posixpath.normpath(
posixpath.join(base_path, local_path)
)

local_resource = codebase.get_resource(full_path)
if not local_resource:
logger_debug(f"resolve_local_replacements: Resource not found at {full_path}")
continue

local_gomod = None
for child in local_resource. children(codebase):
if child.name == 'go.mod':
local_gomod = child
break

if not local_gomod or not local_gomod. package_data:
logger_debug(f"resolve_local_replacements: No go.mod or package_data found in {full_path}")
continue

try:
local_pkg_dict = local_gomod.package_data[0]
local_pkg_data = models.PackageData.from_dict(local_pkg_dict)
except (IndexError, KeyError, TypeError) as e:
logger_debug(f"resolve_local_replacements: Failed to parse package data: {e}")
continue

if not local_pkg_data. purl:
logger_debug(f"resolve_local_replacements: No purl found in local package data")
continue

resolved_dependency = models.DependentPackage(
purl=local_pkg_data.purl,
extracted_requirement=local_pkg_data.version or None,
resolved_package=local_pkg_data.to_dict(),
scope='require',
is_runtime=True,
is_optional=False,
extra_data={
'replaces': replacement.get('replaces'),
'resolved_from_local': True,
'local_path': local_path,
'local_resolved_path': full_path,
}
)

if not any(dep.purl == resolved_dependency.purl for dep in package_data.dependencies):
package_data.dependencies.append(resolved_dependency)
logger_debug(f"resolve_local_replacements: Added dependency: {resolved_dependency.purl}")
else:
logger_debug(f"resolve_local_replacements: Dependency already exists, skipping: {resolved_dependency. purl}")

class GoModHandler(BaseGoModuleHandler):
datasource_id = 'go_mod'
Expand Down Expand Up @@ -79,6 +182,10 @@ def parse(cls, location, package_only=False):
)
)

extra_data = {
'local_replacements': gomods.local_replacements
}

name = gomods.name
namespace = gomods.namespace

Expand All @@ -98,6 +205,7 @@ def parse(cls, location, package_only=False):
homepage_url=homepage_url,
repository_homepage_url=repository_homepage_url,
dependencies=dependencies,
extra_data=extra_data if gomods.local_replacements else {},
primary_language=cls.default_primary_language,
)
yield models.PackageData.from_data(package_data, package_only)
Expand Down
30 changes: 30 additions & 0 deletions tests/packagedcode/data/golang/gomod/gopls/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module golang.org/x/tools/gopls

go 1.18

require (
github.com/google/go-cmp v0.5.9
github.com/jba/printsrc v0.2.2
github.com/jba/templatecheck v0.6.0
github.com/sergi/go-diff v1.1.0
golang.org/x/mod v0.12.0
golang.org/x/sync v0.3.0
golang.org/x/sys v0.11.0
golang.org/x/telemetry v0.0.0-20230808152233-a65b40c0fdb0
golang.org/x/text v0.12.0
golang.org/x/tools v0.6.0
golang.org/x/vuln v0.0.0-20230110180137-6ad3e3d07815
gopkg.in/yaml.v3 v3.0.1
honnef.co/go/tools v0.4.2
mvdan.cc/gofumpt v0.4.0
mvdan.cc/xurls/v2 v2.4.0
)

require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/google/safehtml v0.1.0 // indirect
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
golang.org/x/exp/typeparams v0.0.0-20221212164502-fae10dda9338 // indirect
)

replace golang.org/x/tools => ../
Loading