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; /// /// Reachability lifter for Node.js/npm projects. /// Extracts callgraph edges from import/require statements and builds symbol IDs. /// 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//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 { ["version"] = pkgVersion ?? "0.0.0", ["purl"] = $"pkg:npm/{EncodePackageName(pkgName)}@{pkgVersion}", ["code_id"] = CodeId.ForNode(pkgName, "module") }); // 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), attributes: new Dictionary { ["code_id"] = CodeId.ForNode(pkgName, 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), attributes: new Dictionary { ["code_id"] = CodeId.ForNode(pkgName, 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 { ["bin_name"] = binName, ["code_id"] = CodeId.ForNode(pkgName, NormalizePath(binPath)) }); 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; } }