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
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:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user