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,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(),
};
}
}