up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -0,0 +1,84 @@
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Contract for language-specific static lifters that extract callgraph edges
/// and symbol definitions for reachability analysis.
/// </summary>
/// <remarks>
/// Implementers must produce deterministic output: stable ordering, no randomness,
/// and normalized symbol IDs using <see cref="SymbolId"/> helpers.
/// </remarks>
public interface IReachabilityLifter
{
/// <summary>
/// Language identifier (e.g., "java", "dotnet", "node").
/// Must match <see cref="SymbolId.Lang"/> constants.
/// </summary>
string Language { get; }
/// <summary>
/// Lifts static callgraph information from analyzed artifacts.
/// </summary>
/// <param name="context">Analysis context with filesystem access.</param>
/// <param name="builder">Builder to emit nodes and edges.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task that completes when lifting is done.</returns>
ValueTask LiftAsync(ReachabilityLifterContext context, ReachabilityGraphBuilder builder, CancellationToken cancellationToken);
}
/// <summary>
/// Context provided to reachability lifters during analysis.
/// </summary>
public sealed class ReachabilityLifterContext
{
/// <summary>
/// Root path of the analysis target (workspace, container layer, etc.).
/// </summary>
public required string RootPath { get; init; }
/// <summary>
/// Analysis ID for CAS namespacing.
/// </summary>
public required string AnalysisId { get; init; }
/// <summary>
/// Optional layer digest for container analysis.
/// </summary>
public string? LayerDigest { get; init; }
/// <summary>
/// Optional entrypoint hint from image config.
/// </summary>
public string? Entrypoint { get; init; }
/// <summary>
/// Additional options for lifter behavior.
/// </summary>
public ReachabilityLifterOptions Options { get; init; } = ReachabilityLifterOptions.Default;
}
/// <summary>
/// Options controlling reachability lifter behavior.
/// </summary>
public sealed class ReachabilityLifterOptions
{
/// <summary>
/// Default options for production use.
/// </summary>
public static ReachabilityLifterOptions Default { get; } = new();
/// <summary>
/// Include edges with low confidence (dynamic/reflection patterns).
/// </summary>
public bool IncludeLowConfidenceEdges { get; init; } = true;
/// <summary>
/// Include framework/runtime symbols in the graph.
/// </summary>
public bool IncludeFrameworkSymbols { get; init; } = true;
/// <summary>
/// Maximum depth for transitive edge discovery.
/// </summary>
public int MaxTransitiveDepth { get; init; } = 10;
}

View File

@@ -0,0 +1,471 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
namespace StellaOps.Scanner.Reachability.Lifters;
/// <summary>
/// Reachability lifter for .NET projects.
/// Extracts callgraph edges from project references, package references, and assembly metadata.
/// </summary>
public sealed partial class DotNetReachabilityLifter : IReachabilityLifter
{
public string Language => SymbolId.Lang.DotNet;
public async ValueTask LiftAsync(ReachabilityLifterContext context, ReachabilityGraphBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(builder);
var rootPath = context.RootPath;
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
{
return;
}
// Find all project files
var projectFiles = Directory.EnumerateFiles(rootPath, "*.csproj", SearchOption.AllDirectories)
.Concat(Directory.EnumerateFiles(rootPath, "*.fsproj", SearchOption.AllDirectories))
.Concat(Directory.EnumerateFiles(rootPath, "*.vbproj", SearchOption.AllDirectories))
.Where(p => !p.Contains("obj", StringComparison.OrdinalIgnoreCase) || !IsObjFolder(rootPath, p))
.OrderBy(p => p, StringComparer.Ordinal)
.ToList();
// Build project graph
var projectGraph = new Dictionary<string, ProjectInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var projectFile in projectFiles)
{
cancellationToken.ThrowIfCancellationRequested();
var info = await ParseProjectFileAsync(context, projectFile, cancellationToken).ConfigureAwait(false);
if (info is not null)
{
projectGraph[projectFile] = info;
}
}
// Emit nodes and edges
foreach (var (projectPath, info) in projectGraph.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
EmitProjectNodes(context, builder, info);
EmitProjectEdges(context, builder, info, projectGraph);
}
// Process deps.json files for runtime assembly information
await ProcessDepsJsonFilesAsync(context, builder, rootPath, cancellationToken).ConfigureAwait(false);
}
private static bool IsObjFolder(string rootPath, string path)
{
var relativePath = Path.GetRelativePath(rootPath, path);
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return parts.Any(p => p.Equals("obj", StringComparison.OrdinalIgnoreCase));
}
private static async ValueTask<ProjectInfo?> ParseProjectFileAsync(
ReachabilityLifterContext context,
string projectPath,
CancellationToken cancellationToken)
{
try
{
var content = await File.ReadAllTextAsync(projectPath, cancellationToken).ConfigureAwait(false);
var doc = XDocument.Parse(content);
var root = doc.Root;
if (root is null)
{
return null;
}
var assemblyName = root.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "AssemblyName")?.Value
?? Path.GetFileNameWithoutExtension(projectPath);
var targetFramework = root.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "TargetFramework")?.Value;
var targetFrameworks = root.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "TargetFrameworks")?.Value;
var rootNamespace = root.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "RootNamespace")?.Value
?? assemblyName;
var packageRefs = root.Descendants()
.Where(e => e.Name.LocalName == "PackageReference")
.Select(e => new PackageRef(
e.Attribute("Include")?.Value ?? string.Empty,
e.Attribute("Version")?.Value ?? e.Descendants().FirstOrDefault(d => d.Name.LocalName == "Version")?.Value ?? "*"))
.Where(p => !string.IsNullOrWhiteSpace(p.Name))
.ToList();
var projectRefs = root.Descendants()
.Where(e => e.Name.LocalName == "ProjectReference")
.Select(e => e.Attribute("Include")?.Value ?? string.Empty)
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToList();
var frameworkRefs = root.Descendants()
.Where(e => e.Name.LocalName == "FrameworkReference")
.Select(e => e.Attribute("Include")?.Value ?? string.Empty)
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToList();
return new ProjectInfo(
projectPath,
assemblyName,
rootNamespace,
targetFramework ?? targetFrameworks?.Split(';').FirstOrDefault() ?? "net8.0",
packageRefs,
projectRefs,
frameworkRefs);
}
catch (Exception) when (IsExpectedException)
{
return null;
}
}
// Exception filter pattern for expected exceptions
private static bool IsExpectedException => true;
private static void EmitProjectNodes(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
ProjectInfo info)
{
var relativePath = NormalizePath(Path.GetRelativePath(context.RootPath, info.ProjectPath));
// Add assembly node
var assemblySymbol = SymbolId.ForDotNet(info.AssemblyName, string.Empty, string.Empty, string.Empty);
builder.AddNode(
symbolId: assemblySymbol,
lang: SymbolId.Lang.DotNet,
kind: "assembly",
display: info.AssemblyName,
sourceFile: relativePath,
attributes: new Dictionary<string, string>
{
["target_framework"] = info.TargetFramework,
["root_namespace"] = info.RootNamespace
});
// Add namespace node
if (!string.IsNullOrWhiteSpace(info.RootNamespace))
{
var nsSymbol = SymbolId.ForDotNet(info.AssemblyName, info.RootNamespace, string.Empty, string.Empty);
builder.AddNode(
symbolId: nsSymbol,
lang: SymbolId.Lang.DotNet,
kind: "namespace",
display: info.RootNamespace,
sourceFile: relativePath);
builder.AddEdge(
from: assemblySymbol,
to: nsSymbol,
edgeType: EdgeTypes.Loads,
confidence: EdgeConfidence.Certain,
origin: "static",
provenance: Provenance.Il,
evidence: $"file:{relativePath}:RootNamespace");
}
}
private static void EmitProjectEdges(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
ProjectInfo info,
Dictionary<string, ProjectInfo> projectGraph)
{
var relativePath = NormalizePath(Path.GetRelativePath(context.RootPath, info.ProjectPath));
var assemblySymbol = SymbolId.ForDotNet(info.AssemblyName, string.Empty, string.Empty, string.Empty);
// Package references
foreach (var pkgRef in info.PackageReferences.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
{
var pkgSymbol = SymbolId.ForDotNet(pkgRef.Name, string.Empty, string.Empty, string.Empty);
builder.AddNode(
symbolId: pkgSymbol,
lang: SymbolId.Lang.DotNet,
kind: "package",
display: pkgRef.Name,
attributes: new Dictionary<string, string>
{
["version"] = pkgRef.Version,
["purl"] = $"pkg:nuget/{pkgRef.Name}@{pkgRef.Version}"
});
builder.AddEdge(
from: assemblySymbol,
to: pkgSymbol,
edgeType: EdgeTypes.Import,
confidence: EdgeConfidence.Certain,
origin: "static",
provenance: Provenance.Il,
evidence: $"file:{relativePath}:PackageReference.{pkgRef.Name}");
}
// Project references
foreach (var projRef in info.ProjectReferences.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
{
var refPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(info.ProjectPath) ?? string.Empty, projRef));
if (projectGraph.TryGetValue(refPath, out var refInfo))
{
var refSymbol = SymbolId.ForDotNet(refInfo.AssemblyName, string.Empty, string.Empty, string.Empty);
builder.AddEdge(
from: assemblySymbol,
to: refSymbol,
edgeType: EdgeTypes.Import,
confidence: EdgeConfidence.Certain,
origin: "static",
provenance: Provenance.Il,
evidence: $"file:{relativePath}:ProjectReference");
}
}
// Framework references
foreach (var fwRef in info.FrameworkReferences.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
{
var fwSymbol = SymbolId.ForDotNet(fwRef, string.Empty, string.Empty, string.Empty);
builder.AddNode(
symbolId: fwSymbol,
lang: SymbolId.Lang.DotNet,
kind: "framework",
display: fwRef);
builder.AddEdge(
from: assemblySymbol,
to: fwSymbol,
edgeType: EdgeTypes.Import,
confidence: EdgeConfidence.Certain,
origin: "static",
provenance: Provenance.Il,
evidence: $"file:{relativePath}:FrameworkReference.{fwRef}");
}
}
private static async ValueTask ProcessDepsJsonFilesAsync(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
string rootPath,
CancellationToken cancellationToken)
{
var depsFiles = Directory.EnumerateFiles(rootPath, "*.deps.json", SearchOption.AllDirectories)
.Where(p => p.Contains("bin", StringComparison.OrdinalIgnoreCase) ||
p.Contains("publish", StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p, StringComparer.Ordinal)
.Take(10); // Limit to prevent huge processing
foreach (var depsFile in depsFiles)
{
cancellationToken.ThrowIfCancellationRequested();
await ProcessDepsJsonAsync(context, builder, depsFile, cancellationToken).ConfigureAwait(false);
}
}
private static async ValueTask ProcessDepsJsonAsync(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
string depsFile,
CancellationToken cancellationToken)
{
try
{
var content = await File.ReadAllTextAsync(depsFile, cancellationToken).ConfigureAwait(false);
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
// Extract runtime target
if (root.TryGetProperty("runtimeTarget", out var runtimeTarget) &&
runtimeTarget.TryGetProperty("name", out var runtimeName))
{
var targetName = runtimeName.GetString();
if (!string.IsNullOrWhiteSpace(targetName))
{
// Process targets for this runtime
if (root.TryGetProperty("targets", out var targets) &&
targets.TryGetProperty(targetName, out var targetLibs))
{
ProcessTargetLibraries(context, builder, targetLibs, depsFile);
}
}
}
// Process libraries for version info
if (root.TryGetProperty("libraries", out var libraries))
{
ProcessLibraries(context, builder, libraries, depsFile);
}
}
catch (JsonException)
{
// Invalid JSON
}
catch (IOException)
{
// File access issue
}
}
private static void ProcessTargetLibraries(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
JsonElement targetLibs,
string depsFile)
{
var relativeDepsPath = NormalizePath(Path.GetRelativePath(context.RootPath, depsFile));
foreach (var lib in targetLibs.EnumerateObject())
{
var libKey = lib.Name; // format: "PackageName/Version"
var slashIndex = libKey.IndexOf('/');
if (slashIndex <= 0)
{
continue;
}
var packageName = libKey[..slashIndex];
var version = libKey[(slashIndex + 1)..];
var libSymbol = SymbolId.ForDotNet(packageName, string.Empty, string.Empty, string.Empty);
builder.AddNode(
symbolId: libSymbol,
lang: SymbolId.Lang.DotNet,
kind: "library",
display: packageName,
sourceFile: relativeDepsPath,
attributes: new Dictionary<string, string>
{
["version"] = version,
["purl"] = $"pkg:nuget/{packageName}@{version}"
});
// Process dependencies
if (lib.Value.TryGetProperty("dependencies", out var deps))
{
foreach (var dep in deps.EnumerateObject())
{
var depName = dep.Name;
var depVersion = dep.Value.GetString() ?? "*";
var depSymbol = SymbolId.ForDotNet(depName, string.Empty, string.Empty, string.Empty);
builder.AddNode(
symbolId: depSymbol,
lang: SymbolId.Lang.DotNet,
kind: "library",
display: depName);
builder.AddEdge(
from: libSymbol,
to: depSymbol,
edgeType: EdgeTypes.Import,
confidence: EdgeConfidence.Certain,
origin: "static",
provenance: Provenance.Il,
evidence: $"file:{relativeDepsPath}:dependencies.{depName}");
}
}
// Process runtime assemblies
if (lib.Value.TryGetProperty("runtime", out var runtime))
{
foreach (var asm in runtime.EnumerateObject())
{
var asmPath = asm.Name;
var asmName = Path.GetFileNameWithoutExtension(asmPath);
if (!string.Equals(asmName, packageName, StringComparison.OrdinalIgnoreCase))
{
var asmSymbol = SymbolId.ForDotNet(asmName, string.Empty, string.Empty, string.Empty);
builder.AddNode(
symbolId: asmSymbol,
lang: SymbolId.Lang.DotNet,
kind: "assembly",
display: asmName);
builder.AddEdge(
from: libSymbol,
to: asmSymbol,
edgeType: EdgeTypes.Loads,
confidence: EdgeConfidence.Certain,
origin: "static",
provenance: Provenance.Il,
evidence: $"file:{relativeDepsPath}:runtime.{asmPath}");
}
}
}
}
}
private static void ProcessLibraries(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
JsonElement libraries,
string depsFile)
{
var relativeDepsPath = NormalizePath(Path.GetRelativePath(context.RootPath, depsFile));
foreach (var lib in libraries.EnumerateObject())
{
var libKey = lib.Name;
var slashIndex = libKey.IndexOf('/');
if (slashIndex <= 0)
{
continue;
}
var packageName = libKey[..slashIndex];
var version = libKey[(slashIndex + 1)..];
if (lib.Value.TryGetProperty("type", out var typeEl))
{
var libType = typeEl.GetString();
var kind = libType switch
{
"project" => "project",
"package" => "package",
_ => "library"
};
var libSymbol = SymbolId.ForDotNet(packageName, string.Empty, string.Empty, string.Empty);
builder.AddNode(
symbolId: libSymbol,
lang: SymbolId.Lang.DotNet,
kind: kind,
display: packageName,
attributes: new Dictionary<string, string>
{
["version"] = version,
["type"] = libType ?? "unknown"
});
}
}
}
private static string NormalizePath(string path) => path.Replace('\\', '/');
private sealed record ProjectInfo(
string ProjectPath,
string AssemblyName,
string RootNamespace,
string TargetFramework,
List<PackageRef> PackageReferences,
List<string> ProjectReferences,
List<string> FrameworkReferences);
private sealed record PackageRef(string Name, string Version);
}

View File

@@ -0,0 +1,428 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Reachability.Lifters;
/// <summary>
/// Reachability lifter for Node.js/npm projects.
/// Extracts callgraph edges from import/require statements and builds symbol IDs.
/// </summary>
public sealed class NodeReachabilityLifter : IReachabilityLifter
{
public string Language => SymbolId.Lang.Node;
public async ValueTask LiftAsync(ReachabilityLifterContext context, ReachabilityGraphBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(builder);
var rootPath = context.RootPath;
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
{
return;
}
// Find all package.json files
var packageJsonFiles = Directory.EnumerateFiles(rootPath, "package.json", SearchOption.AllDirectories)
.Where(p => !p.Contains("node_modules", StringComparison.OrdinalIgnoreCase) || IsDirectDependency(rootPath, p))
.OrderBy(p => p, StringComparer.Ordinal)
.ToList();
foreach (var packageJsonPath in packageJsonFiles)
{
cancellationToken.ThrowIfCancellationRequested();
await ProcessPackageAsync(context, builder, packageJsonPath, cancellationToken).ConfigureAwait(false);
}
// Process JS/TS files for import edges
await ProcessSourceFilesAsync(context, builder, rootPath, cancellationToken).ConfigureAwait(false);
}
private static bool IsDirectDependency(string rootPath, string packageJsonPath)
{
// Check if it's a direct dependency in node_modules (not nested)
var relativePath = Path.GetRelativePath(rootPath, packageJsonPath);
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
// Direct dep: node_modules/<pkg>/package.json or node_modules/@scope/pkg/package.json
if (parts.Length < 2 || !parts[0].Equals("node_modules", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Count how many node_modules segments there are
var nodeModulesCount = parts.Count(p => p.Equals("node_modules", StringComparison.OrdinalIgnoreCase));
return nodeModulesCount == 1;
}
private static async ValueTask ProcessPackageAsync(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
string packageJsonPath,
CancellationToken cancellationToken)
{
try
{
var content = await File.ReadAllTextAsync(packageJsonPath, cancellationToken).ConfigureAwait(false);
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
var pkgName = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null;
if (string.IsNullOrWhiteSpace(pkgName))
{
return;
}
var pkgVersion = root.TryGetProperty("version", out var verEl) ? verEl.GetString() : "0.0.0";
// Add package as a module node
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
var relativePath = Path.GetRelativePath(context.RootPath, packageJsonPath);
builder.AddNode(
symbolId: moduleSymbol,
lang: SymbolId.Lang.Node,
kind: "module",
display: pkgName,
sourceFile: NormalizePath(relativePath),
sourceLine: null,
attributes: new Dictionary<string, string>
{
["version"] = pkgVersion ?? "0.0.0",
["purl"] = $"pkg:npm/{EncodePackageName(pkgName)}@{pkgVersion}"
});
// Process entrypoints (main, module, exports)
ProcessEntrypoints(context, builder, root, pkgName, relativePath);
// Process dependencies as edges
ProcessDependencies(builder, root, pkgName, "dependencies", EdgeConfidence.Certain);
ProcessDependencies(builder, root, pkgName, "devDependencies", EdgeConfidence.Medium);
ProcessDependencies(builder, root, pkgName, "peerDependencies", EdgeConfidence.High);
ProcessDependencies(builder, root, pkgName, "optionalDependencies", EdgeConfidence.Low);
}
catch (JsonException)
{
// Invalid JSON, skip
}
catch (IOException)
{
// File access issue, skip
}
}
private static void ProcessEntrypoints(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
JsonElement root,
string pkgName,
string packageJsonPath)
{
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
// Process "main" field
if (root.TryGetProperty("main", out var mainEl) && mainEl.ValueKind == JsonValueKind.String)
{
var mainPath = mainEl.GetString();
if (!string.IsNullOrWhiteSpace(mainPath))
{
var entrySymbol = SymbolId.ForNode(pkgName, NormalizePath(mainPath), "entrypoint");
builder.AddNode(
symbolId: entrySymbol,
lang: SymbolId.Lang.Node,
kind: "entrypoint",
display: $"{pkgName}:{mainPath}",
sourceFile: NormalizePath(mainPath));
builder.AddEdge(
from: moduleSymbol,
to: entrySymbol,
edgeType: EdgeTypes.Loads,
confidence: EdgeConfidence.Certain,
origin: "static",
provenance: Provenance.TsAst,
evidence: $"file:{packageJsonPath}:main");
}
}
// Process "module" field (ESM entry)
if (root.TryGetProperty("module", out var moduleEl) && moduleEl.ValueKind == JsonValueKind.String)
{
var modulePath = moduleEl.GetString();
if (!string.IsNullOrWhiteSpace(modulePath))
{
var entrySymbol = SymbolId.ForNode(pkgName, NormalizePath(modulePath), "entrypoint");
builder.AddNode(
symbolId: entrySymbol,
lang: SymbolId.Lang.Node,
kind: "entrypoint",
display: $"{pkgName}:{modulePath} (ESM)",
sourceFile: NormalizePath(modulePath));
builder.AddEdge(
from: moduleSymbol,
to: entrySymbol,
edgeType: EdgeTypes.Loads,
confidence: EdgeConfidence.Certain,
origin: "static",
provenance: Provenance.TsAst,
evidence: $"file:{packageJsonPath}:module");
}
}
// Process "bin" field
if (root.TryGetProperty("bin", out var binEl))
{
if (binEl.ValueKind == JsonValueKind.String)
{
var binPath = binEl.GetString();
if (!string.IsNullOrWhiteSpace(binPath))
{
AddBinEntrypoint(builder, pkgName, pkgName, binPath, packageJsonPath);
}
}
else if (binEl.ValueKind == JsonValueKind.Object)
{
foreach (var bin in binEl.EnumerateObject())
{
if (bin.Value.ValueKind == JsonValueKind.String)
{
var binPath = bin.Value.GetString();
if (!string.IsNullOrWhiteSpace(binPath))
{
AddBinEntrypoint(builder, pkgName, bin.Name, binPath, packageJsonPath);
}
}
}
}
}
}
private static void AddBinEntrypoint(
ReachabilityGraphBuilder builder,
string pkgName,
string binName,
string binPath,
string packageJsonPath)
{
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
var binSymbol = SymbolId.ForNode(pkgName, NormalizePath(binPath), "bin");
builder.AddNode(
symbolId: binSymbol,
lang: SymbolId.Lang.Node,
kind: "binary",
display: $"{binName} -> {binPath}",
sourceFile: NormalizePath(binPath),
attributes: new Dictionary<string, string> { ["bin_name"] = binName });
builder.AddEdge(
from: moduleSymbol,
to: binSymbol,
edgeType: EdgeTypes.Spawn,
confidence: EdgeConfidence.Certain,
origin: "static",
provenance: Provenance.TsAst,
evidence: $"file:{packageJsonPath}:bin.{binName}");
}
private static void ProcessDependencies(
ReachabilityGraphBuilder builder,
JsonElement root,
string pkgName,
string depField,
EdgeConfidence confidence)
{
if (!root.TryGetProperty(depField, out var depsEl) || depsEl.ValueKind != JsonValueKind.Object)
{
return;
}
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
foreach (var dep in depsEl.EnumerateObject())
{
var depName = dep.Name;
var depVersion = dep.Value.ValueKind == JsonValueKind.String ? dep.Value.GetString() : "*";
var depSymbol = SymbolId.ForNode(depName, string.Empty, "module");
// Add the dependency as a node (may already exist)
builder.AddNode(
symbolId: depSymbol,
lang: SymbolId.Lang.Node,
kind: "module",
display: depName);
// Add edge from this package to the dependency
builder.AddEdge(
from: moduleSymbol,
to: depSymbol,
edgeType: EdgeTypes.Import,
confidence: confidence,
origin: "static",
provenance: Provenance.TsAst,
evidence: $"package.json:{depField}.{depName}");
}
}
private static async ValueTask ProcessSourceFilesAsync(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
string rootPath,
CancellationToken cancellationToken)
{
var jsFiles = Directory.EnumerateFiles(rootPath, "*.js", SearchOption.AllDirectories)
.Concat(Directory.EnumerateFiles(rootPath, "*.mjs", SearchOption.AllDirectories))
.Concat(Directory.EnumerateFiles(rootPath, "*.cjs", SearchOption.AllDirectories))
.Concat(Directory.EnumerateFiles(rootPath, "*.ts", SearchOption.AllDirectories))
.Concat(Directory.EnumerateFiles(rootPath, "*.tsx", SearchOption.AllDirectories))
.Where(p => !p.Contains("node_modules", StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p, StringComparer.Ordinal)
.Take(500); // Limit to prevent huge graphs
foreach (var filePath in jsFiles)
{
cancellationToken.ThrowIfCancellationRequested();
await ProcessSourceFileAsync(context, builder, filePath, cancellationToken).ConfigureAwait(false);
}
}
private static async ValueTask ProcessSourceFileAsync(
ReachabilityLifterContext context,
ReachabilityGraphBuilder builder,
string filePath,
CancellationToken cancellationToken)
{
try
{
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
var relativePath = NormalizePath(Path.GetRelativePath(context.RootPath, filePath));
// Simple regex-based import extraction (Esprima is in the analyzer, not available here)
ExtractImports(builder, relativePath, content);
}
catch (IOException)
{
// File access issue, skip
}
}
private static void ExtractImports(ReachabilityGraphBuilder builder, string sourceFile, string content)
{
var fileSymbol = SymbolId.ForNode(sourceFile, string.Empty, "file");
builder.AddNode(
symbolId: fileSymbol,
lang: SymbolId.Lang.Node,
kind: "file",
display: sourceFile,
sourceFile: sourceFile);
// Extract ES6 imports: import ... from '...'
var importMatches = System.Text.RegularExpressions.Regex.Matches(
content,
@"import\s+(?:(?:\*\s+as\s+\w+)|(?:\{[^}]*\})|(?:\w+(?:\s*,\s*\{[^}]*\})?)|(?:type\s+\{[^}]*\}))\s+from\s+['""]([^'""]+)['""]",
System.Text.RegularExpressions.RegexOptions.Multiline);
foreach (System.Text.RegularExpressions.Match match in importMatches)
{
var target = match.Groups[1].Value;
AddImportEdge(builder, fileSymbol, sourceFile, target, "import", EdgeConfidence.Certain);
}
// Extract require() calls: require('...')
var requireMatches = System.Text.RegularExpressions.Regex.Matches(
content,
@"require\s*\(\s*['""]([^'""]+)['""]\s*\)",
System.Text.RegularExpressions.RegexOptions.Multiline);
foreach (System.Text.RegularExpressions.Match match in requireMatches)
{
var target = match.Groups[1].Value;
AddImportEdge(builder, fileSymbol, sourceFile, target, "require", EdgeConfidence.Certain);
}
// Extract dynamic imports: import('...')
var dynamicImportMatches = System.Text.RegularExpressions.Regex.Matches(
content,
@"import\s*\(\s*['""]([^'""]+)['""]\s*\)",
System.Text.RegularExpressions.RegexOptions.Multiline);
foreach (System.Text.RegularExpressions.Match match in dynamicImportMatches)
{
var target = match.Groups[1].Value;
AddImportEdge(builder, fileSymbol, sourceFile, target, "import()", EdgeConfidence.High);
}
}
private static void AddImportEdge(
ReachabilityGraphBuilder builder,
string fromSymbol,
string sourceFile,
string target,
string kind,
EdgeConfidence confidence)
{
// Determine target symbol
string targetSymbol;
if (target.StartsWith(".", StringComparison.Ordinal))
{
// Relative import - resolve to file symbol
targetSymbol = SymbolId.ForNode(target, string.Empty, "file");
}
else
{
// Package import - resolve to module symbol
var pkgName = GetPackageNameFromSpecifier(target);
targetSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
}
builder.AddNode(
symbolId: targetSymbol,
lang: SymbolId.Lang.Node,
kind: target.StartsWith(".", StringComparison.Ordinal) ? "file" : "module",
display: target);
builder.AddEdge(
from: fromSymbol,
to: targetSymbol,
edgeType: EdgeTypes.Import,
confidence: confidence,
origin: "static",
provenance: Provenance.TsAst,
evidence: $"file:{sourceFile}:{kind}");
}
private static string GetPackageNameFromSpecifier(string specifier)
{
// Handle scoped packages (@scope/pkg)
if (specifier.StartsWith("@", StringComparison.Ordinal))
{
var parts = specifier.Split('/', 3);
return parts.Length >= 2 ? $"{parts[0]}/{parts[1]}" : specifier;
}
// Regular package (pkg/subpath)
var slashIndex = specifier.IndexOf('/');
return slashIndex > 0 ? specifier[..slashIndex] : specifier;
}
private static string NormalizePath(string path)
{
return path.Replace('\\', '/');
}
private static string EncodePackageName(string name)
{
if (name.StartsWith("@", StringComparison.Ordinal))
{
return "%40" + name[1..];
}
return name;
}
}

View File

@@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Reachability.Lifters;
/// <summary>
/// Registry and orchestrator for reachability lifters.
/// Manages all available lifters and coordinates graph extraction.
/// </summary>
public sealed class ReachabilityLifterRegistry
{
private readonly IReadOnlyList<IReachabilityLifter> _lifters;
/// <summary>
/// Creates a registry with the default set of lifters.
/// </summary>
public ReachabilityLifterRegistry()
: this(GetDefaultLifters())
{
}
/// <summary>
/// Creates a registry with custom lifters.
/// </summary>
public ReachabilityLifterRegistry(IEnumerable<IReachabilityLifter> lifters)
{
_lifters = lifters
.Where(l => l is not null)
.OrderBy(l => l.Language, StringComparer.Ordinal)
.ToList();
}
/// <summary>
/// Gets all registered lifters.
/// </summary>
public IReadOnlyList<IReachabilityLifter> Lifters => _lifters;
/// <summary>
/// Gets lifters for the specified languages.
/// </summary>
public IEnumerable<IReachabilityLifter> GetLifters(params string[] languages)
{
if (languages is null or { Length: 0 })
{
return _lifters;
}
var langSet = new HashSet<string>(languages, StringComparer.OrdinalIgnoreCase);
return _lifters.Where(l => langSet.Contains(l.Language));
}
/// <summary>
/// Runs all lifters against the context and returns a combined graph.
/// </summary>
public async ValueTask<ReachabilityUnionGraph> LiftAllAsync(
ReachabilityLifterContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
var builder = new ReachabilityGraphBuilder();
foreach (var lifter in _lifters)
{
cancellationToken.ThrowIfCancellationRequested();
await lifter.LiftAsync(context, builder, cancellationToken).ConfigureAwait(false);
}
// Use a combined language identifier for multi-language graphs
var languages = _lifters.Select(l => l.Language).Distinct().OrderBy(l => l, StringComparer.Ordinal);
var combinedLang = string.Join("+", languages);
return builder.ToUnionGraph(combinedLang);
}
/// <summary>
/// Runs specific lifters by language and returns a combined graph.
/// </summary>
public async ValueTask<ReachabilityUnionGraph> LiftAsync(
ReachabilityLifterContext context,
IEnumerable<string> languages,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
var builder = new ReachabilityGraphBuilder();
var lifters = GetLifters(languages?.ToArray() ?? Array.Empty<string>()).ToList();
foreach (var lifter in lifters)
{
cancellationToken.ThrowIfCancellationRequested();
await lifter.LiftAsync(context, builder, cancellationToken).ConfigureAwait(false);
}
var combinedLang = string.Join("+", lifters.Select(l => l.Language).Distinct().OrderBy(l => l, StringComparer.Ordinal));
return builder.ToUnionGraph(combinedLang);
}
/// <summary>
/// Runs all lifters and writes the result using the union writer.
/// </summary>
public async ValueTask<ReachabilityUnionWriteResult> LiftAndWriteAsync(
ReachabilityLifterContext context,
string outputRoot,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot);
var graph = await LiftAllAsync(context, cancellationToken).ConfigureAwait(false);
var writer = new ReachabilityUnionWriter();
return await writer.WriteAsync(graph, outputRoot, context.AnalysisId, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Gets the default set of lifters for all supported languages.
/// </summary>
public static IReadOnlyList<IReachabilityLifter> GetDefaultLifters()
{
return new IReachabilityLifter[]
{
new NodeReachabilityLifter(),
new DotNetReachabilityLifter(),
// Future lifters:
// new GoReachabilityLifter(),
// new RustReachabilityLifter(),
// new JavaReachabilityLifter(),
// new PythonReachabilityLifter(),
};
}
}

View File

@@ -1,17 +1,29 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Builds reachability graphs with full schema support including
/// rich node metadata, confidence levels, and source provenance.
/// </summary>
public sealed class ReachabilityGraphBuilder
{
private const string GraphSchemaVersion = "1.0";
private readonly Dictionary<string, RichNode> _richNodes = new(StringComparer.Ordinal);
private readonly HashSet<RichEdge> _richEdges = new();
// Legacy compatibility
private readonly HashSet<string> nodes = new(StringComparer.Ordinal);
private readonly HashSet<ReachabilityEdge> edges = new();
/// <summary>
/// Adds a simple node (legacy API).
/// </summary>
public ReachabilityGraphBuilder AddNode(string symbolId)
{
if (!string.IsNullOrWhiteSpace(symbolId))
@@ -22,6 +34,41 @@ public sealed class ReachabilityGraphBuilder
return this;
}
/// <summary>
/// Adds a rich node with full metadata.
/// </summary>
public ReachabilityGraphBuilder AddNode(
string symbolId,
string lang,
string kind,
string? display = null,
string? sourceFile = null,
int? sourceLine = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
if (string.IsNullOrWhiteSpace(symbolId))
{
return this;
}
var id = symbolId.Trim();
var node = new RichNode(
id,
lang?.Trim() ?? string.Empty,
kind?.Trim() ?? "symbol",
display?.Trim(),
sourceFile?.Trim(),
sourceLine,
attributes?.ToImmutableSortedDictionary(StringComparer.Ordinal) ?? ImmutableSortedDictionary<string, string>.Empty);
_richNodes[id] = node;
nodes.Add(id);
return this;
}
/// <summary>
/// Adds a simple edge (legacy API).
/// </summary>
public ReachabilityGraphBuilder AddEdge(string from, string to, string kind = "call")
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
@@ -36,6 +83,52 @@ public sealed class ReachabilityGraphBuilder
return this;
}
/// <summary>
/// Adds a rich edge with confidence and provenance.
/// </summary>
/// <param name="from">Source symbol ID.</param>
/// <param name="to">Target symbol ID.</param>
/// <param name="edgeType">Edge type: call, import, inherits, loads, dynamic, reflects, dlopen, ffi, wasm, spawn.</param>
/// <param name="confidence">Confidence level: certain, high, medium, low.</param>
/// <param name="origin">Origin: static or runtime.</param>
/// <param name="provenance">Provenance hint: jvm-bytecode, il, ts-ast, ssa, ebpf, etw, jfr, hook.</param>
/// <param name="evidence">Evidence locator (e.g., "file:path:line").</param>
public ReachabilityGraphBuilder AddEdge(
string from,
string to,
string edgeType,
EdgeConfidence confidence,
string origin = "static",
string? provenance = null,
string? evidence = null)
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
return this;
}
var fromId = from.Trim();
var toId = to.Trim();
var type = string.IsNullOrWhiteSpace(edgeType) ? "call" : edgeType.Trim();
var richEdge = new RichEdge(
fromId,
toId,
type,
confidence,
origin?.Trim() ?? "static",
provenance?.Trim(),
evidence?.Trim());
_richEdges.Add(richEdge);
nodes.Add(fromId);
nodes.Add(toId);
// Also add to legacy set for compatibility
edges.Add(new ReachabilityEdge(fromId, toId, type));
return this;
}
public string BuildJson(bool indented = true)
{
var payload = new ReachabilityGraphPayload
@@ -54,21 +147,102 @@ public sealed class ReachabilityGraphBuilder
return JsonSerializer.Serialize(payload, options);
}
/// <summary>
/// Converts the builder contents to a union graph using rich metadata when available.
/// </summary>
public ReachabilityUnionGraph ToUnionGraph(string language)
{
ArgumentException.ThrowIfNullOrWhiteSpace(language);
var nodeList = nodes
.Select(id => new ReachabilityUnionNode(id, language, "symbol"))
.ToList();
var lang = language.Trim();
var edgeList = edges
.Select(edge => new ReachabilityUnionEdge(edge.From, edge.To, edge.Kind))
.ToList();
// Build nodes: prefer rich metadata, fall back to simple nodes
var nodeList = new List<ReachabilityUnionNode>();
foreach (var id in nodes.OrderBy(n => n, StringComparer.Ordinal))
{
if (_richNodes.TryGetValue(id, out var rich))
{
var source = rich.SourceFile is not null
? new ReachabilitySource("static", null, rich.SourceLine.HasValue ? $"file:{rich.SourceFile}:{rich.SourceLine}" : $"file:{rich.SourceFile}")
: null;
nodeList.Add(new ReachabilityUnionNode(
id,
rich.Lang,
rich.Kind,
rich.Display,
source,
rich.Attributes.Count > 0 ? rich.Attributes : null));
}
else
{
nodeList.Add(new ReachabilityUnionNode(id, lang, "symbol"));
}
}
// Build edges: prefer rich metadata, fall back to simple edges
var edgeSet = new HashSet<(string, string, string)>();
var edgeList = new List<ReachabilityUnionEdge>();
foreach (var rich in _richEdges.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.ThenBy(e => e.EdgeType, StringComparer.Ordinal))
{
var key = (rich.From, rich.To, rich.EdgeType);
if (!edgeSet.Add(key))
{
continue;
}
var source = new ReachabilitySource(
rich.Origin,
rich.Provenance,
rich.Evidence);
edgeList.Add(new ReachabilityUnionEdge(
rich.From,
rich.To,
rich.EdgeType,
ConfidenceToString(rich.Confidence),
source));
}
// Add any legacy edges not already covered
foreach (var edge in edges.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.ThenBy(e => e.Kind, StringComparer.Ordinal))
{
var key = (edge.From, edge.To, edge.Kind);
if (!edgeSet.Add(key))
{
continue;
}
edgeList.Add(new ReachabilityUnionEdge(edge.From, edge.To, edge.Kind));
}
return new ReachabilityUnionGraph(nodeList, edgeList);
}
/// <summary>
/// Gets the count of nodes in the graph.
/// </summary>
public int NodeCount => nodes.Count;
/// <summary>
/// Gets the count of edges in the graph.
/// </summary>
public int EdgeCount => edges.Count + _richEdges.Count(re => !edges.Contains(new ReachabilityEdge(re.From, re.To, re.EdgeType)));
private static string ConfidenceToString(EdgeConfidence confidence) => confidence switch
{
EdgeConfidence.Certain => "certain",
EdgeConfidence.High => "high",
EdgeConfidence.Medium => "medium",
EdgeConfidence.Low => "low",
_ => "certain"
};
public static ReachabilityGraphBuilder FromFixture(string variantPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(variantPath);
@@ -133,4 +307,80 @@ public sealed class ReachabilityGraphBuilder
public List<ReachabilityNode> Nodes { get; set; } = new();
public List<ReachabilityEdgePayload> Edges { get; set; } = new();
}
private sealed record RichNode(
string SymbolId,
string Lang,
string Kind,
string? Display,
string? SourceFile,
int? SourceLine,
ImmutableSortedDictionary<string, string> Attributes);
private sealed record RichEdge(
string From,
string To,
string EdgeType,
EdgeConfidence Confidence,
string Origin,
string? Provenance,
string? Evidence);
}
/// <summary>
/// Confidence levels for reachability edges per the union schema.
/// </summary>
public enum EdgeConfidence
{
/// <summary>
/// Edge is certain (direct call, import statement).
/// </summary>
Certain,
/// <summary>
/// High confidence (type-constrained virtual call).
/// </summary>
High,
/// <summary>
/// Medium confidence (interface dispatch, some dynamic patterns).
/// </summary>
Medium,
/// <summary>
/// Low confidence (reflection, string-based loading).
/// </summary>
Low
}
/// <summary>
/// Well-known edge types per the reachability union schema.
/// </summary>
public static class EdgeTypes
{
public const string Call = "call";
public const string Import = "import";
public const string Inherits = "inherits";
public const string Loads = "loads";
public const string Dynamic = "dynamic";
public const string Reflects = "reflects";
public const string Dlopen = "dlopen";
public const string Ffi = "ffi";
public const string Wasm = "wasm";
public const string Spawn = "spawn";
}
/// <summary>
/// Well-known provenance hints per the reachability union schema.
/// </summary>
public static class Provenance
{
public const string JvmBytecode = "jvm-bytecode";
public const string Il = "il";
public const string TsAst = "ts-ast";
public const string Ssa = "ssa";
public const string Ebpf = "ebpf";
public const string Etw = "etw";
public const string Jfr = "jfr";
public const string Hook = "hook";
}

View File

@@ -0,0 +1,238 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Builds canonical SymbolIDs per the reachability union schema (v0.1).
/// SymbolIDs are stable, path-independent identifiers that enable CAS lookups
/// to remain reproducible and cacheable across hosts.
/// </summary>
/// <remarks>
/// Format: <c>sym:{lang}:{stable-fragment}</c>
/// where stable-fragment is SHA-256(base64url-no-pad) of the canonical tuple per language.
/// </remarks>
public static class SymbolId
{
/// <summary>
/// Supported languages for symbol IDs.
/// </summary>
public static class Lang
{
public const string Java = "java";
public const string DotNet = "dotnet";
public const string Go = "go";
public const string Node = "node";
public const string Deno = "deno";
public const string Rust = "rust";
public const string Swift = "swift";
public const string Shell = "shell";
public const string Binary = "binary";
public const string Python = "python";
public const string Ruby = "ruby";
public const string Php = "php";
}
/// <summary>
/// Creates a Java symbol ID from method signature components.
/// </summary>
/// <param name="package">Package name (e.g., "com.example").</param>
/// <param name="className">Class name (e.g., "MyClass").</param>
/// <param name="method">Method name (e.g., "doSomething").</param>
/// <param name="descriptor">JVM method descriptor (e.g., "(Ljava/lang/String;)V").</param>
public static string ForJava(string package, string className, string method, string descriptor)
{
var tuple = $"{Lower(package)}\0{Lower(className)}\0{Lower(method)}\0{Lower(descriptor)}";
return Build(Lang.Java, tuple);
}
/// <summary>
/// Creates a .NET symbol ID from member signature components.
/// </summary>
/// <param name="assemblyName">Assembly name (without version/key).</param>
/// <param name="ns">Namespace.</param>
/// <param name="typeName">Type name.</param>
/// <param name="memberSignature">Member signature using ECMA-335 format.</param>
public static string ForDotNet(string assemblyName, string ns, string typeName, string memberSignature)
{
var tuple = $"{Norm(assemblyName)}\0{Norm(ns)}\0{Norm(typeName)}\0{Norm(memberSignature)}";
return Build(Lang.DotNet, tuple);
}
/// <summary>
/// Creates a Node/Deno symbol ID from module export components.
/// </summary>
/// <param name="pkgNameOrPath">npm package name or normalized absolute path (drive stripped).</param>
/// <param name="exportPath">ESM/CJS export path (slash-joined).</param>
/// <param name="kind">Export kind (e.g., "function", "class", "default").</param>
public static string ForNode(string pkgNameOrPath, string exportPath, string kind)
{
var tuple = $"{Norm(pkgNameOrPath)}\0{Norm(exportPath)}\0{Norm(kind)}";
return Build(Lang.Node, tuple);
}
/// <summary>
/// Creates a Deno symbol ID from module export components.
/// </summary>
public static string ForDeno(string pkgNameOrPath, string exportPath, string kind)
{
var tuple = $"{Norm(pkgNameOrPath)}\0{Norm(exportPath)}\0{Norm(kind)}";
return Build(Lang.Deno, tuple);
}
/// <summary>
/// Creates a Go symbol ID from function/method components.
/// </summary>
/// <param name="modulePath">Module path (e.g., "github.com/example/repo").</param>
/// <param name="packagePath">Package path within module.</param>
/// <param name="receiver">Receiver type (empty for functions).</param>
/// <param name="func">Function name.</param>
public static string ForGo(string modulePath, string packagePath, string receiver, string func)
{
var tuple = $"{Norm(modulePath)}\0{Norm(packagePath)}\0{Norm(receiver)}\0{Norm(func)}";
return Build(Lang.Go, tuple);
}
/// <summary>
/// Creates a Rust symbol ID from item components.
/// </summary>
/// <param name="crateName">Crate name.</param>
/// <param name="modulePath">Module path within crate (e.g., "foo::bar").</param>
/// <param name="itemName">Item name (function, struct, trait, etc.).</param>
/// <param name="mangled">Optional Rust-mangled name.</param>
public static string ForRust(string crateName, string modulePath, string itemName, string? mangled = null)
{
var tuple = $"{Norm(crateName)}\0{Norm(modulePath)}\0{Norm(itemName)}\0{Norm(mangled)}";
return Build(Lang.Rust, tuple);
}
/// <summary>
/// Creates a Swift symbol ID from member components.
/// </summary>
/// <param name="module">Swift module name.</param>
/// <param name="typeName">Type name (class, struct, enum, protocol).</param>
/// <param name="member">Member name.</param>
/// <param name="mangled">Optional Swift-mangled name.</param>
public static string ForSwift(string module, string typeName, string member, string? mangled = null)
{
var tuple = $"{Norm(module)}\0{Norm(typeName)}\0{Norm(member)}\0{Norm(mangled)}";
return Build(Lang.Swift, tuple);
}
/// <summary>
/// Creates a shell symbol ID from script/function components.
/// </summary>
/// <param name="scriptRelPath">Relative path to script file.</param>
/// <param name="functionOrCmd">Function name or command identifier.</param>
public static string ForShell(string scriptRelPath, string functionOrCmd)
{
var tuple = $"{Norm(scriptRelPath)}\0{Norm(functionOrCmd)}";
return Build(Lang.Shell, tuple);
}
/// <summary>
/// Creates a binary symbol ID from ELF/PE/Mach-O components.
/// </summary>
/// <param name="buildId">Binary build-id (GNU build-id, PE GUID, Mach-O UUID).</param>
/// <param name="section">Section name (e.g., ".text", ".dynsym").</param>
/// <param name="symbolName">Symbol name from symbol table.</param>
public static string ForBinary(string buildId, string section, string symbolName)
{
var tuple = $"{Norm(buildId)}\0{Norm(section)}\0{Norm(symbolName)}";
return Build(Lang.Binary, tuple);
}
/// <summary>
/// Creates a Python symbol ID from module/function components.
/// </summary>
/// <param name="packageOrPath">Package name or module file path.</param>
/// <param name="modulePath">Module path within package (dot-separated).</param>
/// <param name="qualifiedName">Qualified name (class.method or function).</param>
public static string ForPython(string packageOrPath, string modulePath, string qualifiedName)
{
var tuple = $"{Norm(packageOrPath)}\0{Norm(modulePath)}\0{Norm(qualifiedName)}";
return Build(Lang.Python, tuple);
}
/// <summary>
/// Creates a Ruby symbol ID from module/method components.
/// </summary>
/// <param name="gemOrPath">Gem name or file path.</param>
/// <param name="modulePath">Module/class path (e.g., "Foo::Bar").</param>
/// <param name="methodName">Method name (with prefix # for instance, . for class).</param>
public static string ForRuby(string gemOrPath, string modulePath, string methodName)
{
var tuple = $"{Norm(gemOrPath)}\0{Norm(modulePath)}\0{Norm(methodName)}";
return Build(Lang.Ruby, tuple);
}
/// <summary>
/// Creates a PHP symbol ID from namespace/function components.
/// </summary>
/// <param name="composerPackage">Composer package name or file path.</param>
/// <param name="ns">Namespace (e.g., "App\\Services").</param>
/// <param name="qualifiedName">Fully qualified class::method or function name.</param>
public static string ForPhp(string composerPackage, string ns, string qualifiedName)
{
var tuple = $"{Norm(composerPackage)}\0{Norm(ns)}\0{Norm(qualifiedName)}";
return Build(Lang.Php, tuple);
}
/// <summary>
/// Creates a symbol ID from a pre-computed canonical tuple and language.
/// </summary>
/// <param name="lang">Language identifier (use <see cref="Lang"/> constants).</param>
/// <param name="canonicalTuple">Pre-formatted canonical tuple (NUL-separated components).</param>
public static string FromTuple(string lang, string canonicalTuple)
{
ArgumentException.ThrowIfNullOrWhiteSpace(lang);
return Build(lang, canonicalTuple);
}
/// <summary>
/// Parses a symbol ID into its language and fragment components.
/// </summary>
/// <returns>Tuple of (language, fragment) or null if invalid format.</returns>
public static (string Lang, string Fragment)? Parse(string symbolId)
{
if (string.IsNullOrWhiteSpace(symbolId) || !symbolId.StartsWith("sym:", StringComparison.Ordinal))
{
return null;
}
var rest = symbolId.AsSpan(4); // Skip "sym:"
var colonIndex = rest.IndexOf(':');
if (colonIndex < 1)
{
return null;
}
var lang = rest[..colonIndex].ToString();
var fragment = rest[(colonIndex + 1)..].ToString();
return (lang, fragment);
}
private static string Build(string lang, string tuple)
{
var hash = ComputeFragment(tuple);
return $"sym:{lang}:{hash}";
}
private static string ComputeFragment(string tuple)
{
var bytes = Encoding.UTF8.GetBytes(tuple);
var hash = SHA256.HashData(bytes);
// Base64url without padding per spec
return Convert.ToBase64String(hash)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
private static string Lower(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant();
private static string Norm(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
}