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
- 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.
442 lines
17 KiB
C#
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;
|
|
}
|
|
}
|