Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Lifters/NodeReachabilityLifter.cs
StellaOps Bot c11d87d252
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
feat: Add tests for RichGraphPublisher and RichGraphWriter
- Implement unit tests for RichGraphPublisher to verify graph publishing to CAS.
- Implement unit tests for RichGraphWriter to ensure correct writing of canonical graphs and metadata.

feat: Implement AOC Guard validation logic

- Add AOC Guard validation logic to enforce document structure and field constraints.
- Introduce violation codes for various validation errors.
- Implement tests for AOC Guard to validate expected behavior.

feat: Create Console Status API client and service

- Implement ConsoleStatusClient for fetching console status and streaming run events.
- Create ConsoleStatusService to manage console status polling and event subscriptions.
- Add tests for ConsoleStatusClient to verify API interactions.

feat: Develop Console Status component

- Create ConsoleStatusComponent for displaying console status and run events.
- Implement UI for showing status metrics and handling user interactions.
- Add styles for console status display.

test: Add tests for Console Status store

- Implement tests for ConsoleStatusStore to verify event handling and state management.
2025-12-01 07:34:50 +02:00

442 lines
17 KiB
C#

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}",
["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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["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;
}
}