Skip to content

Conversation

@uttam282005
Copy link
Contributor

Fixes #3492

  1. Parsing Replace Directives (go_mod.py)
  • Added handle_replace_directive() function to parse and categorize replacements:
  • Parses both inline and block-style replace statements
  • Distinguishes between local path and remote replacements
  • Stores original module in exclude list (as it's being replaced)
  • Stores replacement in require list (remote) or local_replacements (local)
  1. Local Replacement Resolution (golang.py)
  • Added resolve_local_replacements() method that:
  • Traverses the codebase to locate local replacement targets
  • Resolves relative paths (e.g., ../pkg/) to absolute paths
  • Extracts package metadata from target's go.mod file
  • Creates resolved dependency entries with full package information

3. Assembly Integration

Modified BaseGoModuleHandler.assemble() to:

  • Resolve local replacements after package assembly
  • Ensure resolved dependencies are included in final output

This PR is inspired by the work done by @shravankshenoy and maintainer reviews on PR #3693.

Tasks

  • Reviewed contribution guidelines
  • PR is descriptively titled 📑 and links the original issue above 🔗
  • Tests pass -- look for a green checkbox ✔️ a few minutes after opening your PR
    Run tests locally to check for errors.
  • Commits are in uniquely-named feature branch and has no merge conflicts 📁
  • Updated documentation pages (if applicable)
  • Updated CHANGELOG.rst (if applicable)

Copilot AI review requested due to automatic review settings January 10, 2026 16:04
@uttam282005 uttam282005 force-pushed the support-replace-directive branch from dc7c152 to 0b389bf Compare January 10, 2026 16:05
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds support for parsing replace directives in Go module files (go.mod), enabling ScanCode to properly handle both local path and remote package replacements commonly used in Go projects.

Changes:

  • Added parsing logic for replace directives in both inline and block-style formats
  • Implemented resolution of local replacement paths to extract package metadata from target go.mod files
  • Enhanced package data model to track local replacements and their resolved dependencies

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/packagedcode/go_mod.py Added replace directive parsing functions, regex patterns, and local_replacements tracking in GoModule class
src/packagedcode/golang.py Added resolve_local_replacements method to resolve local paths and integrate resolved dependencies into package data
tests/packagedcode/test_golang.py Added two new test cases for milvus and gopls projects with replace directives
tests/packagedcode/data/golang/gomod/milvus/go.mod New test fixture with mixed local and remote replace directives
tests/packagedcode/data/golang/gomod/milvus/output.expected.json Expected parsing output for milvus test
tests/packagedcode/data/golang/gomod/gopls/go.mod New test fixture with local replace directive
tests/packagedcode/data/golang/gomod/gopls/output.expected.json Expected parsing output for gopls test
tests/packagedcode/data/golang/gomod/opencensus-service/output.expected.json Updated expected output to include replace directive results

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +113 to +114
local_replacements.append({
'replaces': f"{original.get('namespace')}/{original.get('name')}",
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.
Comment on lines +228 to +233
break
handle_replace_directive(rep, require, exclude, local_replacements)
continue

if 'replace' in line and '=>' in line:
line = line.lstrip("replace").strip()
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.
return line

def parse_replace_directive(line):
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.
def parse_replace_directive(line):
parsed_replace = parse_rep_link(line)
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.
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.
@uttam282005 uttam282005 marked this pull request as draft January 12, 2026 18:44
uttam282005 and others added 11 commits January 13, 2026 19:44
Signed-off-by: uttam282005 <[email protected]>
Signed-off-by: uttam282005 <[email protected]>
Signed-off-by: uttam282005 <[email protected]>
Signed-off-by: uttam282005 <[email protected]>
Signed-off-by: uttam282005 <[email protected]>
Signed-off-by: uttam282005 <[email protected]>
Co-authored-by: Copilot <[email protected]>
Signed-off-by: uttam282005 <[email protected]>
Signed-off-by: uttam282005 <[email protected]>
@uttam282005 uttam282005 force-pushed the support-replace-directive branch from 4da4fd1 to d6a0252 Compare January 13, 2026 14:14
@uttam282005 uttam282005 marked this pull request as ready for review January 13, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support directives in go.mod

1 participant