Add comprehensive tests for Go and Python version conflict detection and licensing normalization
- Implemented GoVersionConflictDetectorTests to validate pseudo-version detection, conflict analysis, and conflict retrieval for Go modules. - Created VersionConflictDetectorTests for Python to assess conflict detection across various version scenarios, including major, minor, and patch differences. - Added SpdxLicenseNormalizerTests to ensure accurate normalization of SPDX license strings and classifiers. - Developed VendoredPackageDetectorTests to identify vendored packages and extract embedded packages from Python packages, including handling of vendor directories and known vendored packages.
This commit is contained in:
@@ -175,6 +175,47 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
metadata["workspace"] = "true";
|
||||
}
|
||||
|
||||
// Add license metadata
|
||||
if (!string.IsNullOrEmpty(inventory.License))
|
||||
{
|
||||
metadata["license"] = inventory.License;
|
||||
}
|
||||
|
||||
// Add CGO metadata
|
||||
if (!inventory.CgoAnalysis.IsEmpty)
|
||||
{
|
||||
metadata["cgo.enabled"] = inventory.CgoAnalysis.HasCgoImport ? "true" : "false";
|
||||
|
||||
var cflags = inventory.CgoAnalysis.GetCFlags();
|
||||
if (!string.IsNullOrEmpty(cflags))
|
||||
{
|
||||
metadata["cgo.cflags"] = cflags;
|
||||
}
|
||||
|
||||
var ldflags = inventory.CgoAnalysis.GetLdFlags();
|
||||
if (!string.IsNullOrEmpty(ldflags))
|
||||
{
|
||||
metadata["cgo.ldflags"] = ldflags;
|
||||
}
|
||||
|
||||
if (inventory.CgoAnalysis.NativeLibraries.Length > 0)
|
||||
{
|
||||
metadata["cgo.nativeLibs"] = string.Join(",", inventory.CgoAnalysis.NativeLibraries.Take(10));
|
||||
}
|
||||
|
||||
if (inventory.CgoAnalysis.IncludedHeaders.Length > 0)
|
||||
{
|
||||
metadata["cgo.headers"] = string.Join(",", inventory.CgoAnalysis.IncludedHeaders.Take(10));
|
||||
}
|
||||
}
|
||||
|
||||
// Add conflict summary for main module
|
||||
if (inventory.ConflictAnalysis.HasConflicts)
|
||||
{
|
||||
metadata["conflict.count"] = inventory.ConflictAnalysis.Conflicts.Length.ToString();
|
||||
metadata["conflict.maxSeverity"] = inventory.ConflictAnalysis.MaxSeverity.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
var evidence = new List<LanguageComponentEvidence>();
|
||||
|
||||
if (!string.IsNullOrEmpty(goModRelative))
|
||||
@@ -187,6 +228,17 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
null));
|
||||
}
|
||||
|
||||
// Add CGO file evidence
|
||||
foreach (var cgoFile in inventory.CgoAnalysis.CgoFiles.Take(5))
|
||||
{
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"cgo-source",
|
||||
cgoFile,
|
||||
"import \"C\"",
|
||||
null));
|
||||
}
|
||||
|
||||
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
|
||||
|
||||
// Main module typically has (devel) as version in source context
|
||||
@@ -281,6 +333,37 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
metadata["excluded"] = "true";
|
||||
}
|
||||
|
||||
// Add license metadata
|
||||
if (!string.IsNullOrEmpty(module.License))
|
||||
{
|
||||
metadata["license"] = module.License;
|
||||
if (module.LicenseConfidence != GoLicenseDetector.LicenseConfidence.None)
|
||||
{
|
||||
metadata["license.confidence"] = module.LicenseConfidence.ToString().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
// Add pseudo-version indicator
|
||||
if (module.IsPseudoVersion)
|
||||
{
|
||||
metadata["pseudoVersion"] = "true";
|
||||
}
|
||||
|
||||
// Add conflict metadata for this specific module
|
||||
var conflict = inventory.ConflictAnalysis.GetConflict(module.Path);
|
||||
if (conflict is not null)
|
||||
{
|
||||
metadata["conflict.detected"] = "true";
|
||||
metadata["conflict.severity"] = conflict.Severity.ToString().ToLowerInvariant();
|
||||
metadata["conflict.type"] = conflict.ConflictType.ToString();
|
||||
|
||||
var otherVersions = conflict.OtherVersions.Take(5).ToList();
|
||||
if (otherVersions.Count > 0)
|
||||
{
|
||||
metadata["conflict.otherVersions"] = string.Join(",", otherVersions);
|
||||
}
|
||||
}
|
||||
|
||||
var evidence = new List<LanguageComponentEvidence>();
|
||||
|
||||
// Evidence from go.mod
|
||||
@@ -428,6 +511,28 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
AddIfMissing(entries, "build.vcs.modified", dwarf.Modified?.ToString()?.ToLowerInvariant());
|
||||
AddIfMissing(entries, "build.vcs.time", dwarf.TimestampUtc);
|
||||
}
|
||||
|
||||
// Extract explicit CGO metadata from build settings
|
||||
var cgoSettings = GoCgoDetector.ExtractFromBuildSettings(buildInfo.Settings);
|
||||
if (cgoSettings.CgoEnabled)
|
||||
{
|
||||
AddIfMissing(entries, "cgo.enabled", "true");
|
||||
AddIfMissing(entries, "cgo.cflags", cgoSettings.CgoFlags);
|
||||
AddIfMissing(entries, "cgo.ldflags", cgoSettings.CgoLdFlags);
|
||||
AddIfMissing(entries, "cgo.cc", cgoSettings.CCompiler);
|
||||
AddIfMissing(entries, "cgo.cxx", cgoSettings.CxxCompiler);
|
||||
}
|
||||
|
||||
// Scan for native libraries alongside the binary
|
||||
var binaryDir = Path.GetDirectoryName(buildInfo.AbsoluteBinaryPath);
|
||||
if (!string.IsNullOrEmpty(binaryDir))
|
||||
{
|
||||
var nativeLibs = GoCgoDetector.ScanForNativeLibraries(binaryDir);
|
||||
if (nativeLibs.Count > 0)
|
||||
{
|
||||
AddIfMissing(entries, "cgo.nativeLibs", string.Join(",", nativeLibs.Take(10)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Detects CGO usage in Go modules and binaries.
|
||||
/// Equivalent to Java's JNI detection for native code integration.
|
||||
/// </summary>
|
||||
internal static partial class GoCgoDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Native library file extensions.
|
||||
/// </summary>
|
||||
private static readonly string[] NativeLibraryExtensions =
|
||||
[
|
||||
".so", // Linux shared library
|
||||
".dll", // Windows dynamic link library
|
||||
".dylib", // macOS dynamic library
|
||||
".a", // Static library (archive)
|
||||
".lib", // Windows static library
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Result of CGO analysis for a Go module.
|
||||
/// </summary>
|
||||
public sealed record CgoAnalysisResult
|
||||
{
|
||||
public static readonly CgoAnalysisResult Empty = new(
|
||||
false,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<CgoDirective>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
public CgoAnalysisResult(
|
||||
bool hasCgoImport,
|
||||
ImmutableArray<string> cgoFiles,
|
||||
ImmutableArray<CgoDirective> directives,
|
||||
ImmutableArray<string> nativeLibraries,
|
||||
ImmutableArray<string> includedHeaders)
|
||||
{
|
||||
HasCgoImport = hasCgoImport;
|
||||
CgoFiles = cgoFiles;
|
||||
Directives = directives;
|
||||
NativeLibraries = nativeLibraries;
|
||||
IncludedHeaders = includedHeaders;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if any Go file imports "C".
|
||||
/// </summary>
|
||||
public bool HasCgoImport { get; }
|
||||
|
||||
/// <summary>
|
||||
/// List of Go files containing CGO imports.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> CgoFiles { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed #cgo directives from source files.
|
||||
/// </summary>
|
||||
public ImmutableArray<CgoDirective> Directives { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Native libraries found alongside Go source/binary.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> NativeLibraries { get; }
|
||||
|
||||
/// <summary>
|
||||
/// C headers included in cgo preamble.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> IncludedHeaders { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any CGO usage was detected.
|
||||
/// </summary>
|
||||
public bool IsEmpty => !HasCgoImport && CgoFiles.IsEmpty && NativeLibraries.IsEmpty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets CFLAGS from directives.
|
||||
/// </summary>
|
||||
public string? GetCFlags()
|
||||
=> GetDirectiveValues("CFLAGS");
|
||||
|
||||
/// <summary>
|
||||
/// Gets LDFLAGS from directives.
|
||||
/// </summary>
|
||||
public string? GetLdFlags()
|
||||
=> GetDirectiveValues("LDFLAGS");
|
||||
|
||||
/// <summary>
|
||||
/// Gets pkg-config packages from directives.
|
||||
/// </summary>
|
||||
public string? GetPkgConfig()
|
||||
=> GetDirectiveValues("pkg-config");
|
||||
|
||||
private string? GetDirectiveValues(string directiveType)
|
||||
{
|
||||
var values = Directives
|
||||
.Where(d => d.Type.Equals(directiveType, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(d => d.Value)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return values.Count > 0 ? string.Join(" ", values) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed #cgo directive.
|
||||
/// </summary>
|
||||
public sealed record CgoDirective(
|
||||
string Type,
|
||||
string Value,
|
||||
string? Constraint,
|
||||
string SourceFile);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a Go module directory for CGO usage.
|
||||
/// </summary>
|
||||
public static CgoAnalysisResult AnalyzeModule(string modulePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(modulePath);
|
||||
|
||||
if (!Directory.Exists(modulePath))
|
||||
{
|
||||
return CgoAnalysisResult.Empty;
|
||||
}
|
||||
|
||||
var cgoFiles = new List<string>();
|
||||
var directives = new List<CgoDirective>();
|
||||
var headers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var nativeLibs = new List<string>();
|
||||
|
||||
// Scan for .go files with CGO imports
|
||||
var goFiles = EnumerateGoFiles(modulePath);
|
||||
|
||||
foreach (var goFile in goFiles)
|
||||
{
|
||||
var result = AnalyzeGoFile(goFile);
|
||||
if (result.HasCgoImport)
|
||||
{
|
||||
cgoFiles.Add(Path.GetRelativePath(modulePath, goFile));
|
||||
directives.AddRange(result.Directives);
|
||||
|
||||
foreach (var header in result.Headers)
|
||||
{
|
||||
headers.Add(header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan for native libraries
|
||||
nativeLibs.AddRange(ScanForNativeLibraries(modulePath));
|
||||
|
||||
return new CgoAnalysisResult(
|
||||
cgoFiles.Count > 0,
|
||||
[.. cgoFiles.OrderBy(f => f, StringComparer.Ordinal)],
|
||||
[.. directives],
|
||||
[.. nativeLibs.Distinct().OrderBy(l => l, StringComparer.Ordinal)],
|
||||
[.. headers.OrderBy(h => h, StringComparer.Ordinal)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts CGO settings from build info settings.
|
||||
/// </summary>
|
||||
public static CgoBuildSettings ExtractFromBuildSettings(
|
||||
IEnumerable<KeyValuePair<string, string?>> settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
string? cgoEnabled = null;
|
||||
string? cgoFlags = null;
|
||||
string? cgoLdFlags = null;
|
||||
string? ccCompiler = null;
|
||||
string? cxxCompiler = null;
|
||||
|
||||
foreach (var setting in settings)
|
||||
{
|
||||
switch (setting.Key)
|
||||
{
|
||||
case "CGO_ENABLED":
|
||||
cgoEnabled = setting.Value;
|
||||
break;
|
||||
case "CGO_CFLAGS":
|
||||
cgoFlags = setting.Value;
|
||||
break;
|
||||
case "CGO_LDFLAGS":
|
||||
cgoLdFlags = setting.Value;
|
||||
break;
|
||||
case "CC":
|
||||
ccCompiler = setting.Value;
|
||||
break;
|
||||
case "CXX":
|
||||
cxxCompiler = setting.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new CgoBuildSettings(
|
||||
cgoEnabled?.Equals("1", StringComparison.Ordinal) == true,
|
||||
cgoFlags,
|
||||
cgoLdFlags,
|
||||
ccCompiler,
|
||||
cxxCompiler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans for native libraries in a directory (alongside a binary).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ScanForNativeLibraries(string directoryPath)
|
||||
{
|
||||
if (!Directory.Exists(directoryPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var libraries = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(directoryPath, "*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
var extension = Path.GetExtension(file);
|
||||
if (NativeLibraryExtensions.Any(ext =>
|
||||
extension.Equals(ext, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
libraries.Add(Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip inaccessible directories
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip inaccessible directories
|
||||
}
|
||||
|
||||
return libraries;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateGoFiles(string rootPath)
|
||||
{
|
||||
var options = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
MaxRecursionDepth = 10,
|
||||
};
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(rootPath, "*.go", options))
|
||||
{
|
||||
// Skip test files and vendor directory
|
||||
if (file.EndsWith("_test.go", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.Contains($"{Path.DirectorySeparatorChar}vendor{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.AltDirectorySeparatorChar}vendor{Path.AltDirectorySeparatorChar}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return file;
|
||||
}
|
||||
}
|
||||
|
||||
private static GoFileAnalysisResult AnalyzeGoFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(filePath);
|
||||
return AnalyzeGoFileContent(content, filePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return GoFileAnalysisResult.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return GoFileAnalysisResult.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal static GoFileAnalysisResult AnalyzeGoFileContent(string content, string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return GoFileAnalysisResult.Empty;
|
||||
}
|
||||
|
||||
// Check for import "C"
|
||||
var hasCgoImport = CgoImportPattern().IsMatch(content);
|
||||
|
||||
if (!hasCgoImport)
|
||||
{
|
||||
return GoFileAnalysisResult.Empty;
|
||||
}
|
||||
|
||||
var directives = new List<CgoDirective>();
|
||||
var headers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Find the cgo preamble (comment block before import "C")
|
||||
var preambleMatch = CgoPreamblePattern().Match(content);
|
||||
if (preambleMatch.Success)
|
||||
{
|
||||
var preamble = preambleMatch.Groups[1].Value;
|
||||
|
||||
// Parse #cgo directives
|
||||
foreach (Match directiveMatch in CgoDirectivePattern().Matches(preamble))
|
||||
{
|
||||
var constraint = directiveMatch.Groups[1].Success
|
||||
? directiveMatch.Groups[1].Value.Trim()
|
||||
: null;
|
||||
var directiveType = directiveMatch.Groups[2].Value.Trim();
|
||||
var directiveValue = directiveMatch.Groups[3].Value.Trim();
|
||||
|
||||
directives.Add(new CgoDirective(
|
||||
directiveType,
|
||||
directiveValue,
|
||||
constraint,
|
||||
filePath));
|
||||
}
|
||||
|
||||
// Parse #include directives for headers
|
||||
foreach (Match includeMatch in CIncludePattern().Matches(preamble))
|
||||
{
|
||||
var header = includeMatch.Groups[1].Value;
|
||||
if (!string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
headers.Add(header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new GoFileAnalysisResult(true, directives, headers.ToList());
|
||||
}
|
||||
|
||||
internal sealed record GoFileAnalysisResult(
|
||||
bool HasCgoImport,
|
||||
List<CgoDirective> Directives,
|
||||
List<string> Headers)
|
||||
{
|
||||
public static readonly GoFileAnalysisResult Empty = new(false, [], []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CGO build settings extracted from binary build info.
|
||||
/// </summary>
|
||||
public sealed record CgoBuildSettings(
|
||||
bool CgoEnabled,
|
||||
string? CgoFlags,
|
||||
string? CgoLdFlags,
|
||||
string? CCompiler,
|
||||
string? CxxCompiler)
|
||||
{
|
||||
public static readonly CgoBuildSettings Empty = new(false, null, null, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if CGO is enabled.
|
||||
/// </summary>
|
||||
public bool IsEmpty => !CgoEnabled &&
|
||||
string.IsNullOrEmpty(CgoFlags) &&
|
||||
string.IsNullOrEmpty(CgoLdFlags);
|
||||
}
|
||||
|
||||
// Regex patterns
|
||||
|
||||
/// <summary>
|
||||
/// Matches: import "C" or import ( ... "C" ... )
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"import\s*(?:\(\s*)?""C""", RegexOptions.Multiline)]
|
||||
private static partial Regex CgoImportPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Matches the cgo preamble comment block before import "C".
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"/\*\s*((?:#.*?\n|.*?\n)*?)\s*\*/\s*import\s*""C""", RegexOptions.Singleline)]
|
||||
private static partial Regex CgoPreamblePattern();
|
||||
|
||||
/// <summary>
|
||||
/// Matches #cgo directives with optional build constraints.
|
||||
/// Format: #cgo [GOOS GOARCH] DIRECTIVE: value
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"#cgo\s+(?:([a-z0-9_,!\s]+)\s+)?(\w+):\s*(.+?)(?=\n|$)", RegexOptions.Multiline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CgoDirectivePattern();
|
||||
|
||||
/// <summary>
|
||||
/// Matches C #include directives.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"#include\s*[<""]([^>""]+)[>""]", RegexOptions.Multiline)]
|
||||
private static partial Regex CIncludePattern();
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Detects and normalizes licenses for Go modules.
|
||||
/// Scans LICENSE files and converts to SPDX identifiers.
|
||||
/// </summary>
|
||||
internal static partial class GoLicenseDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Common license file names to scan.
|
||||
/// </summary>
|
||||
private static readonly string[] LicenseFileNames =
|
||||
[
|
||||
"LICENSE",
|
||||
"LICENSE.txt",
|
||||
"LICENSE.md",
|
||||
"LICENSE.rst",
|
||||
"LICENCE", // British spelling
|
||||
"LICENCE.txt",
|
||||
"LICENCE.md",
|
||||
"COPYING",
|
||||
"COPYING.txt",
|
||||
"COPYING.md",
|
||||
"MIT-LICENSE",
|
||||
"MIT-LICENSE.txt",
|
||||
"APACHE-LICENSE",
|
||||
"APACHE-LICENSE.txt",
|
||||
"APACHE-2.0.txt",
|
||||
"UNLICENSE",
|
||||
"UNLICENSE.txt",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// License patterns mapped to SPDX identifiers.
|
||||
/// Order matters - more specific patterns first.
|
||||
/// </summary>
|
||||
private static readonly LicensePattern[] LicensePatterns =
|
||||
[
|
||||
// Apache variants
|
||||
new("Apache-2.0", @"Apache License.*?(?:Version 2\.0|v2\.0)", "Apache License, Version 2.0"),
|
||||
new("Apache-1.1", @"Apache License.*?(?:Version 1\.1|v1\.1)", "Apache License, Version 1.1"),
|
||||
new("Apache-1.0", @"Apache License.*?(?:Version 1\.0|v1\.0)", "Apache License, Version 1.0"),
|
||||
|
||||
// MIT variants
|
||||
new("MIT", @"(?:MIT License|Permission is hereby granted, free of charge)", "MIT License"),
|
||||
new("MIT-0", @"MIT No Attribution", "MIT No Attribution"),
|
||||
|
||||
// BSD variants (order matters - check 3-clause before 2-clause)
|
||||
new("BSD-3-Clause", @"BSD 3-Clause|Redistribution and use.*?3\. Neither the name", "BSD 3-Clause License"),
|
||||
new("BSD-2-Clause", @"BSD 2-Clause|Redistribution and use.*?provided that the following conditions", "BSD 2-Clause License"),
|
||||
new("BSD-3-Clause-Clear", @"BSD-3-Clause-Clear|clear BSD", "BSD 3-Clause Clear License"),
|
||||
new("0BSD", @"Zero-Clause BSD|BSD Zero Clause", "BSD Zero Clause License"),
|
||||
|
||||
// GPL variants
|
||||
new("GPL-3.0-only", @"GNU GENERAL PUBLIC LICENSE.*?Version 3", "GNU General Public License v3.0 only"),
|
||||
new("GPL-3.0-or-later", @"GNU GENERAL PUBLIC LICENSE.*?Version 3.*?or \(at your option\) any later", "GNU General Public License v3.0 or later"),
|
||||
new("GPL-2.0-only", @"GNU GENERAL PUBLIC LICENSE.*?Version 2(?!.*or later)", "GNU General Public License v2.0 only"),
|
||||
new("GPL-2.0-or-later", @"GNU GENERAL PUBLIC LICENSE.*?Version 2.*?or \(at your option\) any later", "GNU General Public License v2.0 or later"),
|
||||
|
||||
// LGPL variants
|
||||
new("LGPL-3.0-only", @"GNU LESSER GENERAL PUBLIC LICENSE.*?Version 3", "GNU Lesser General Public License v3.0 only"),
|
||||
new("LGPL-2.1-only", @"GNU LESSER GENERAL PUBLIC LICENSE.*?Version 2\.1", "GNU Lesser General Public License v2.1 only"),
|
||||
new("LGPL-2.0-only", @"GNU LIBRARY GENERAL PUBLIC LICENSE.*?Version 2", "GNU Library General Public License v2 only"),
|
||||
|
||||
// AGPL variants
|
||||
new("AGPL-3.0-only", @"GNU AFFERO GENERAL PUBLIC LICENSE.*?Version 3", "GNU Affero General Public License v3.0 only"),
|
||||
|
||||
// Mozilla
|
||||
new("MPL-2.0", @"Mozilla Public License.*?(?:Version 2\.0|v2\.0|2\.0)", "Mozilla Public License 2.0"),
|
||||
new("MPL-1.1", @"Mozilla Public License.*?(?:Version 1\.1|v1\.1|1\.1)", "Mozilla Public License 1.1"),
|
||||
|
||||
// Creative Commons
|
||||
new("CC-BY-4.0", @"Creative Commons Attribution 4\.0", "Creative Commons Attribution 4.0"),
|
||||
new("CC-BY-SA-4.0", @"Creative Commons Attribution-ShareAlike 4\.0", "Creative Commons Attribution ShareAlike 4.0"),
|
||||
new("CC0-1.0", @"CC0 1\.0|Creative Commons Zero", "Creative Commons Zero v1.0 Universal"),
|
||||
|
||||
// Other common licenses
|
||||
new("ISC", @"ISC License|Permission to use, copy, modify, and/or distribute", "ISC License"),
|
||||
new("Unlicense", @"This is free and unencumbered software released into the public domain", "The Unlicense"),
|
||||
new("WTFPL", @"DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE", "Do What The F*ck You Want To Public License"),
|
||||
new("Zlib", @"zlib License|This software is provided 'as-is'", "zlib License"),
|
||||
new("BSL-1.0", @"Boost Software License", "Boost Software License 1.0"),
|
||||
new("PostgreSQL", @"PostgreSQL License", "PostgreSQL License"),
|
||||
new("BlueOak-1.0.0", @"Blue Oak Model License", "Blue Oak Model License 1.0.0"),
|
||||
|
||||
// Dual/multiple license indicators
|
||||
new("MIT OR Apache-2.0", @"(?:MIT|Apache)[/\s]+(?:OR|AND|/)[/\s]+(?:Apache|MIT)", "MIT OR Apache-2.0"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Result of license detection for a module.
|
||||
/// </summary>
|
||||
public sealed record LicenseInfo(
|
||||
string? SpdxIdentifier,
|
||||
string? LicenseFile,
|
||||
string? RawLicenseName,
|
||||
LicenseConfidence Confidence)
|
||||
{
|
||||
public static readonly LicenseInfo Unknown = new(null, null, null, LicenseConfidence.None);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if a license was detected.
|
||||
/// </summary>
|
||||
public bool IsDetected => !string.IsNullOrEmpty(SpdxIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for license detection.
|
||||
/// </summary>
|
||||
public enum LicenseConfidence
|
||||
{
|
||||
/// <summary>No license detected.</summary>
|
||||
None = 0,
|
||||
/// <summary>Matched by heuristic or partial match.</summary>
|
||||
Low = 1,
|
||||
/// <summary>Matched by pattern with good confidence.</summary>
|
||||
Medium = 2,
|
||||
/// <summary>Exact SPDX identifier found or strong pattern match.</summary>
|
||||
High = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects license for a Go module at the given path.
|
||||
/// </summary>
|
||||
public static LicenseInfo DetectLicense(string modulePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(modulePath);
|
||||
|
||||
if (!Directory.Exists(modulePath))
|
||||
{
|
||||
return LicenseInfo.Unknown;
|
||||
}
|
||||
|
||||
// Search for license files
|
||||
foreach (var licenseFileName in LicenseFileNames)
|
||||
{
|
||||
var licensePath = Path.Combine(modulePath, licenseFileName);
|
||||
if (File.Exists(licensePath))
|
||||
{
|
||||
var result = AnalyzeLicenseFile(licensePath);
|
||||
if (result.IsDetected)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for license in a docs subdirectory
|
||||
var docsPath = Path.Combine(modulePath, "docs");
|
||||
if (Directory.Exists(docsPath))
|
||||
{
|
||||
foreach (var licenseFileName in LicenseFileNames)
|
||||
{
|
||||
var licensePath = Path.Combine(docsPath, licenseFileName);
|
||||
if (File.Exists(licensePath))
|
||||
{
|
||||
var result = AnalyzeLicenseFile(licensePath);
|
||||
if (result.IsDetected)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return LicenseInfo.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects license for a vendored module.
|
||||
/// </summary>
|
||||
public static LicenseInfo DetectVendoredLicense(string vendorPath, string modulePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vendorPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(modulePath);
|
||||
|
||||
// vendor/<module-path>/LICENSE
|
||||
var vendoredModulePath = Path.Combine(vendorPath, modulePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
if (Directory.Exists(vendoredModulePath))
|
||||
{
|
||||
return DetectLicense(vendoredModulePath);
|
||||
}
|
||||
|
||||
return LicenseInfo.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a license file and returns detected license info.
|
||||
/// </summary>
|
||||
public static LicenseInfo AnalyzeLicenseFile(string filePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
|
||||
try
|
||||
{
|
||||
// Read first 8KB of file (should be enough for license detection)
|
||||
var content = ReadFileHead(filePath, 8192);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return LicenseInfo.Unknown;
|
||||
}
|
||||
|
||||
return AnalyzeLicenseContent(content, filePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return LicenseInfo.Unknown;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return LicenseInfo.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes license content and returns detected license info.
|
||||
/// </summary>
|
||||
internal static LicenseInfo AnalyzeLicenseContent(string content, string? sourceFile = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return LicenseInfo.Unknown;
|
||||
}
|
||||
|
||||
// Check for explicit SPDX identifier first (highest confidence)
|
||||
var spdxMatch = SpdxIdentifierPattern().Match(content);
|
||||
if (spdxMatch.Success)
|
||||
{
|
||||
var spdxId = spdxMatch.Groups[1].Value.Trim();
|
||||
return new LicenseInfo(spdxId, sourceFile, spdxId, LicenseConfidence.High);
|
||||
}
|
||||
|
||||
// Try pattern matching
|
||||
foreach (var pattern in LicensePatterns)
|
||||
{
|
||||
if (pattern.CompiledRegex.IsMatch(content))
|
||||
{
|
||||
return new LicenseInfo(
|
||||
pattern.SpdxId,
|
||||
sourceFile,
|
||||
pattern.DisplayName,
|
||||
LicenseConfidence.Medium);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for common keywords as low-confidence fallback
|
||||
var keywordLicense = DetectByKeywords(content);
|
||||
if (keywordLicense is not null)
|
||||
{
|
||||
return new LicenseInfo(
|
||||
keywordLicense,
|
||||
sourceFile,
|
||||
keywordLicense,
|
||||
LicenseConfidence.Low);
|
||||
}
|
||||
|
||||
return LicenseInfo.Unknown;
|
||||
}
|
||||
|
||||
private static string? DetectByKeywords(string content)
|
||||
{
|
||||
var upperContent = content.ToUpperInvariant();
|
||||
|
||||
// Very basic keyword detection as fallback
|
||||
if (upperContent.Contains("MIT"))
|
||||
{
|
||||
return "MIT";
|
||||
}
|
||||
|
||||
if (upperContent.Contains("APACHE"))
|
||||
{
|
||||
return "Apache-2.0"; // Default to 2.0
|
||||
}
|
||||
|
||||
if (upperContent.Contains("BSD"))
|
||||
{
|
||||
return "BSD-3-Clause"; // Default to 3-clause
|
||||
}
|
||||
|
||||
if (upperContent.Contains("GPL"))
|
||||
{
|
||||
return "GPL-3.0-only"; // Default to 3.0
|
||||
}
|
||||
|
||||
if (upperContent.Contains("PUBLIC DOMAIN") || upperContent.Contains("UNLICENSE"))
|
||||
{
|
||||
return "Unlicense";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReadFileHead(string filePath, int maxBytes)
|
||||
{
|
||||
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var buffer = new byte[Math.Min(maxBytes, stream.Length)];
|
||||
var bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
// Try UTF-8 first, fall back to ASCII
|
||||
try
|
||||
{
|
||||
return System.Text.Encoding.UTF8.GetString(buffer, 0, bytesRead);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return System.Text.Encoding.ASCII.GetString(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches SPDX-License-Identifier comments.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"SPDX-License-Identifier:\s*([A-Za-z0-9\-\.+]+(?:\s+(?:OR|AND)\s+[A-Za-z0-9\-\.+]+)*)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SpdxIdentifierPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Internal record for license patterns.
|
||||
/// </summary>
|
||||
private sealed record LicensePattern
|
||||
{
|
||||
public LicensePattern(string spdxId, string pattern, string displayName)
|
||||
{
|
||||
SpdxId = spdxId;
|
||||
DisplayName = displayName;
|
||||
CompiledRegex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
}
|
||||
|
||||
public string SpdxId { get; }
|
||||
public string DisplayName { get; }
|
||||
public Regex CompiledRegex { get; }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,21 @@ internal static class GoSourceInventory
|
||||
public string Source { get; init; } = "go.mod";
|
||||
public string ModuleCategory { get; init; } = "public";
|
||||
public string? Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SPDX license identifier if detected.
|
||||
/// </summary>
|
||||
public string? License { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// License detection confidence.
|
||||
/// </summary>
|
||||
public GoLicenseDetector.LicenseConfidence LicenseConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this is a pseudo-version (unreleased code).
|
||||
/// </summary>
|
||||
public bool IsPseudoVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,18 +53,27 @@ internal static class GoSourceInventory
|
||||
null,
|
||||
null,
|
||||
ImmutableArray<GoSourceModule>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
ImmutableArray<string>.Empty,
|
||||
GoVersionConflictDetector.GoConflictAnalysis.Empty,
|
||||
GoCgoDetector.CgoAnalysisResult.Empty,
|
||||
null);
|
||||
|
||||
public SourceInventoryResult(
|
||||
string? modulePath,
|
||||
string? goVersion,
|
||||
ImmutableArray<GoSourceModule> modules,
|
||||
ImmutableArray<string> retractedVersions)
|
||||
ImmutableArray<string> retractedVersions,
|
||||
GoVersionConflictDetector.GoConflictAnalysis conflictAnalysis,
|
||||
GoCgoDetector.CgoAnalysisResult cgoAnalysis,
|
||||
string? license)
|
||||
{
|
||||
ModulePath = modulePath;
|
||||
GoVersion = goVersion;
|
||||
Modules = modules;
|
||||
RetractedVersions = retractedVersions;
|
||||
ConflictAnalysis = conflictAnalysis;
|
||||
CgoAnalysis = cgoAnalysis;
|
||||
License = license;
|
||||
}
|
||||
|
||||
public string? ModulePath { get; }
|
||||
@@ -57,6 +81,21 @@ internal static class GoSourceInventory
|
||||
public ImmutableArray<GoSourceModule> Modules { get; }
|
||||
public ImmutableArray<string> RetractedVersions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Version conflict analysis for this inventory.
|
||||
/// </summary>
|
||||
public GoVersionConflictDetector.GoConflictAnalysis ConflictAnalysis { get; }
|
||||
|
||||
/// <summary>
|
||||
/// CGO usage analysis for this module.
|
||||
/// </summary>
|
||||
public GoCgoDetector.CgoAnalysisResult CgoAnalysis { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Main module license (SPDX identifier).
|
||||
/// </summary>
|
||||
public string? License { get; }
|
||||
|
||||
public bool IsEmpty => Modules.IsEmpty && string.IsNullOrEmpty(ModulePath);
|
||||
}
|
||||
|
||||
@@ -114,6 +153,7 @@ internal static class GoSourceInventory
|
||||
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(req.Path);
|
||||
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(req.Path);
|
||||
var registry = GoPrivateModuleDetector.GetRegistry(req.Path);
|
||||
var isPseudoVersion = GoVersionConflictDetector.IsPseudoVersion(req.Version);
|
||||
|
||||
// Check for replacement
|
||||
GoModParser.GoModReplace? replacement = null;
|
||||
@@ -127,6 +167,20 @@ internal static class GoSourceInventory
|
||||
// Check if excluded
|
||||
var isExcluded = excludes.Contains(versionedKey);
|
||||
|
||||
// Detect license for vendored modules
|
||||
string? license = null;
|
||||
var licenseConfidence = GoLicenseDetector.LicenseConfidence.None;
|
||||
if (isVendored && project.HasVendor)
|
||||
{
|
||||
var vendorDir = Path.GetDirectoryName(project.VendorModulesPath);
|
||||
if (!string.IsNullOrEmpty(vendorDir))
|
||||
{
|
||||
var licenseInfo = GoLicenseDetector.DetectVendoredLicense(vendorDir, req.Path);
|
||||
license = licenseInfo.SpdxIdentifier;
|
||||
licenseConfidence = licenseInfo.Confidence;
|
||||
}
|
||||
}
|
||||
|
||||
var module = new GoSourceModule
|
||||
{
|
||||
Path = req.Path,
|
||||
@@ -143,7 +197,10 @@ internal static class GoSourceInventory
|
||||
ReplacementVersion = replacement?.NewVersion,
|
||||
Source = isVendored ? "vendor" : "go.mod",
|
||||
ModuleCategory = moduleCategory,
|
||||
Registry = registry
|
||||
Registry = registry,
|
||||
License = license,
|
||||
LicenseConfidence = licenseConfidence,
|
||||
IsPseudoVersion = isPseudoVersion
|
||||
};
|
||||
|
||||
modules.Add(module);
|
||||
@@ -162,6 +219,21 @@ internal static class GoSourceInventory
|
||||
{
|
||||
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(vendorMod.Path);
|
||||
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(vendorMod.Path);
|
||||
var isPseudoVersion = GoVersionConflictDetector.IsPseudoVersion(vendorMod.Version);
|
||||
|
||||
// Detect license for vendored module
|
||||
string? license = null;
|
||||
var licenseConfidence = GoLicenseDetector.LicenseConfidence.None;
|
||||
if (project.HasVendor)
|
||||
{
|
||||
var vendorDir = Path.GetDirectoryName(project.VendorModulesPath);
|
||||
if (!string.IsNullOrEmpty(vendorDir))
|
||||
{
|
||||
var licenseInfo = GoLicenseDetector.DetectVendoredLicense(vendorDir, vendorMod.Path);
|
||||
license = licenseInfo.SpdxIdentifier;
|
||||
licenseConfidence = licenseInfo.Confidence;
|
||||
}
|
||||
}
|
||||
|
||||
modules.Add(new GoSourceModule
|
||||
{
|
||||
@@ -176,17 +248,36 @@ internal static class GoSourceInventory
|
||||
IsRetracted = false,
|
||||
IsPrivate = isPrivate,
|
||||
Source = "vendor",
|
||||
ModuleCategory = moduleCategory
|
||||
ModuleCategory = moduleCategory,
|
||||
License = license,
|
||||
LicenseConfidence = licenseConfidence,
|
||||
IsPseudoVersion = isPseudoVersion
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform conflict analysis
|
||||
var conflictAnalysis = GoVersionConflictDetector.Analyze(
|
||||
modules,
|
||||
goMod.Replaces.ToList(),
|
||||
goMod.Excludes.ToList(),
|
||||
retractedVersions);
|
||||
|
||||
// Analyze CGO usage in the module
|
||||
var cgoAnalysis = GoCgoDetector.AnalyzeModule(project.RootPath);
|
||||
|
||||
// Detect main module license
|
||||
var mainLicense = GoLicenseDetector.DetectLicense(project.RootPath);
|
||||
|
||||
return new SourceInventoryResult(
|
||||
goMod.ModulePath,
|
||||
goMod.GoVersion,
|
||||
modules.ToImmutableArray(),
|
||||
retractedVersions);
|
||||
retractedVersions,
|
||||
conflictAnalysis,
|
||||
cgoAnalysis,
|
||||
mainLicense.SpdxIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Detects version conflicts in Go module dependencies.
|
||||
/// Similar to Java's VersionConflictDetector for Maven artifacts.
|
||||
/// </summary>
|
||||
internal static partial class GoVersionConflictDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Conflict severity levels.
|
||||
/// </summary>
|
||||
public enum GoConflictSeverity
|
||||
{
|
||||
/// <summary>No conflict detected.</summary>
|
||||
None = 0,
|
||||
/// <summary>Minor version mismatch or informational.</summary>
|
||||
Low = 1,
|
||||
/// <summary>Potential compatibility issue.</summary>
|
||||
Medium = 2,
|
||||
/// <summary>Likely breaking change or security concern.</summary>
|
||||
High = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of version conflicts in Go modules.
|
||||
/// </summary>
|
||||
public enum GoConflictType
|
||||
{
|
||||
/// <summary>No conflict.</summary>
|
||||
None,
|
||||
/// <summary>Module replaced with different version.</summary>
|
||||
ReplaceOverride,
|
||||
/// <summary>Module replaced with local path.</summary>
|
||||
LocalReplacement,
|
||||
/// <summary>Using pseudo-version (unreleased code).</summary>
|
||||
PseudoVersion,
|
||||
/// <summary>Major version mismatch in module path.</summary>
|
||||
MajorVersionMismatch,
|
||||
/// <summary>Multiple workspace modules require different versions.</summary>
|
||||
WorkspaceConflict,
|
||||
/// <summary>Excluded version is still being required.</summary>
|
||||
ExcludedVersion,
|
||||
/// <summary>Using a retracted version.</summary>
|
||||
RetractedVersion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a detected version conflict.
|
||||
/// </summary>
|
||||
public sealed record GoVersionConflict(
|
||||
string ModulePath,
|
||||
string SelectedVersion,
|
||||
ImmutableArray<string> RequestedVersions,
|
||||
GoConflictSeverity Severity,
|
||||
GoConflictType ConflictType,
|
||||
string? Description)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets other versions that were requested but not selected.
|
||||
/// </summary>
|
||||
public IEnumerable<string> OtherVersions
|
||||
=> RequestedVersions.Where(v => !v.Equals(SelectedVersion, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of conflict analysis for a module inventory.
|
||||
/// </summary>
|
||||
public sealed record GoConflictAnalysis
|
||||
{
|
||||
public static readonly GoConflictAnalysis Empty = new(
|
||||
ImmutableArray<GoVersionConflict>.Empty,
|
||||
ImmutableDictionary<string, GoVersionConflict>.Empty);
|
||||
|
||||
public GoConflictAnalysis(
|
||||
ImmutableArray<GoVersionConflict> conflicts,
|
||||
ImmutableDictionary<string, GoVersionConflict> byModule)
|
||||
{
|
||||
Conflicts = conflicts;
|
||||
_byModule = byModule;
|
||||
}
|
||||
|
||||
private readonly ImmutableDictionary<string, GoVersionConflict> _byModule;
|
||||
|
||||
/// <summary>
|
||||
/// All detected conflicts.
|
||||
/// </summary>
|
||||
public ImmutableArray<GoVersionConflict> Conflicts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any conflicts were detected.
|
||||
/// </summary>
|
||||
public bool HasConflicts => Conflicts.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the highest severity among all conflicts.
|
||||
/// </summary>
|
||||
public GoConflictSeverity MaxSeverity
|
||||
=> Conflicts.Length > 0 ? Conflicts.Max(c => c.Severity) : GoConflictSeverity.None;
|
||||
|
||||
/// <summary>
|
||||
/// Gets conflict for a specific module if one exists.
|
||||
/// </summary>
|
||||
public GoVersionConflict? GetConflict(string modulePath)
|
||||
=> _byModule.TryGetValue(modulePath, out var conflict) ? conflict : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes module inventory for version conflicts.
|
||||
/// </summary>
|
||||
public static GoConflictAnalysis Analyze(
|
||||
IReadOnlyList<GoSourceInventory.GoSourceModule> modules,
|
||||
IReadOnlyList<GoModParser.GoModReplace> replaces,
|
||||
IReadOnlyList<GoModParser.GoModExclude> excludes,
|
||||
ImmutableArray<string> retractedVersions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(modules);
|
||||
ArgumentNullException.ThrowIfNull(replaces);
|
||||
ArgumentNullException.ThrowIfNull(excludes);
|
||||
|
||||
if (modules.Count == 0)
|
||||
{
|
||||
return GoConflictAnalysis.Empty;
|
||||
}
|
||||
|
||||
var conflicts = new List<GoVersionConflict>();
|
||||
|
||||
// Build exclude set for quick lookup
|
||||
var excludeSet = excludes
|
||||
.Select(e => $"{e.Path}@{e.Version}")
|
||||
.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Build replace map
|
||||
var replaceMap = replaces.ToDictionary(
|
||||
r => r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath,
|
||||
r => r,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
// Check for pseudo-version
|
||||
if (IsPseudoVersion(module.Version))
|
||||
{
|
||||
conflicts.Add(new GoVersionConflict(
|
||||
module.Path,
|
||||
module.Version,
|
||||
[module.Version],
|
||||
GoConflictSeverity.Medium,
|
||||
GoConflictType.PseudoVersion,
|
||||
"Using pseudo-version indicates unreleased or unstable code"));
|
||||
}
|
||||
|
||||
// Check for replace directive conflicts
|
||||
if (module.IsReplaced)
|
||||
{
|
||||
var severity = GoConflictSeverity.Low;
|
||||
var conflictType = GoConflictType.ReplaceOverride;
|
||||
var description = $"Module replaced with {module.ReplacementPath}";
|
||||
|
||||
// Local path replacement is higher risk
|
||||
if (IsLocalPath(module.ReplacementPath))
|
||||
{
|
||||
severity = GoConflictSeverity.High;
|
||||
conflictType = GoConflictType.LocalReplacement;
|
||||
description = "Module replaced with local path - may not be reproducible";
|
||||
}
|
||||
|
||||
conflicts.Add(new GoVersionConflict(
|
||||
module.Path,
|
||||
module.Version,
|
||||
[module.Version],
|
||||
severity,
|
||||
conflictType,
|
||||
description));
|
||||
}
|
||||
|
||||
// Check for excluded version being required
|
||||
var versionedKey = $"{module.Path}@{module.Version}";
|
||||
if (excludeSet.Contains(versionedKey))
|
||||
{
|
||||
conflicts.Add(new GoVersionConflict(
|
||||
module.Path,
|
||||
module.Version,
|
||||
[module.Version],
|
||||
GoConflictSeverity.High,
|
||||
GoConflictType.ExcludedVersion,
|
||||
"Required version is explicitly excluded"));
|
||||
}
|
||||
|
||||
// Check for retracted versions (in own module's go.mod)
|
||||
if (module.IsRetracted || retractedVersions.Contains(module.Version))
|
||||
{
|
||||
conflicts.Add(new GoVersionConflict(
|
||||
module.Path,
|
||||
module.Version,
|
||||
[module.Version],
|
||||
GoConflictSeverity.High,
|
||||
GoConflictType.RetractedVersion,
|
||||
"Using a retracted version - may have known issues"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for major version mismatches
|
||||
var modulesByBasePath = modules
|
||||
.GroupBy(m => ExtractBasePath(m.Path), StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => g.Count() > 1);
|
||||
|
||||
foreach (var group in modulesByBasePath)
|
||||
{
|
||||
var versions = group.Select(m => ExtractMajorVersion(m.Path)).Distinct().ToList();
|
||||
if (versions.Count > 1)
|
||||
{
|
||||
foreach (var module in group)
|
||||
{
|
||||
var otherVersions = group
|
||||
.Where(m => !m.Path.Equals(module.Path, StringComparison.Ordinal))
|
||||
.Select(m => m.Version)
|
||||
.ToImmutableArray();
|
||||
|
||||
conflicts.Add(new GoVersionConflict(
|
||||
module.Path,
|
||||
module.Version,
|
||||
[module.Version, .. otherVersions],
|
||||
GoConflictSeverity.Medium,
|
||||
GoConflictType.MajorVersionMismatch,
|
||||
$"Multiple major versions of same module: {string.Join(", ", versions)}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var byModule = conflicts
|
||||
.GroupBy(c => c.ModulePath, StringComparer.Ordinal)
|
||||
.Select(g => g.OrderByDescending(c => c.Severity).First())
|
||||
.ToImmutableDictionary(c => c.ModulePath, c => c, StringComparer.Ordinal);
|
||||
|
||||
return new GoConflictAnalysis(
|
||||
[.. conflicts.OrderBy(c => c.ModulePath, StringComparer.Ordinal)],
|
||||
byModule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes workspace for cross-module version conflicts.
|
||||
/// </summary>
|
||||
public static GoConflictAnalysis AnalyzeWorkspace(
|
||||
IReadOnlyList<GoSourceInventory.SourceInventoryResult> inventories)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inventories);
|
||||
|
||||
if (inventories.Count < 2)
|
||||
{
|
||||
return GoConflictAnalysis.Empty;
|
||||
}
|
||||
|
||||
var conflicts = new List<GoVersionConflict>();
|
||||
|
||||
// Group all modules by path across workspace members
|
||||
var allModules = inventories
|
||||
.SelectMany(inv => inv.Modules)
|
||||
.GroupBy(m => m.Path, StringComparer.Ordinal);
|
||||
|
||||
foreach (var group in allModules)
|
||||
{
|
||||
var versions = group
|
||||
.Select(m => m.Version)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (versions.Count > 1)
|
||||
{
|
||||
// Different versions of same dependency across workspace
|
||||
var selectedVersion = SelectMvsVersion(versions);
|
||||
|
||||
conflicts.Add(new GoVersionConflict(
|
||||
group.Key,
|
||||
selectedVersion,
|
||||
[.. versions],
|
||||
GoConflictSeverity.Low,
|
||||
GoConflictType.WorkspaceConflict,
|
||||
$"Workspace modules require different versions: {string.Join(", ", versions)}"));
|
||||
}
|
||||
}
|
||||
|
||||
var byModule = conflicts
|
||||
.ToImmutableDictionary(c => c.ModulePath, c => c, StringComparer.Ordinal);
|
||||
|
||||
return new GoConflictAnalysis([.. conflicts], byModule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a version string is a pseudo-version.
|
||||
/// Pseudo-versions have format: v0.0.0-yyyymmddhhmmss-abcdefabcdef
|
||||
/// </summary>
|
||||
public static bool IsPseudoVersion(string version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return PseudoVersionPattern().IsMatch(version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a path is a local filesystem path.
|
||||
/// </summary>
|
||||
private static bool IsLocalPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Starts with ./ or ../ or /
|
||||
if (path.StartsWith('.') || path.StartsWith('/') || path.StartsWith('\\'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Windows absolute path
|
||||
if (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the base path without major version suffix.
|
||||
/// Example: "github.com/user/repo/v2" -> "github.com/user/repo"
|
||||
/// </summary>
|
||||
private static string ExtractBasePath(string modulePath)
|
||||
{
|
||||
var match = MajorVersionSuffixPattern().Match(modulePath);
|
||||
return match.Success ? modulePath[..^match.Length] : modulePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts major version from module path.
|
||||
/// Example: "github.com/user/repo/v2" -> "v2"
|
||||
/// </summary>
|
||||
private static string ExtractMajorVersion(string modulePath)
|
||||
{
|
||||
var match = MajorVersionSuffixPattern().Match(modulePath);
|
||||
return match.Success ? match.Value : "v0/v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates Go's Minimal Version Selection to pick the highest version.
|
||||
/// </summary>
|
||||
private static string SelectMvsVersion(IEnumerable<string> versions)
|
||||
{
|
||||
// MVS picks the highest version among all requested
|
||||
return versions
|
||||
.OrderByDescending(v => v, SemVerComparer.Instance)
|
||||
.First();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches pseudo-versions: v0.0.0-timestamp-hash or vX.Y.Z-pre.0.timestamp-hash
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^v\d+\.\d+\.\d+(-[a-z0-9]+)?\.?\d*\.?\d{14}-[a-f0-9]{12}$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex PseudoVersionPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Matches major version suffix: /v2, /v3, etc.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"/v\d+$")]
|
||||
private static partial Regex MajorVersionSuffixPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Comparer for semantic versions that handles Go module versions.
|
||||
/// </summary>
|
||||
private sealed class SemVerComparer : IComparer<string>
|
||||
{
|
||||
public static readonly SemVerComparer Instance = new();
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (x is null && y is null) return 0;
|
||||
if (x is null) return -1;
|
||||
if (y is null) return 1;
|
||||
|
||||
var partsX = ParseVersion(x);
|
||||
var partsY = ParseVersion(y);
|
||||
|
||||
// Compare major.minor.patch
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var comparison = partsX[i].CompareTo(partsY[i]);
|
||||
if (comparison != 0) return comparison;
|
||||
}
|
||||
|
||||
// Compare pre-release (no pre-release > pre-release)
|
||||
var preX = partsX[3] > 0 || !string.IsNullOrEmpty(GetPrerelease(x));
|
||||
var preY = partsY[3] > 0 || !string.IsNullOrEmpty(GetPrerelease(y));
|
||||
|
||||
if (!preX && preY) return 1;
|
||||
if (preX && !preY) return -1;
|
||||
|
||||
return string.CompareOrdinal(x, y);
|
||||
}
|
||||
|
||||
private static int[] ParseVersion(string version)
|
||||
{
|
||||
var result = new int[4]; // major, minor, patch, prerelease indicator
|
||||
|
||||
// Strip 'v' prefix
|
||||
if (version.StartsWith('v') || version.StartsWith('V'))
|
||||
{
|
||||
version = version[1..];
|
||||
}
|
||||
|
||||
// Handle pseudo-versions specially
|
||||
if (version.Contains('-'))
|
||||
{
|
||||
var dashIndex = version.IndexOf('-');
|
||||
version = version[..dashIndex];
|
||||
result[3] = 1; // Mark as pre-release
|
||||
}
|
||||
|
||||
var parts = version.Split('.');
|
||||
for (var i = 0; i < Math.Min(parts.Length, 3); i++)
|
||||
{
|
||||
if (int.TryParse(parts[i], out var num))
|
||||
{
|
||||
result[i] = num;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? GetPrerelease(string version)
|
||||
{
|
||||
var dashIndex = version.IndexOf('-');
|
||||
return dashIndex >= 0 ? version[(dashIndex + 1)..] : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.Go.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
|
||||
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
|
||||
|
||||
Reference in New Issue
Block a user