feat: add stella-callgraph-node for JavaScript/TypeScript call graph extraction

- Implemented a new tool `stella-callgraph-node` that extracts call graphs from JavaScript/TypeScript projects using Babel AST.
- Added command-line interface with options for JSON output and help.
- Included functionality to analyze project structure, detect functions, and build call graphs.
- Created a package.json file for dependency management.

feat: introduce stella-callgraph-python for Python call graph extraction

- Developed `stella-callgraph-python` to extract call graphs from Python projects using AST analysis.
- Implemented command-line interface with options for JSON output and verbose logging.
- Added framework detection to identify popular web frameworks and their entry points.
- Created an AST analyzer to traverse Python code and extract function definitions and calls.
- Included requirements.txt for project dependencies.

chore: add framework detection for Python projects

- Implemented framework detection logic to identify frameworks like Flask, FastAPI, Django, and others based on project files and import patterns.
- Enhanced the AST analyzer to recognize entry points based on decorators and function definitions.
This commit is contained in:
master
2025-12-19 18:11:59 +02:00
parent 951a38d561
commit 8779e9226f
130 changed files with 19011 additions and 422 deletions

View File

@@ -0,0 +1,259 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway.Routing;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class DefaultRoutingPluginTests
{
[Fact]
public async Task ChooseInstanceAsync_WhenNoCandidates_ReturnsNull()
{
var plugin = CreatePlugin(gatewayRegion: "eu1");
var decision = await plugin.ChooseInstanceAsync(
new RoutingContext
{
Method = "GET",
Path = "/items",
Headers = new Dictionary<string, string>(),
Endpoint = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
},
AvailableConnections = [],
GatewayRegion = "eu1",
RequestedVersion = null,
CancellationToken = CancellationToken.None
},
CancellationToken.None);
decision.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_WhenRequestedVersionDoesNotMatch_ReturnsNull()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null
});
var candidates = new List<ConnectionState>
{
CreateConnection("inv-1", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Healthy)
};
var decision = await plugin.ChooseInstanceAsync(
CreateContext(
requestedVersion: "2.0.0",
candidates: candidates),
CancellationToken.None);
decision.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_PrefersHealthyOverDegraded()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin
});
var degraded = CreateConnection("inv-degraded", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Degraded);
degraded.AveragePingMs = 1;
var healthy = CreateConnection("inv-healthy", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Healthy);
healthy.AveragePingMs = 50;
var decision = await plugin.ChooseInstanceAsync(
CreateContext(
requestedVersion: "1.0.0",
candidates: [degraded, healthy]),
CancellationToken.None);
decision.Should().NotBeNull();
decision!.Connection.ConnectionId.Should().Be("inv-healthy");
}
[Fact]
public async Task ChooseInstanceAsync_PrefersLocalRegionOverRemote()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin
});
var remote = CreateConnection("inv-us1", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
var local = CreateConnection("inv-eu1", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Healthy);
var decision = await plugin.ChooseInstanceAsync(
CreateContext(
requestedVersion: "1.0.0",
candidates: [remote, local]),
CancellationToken.None);
decision.Should().NotBeNull();
decision!.Connection.ConnectionId.Should().Be("inv-eu1");
}
[Fact]
public async Task ChooseInstanceAsync_WhenNoLocal_UsesNeighborRegion()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
gatewayNeighbors: ["eu2"],
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin
});
var neighbor = CreateConnection("inv-eu2", "inventory", "1.0.0", region: "eu2", status: InstanceHealthStatus.Healthy);
var remote = CreateConnection("inv-us1", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
var decision = await plugin.ChooseInstanceAsync(
CreateContext(
requestedVersion: "1.0.0",
candidates: [remote, neighbor]),
CancellationToken.None);
decision.Should().NotBeNull();
decision!.Connection.ConnectionId.Should().Be("inv-eu2");
}
[Fact]
public async Task ChooseInstanceAsync_WhenTied_UsesRoundRobin()
{
var plugin = CreatePlugin(
gatewayRegion: "eu1",
options: new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = false,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin,
PingToleranceMs = 1_000
});
var heartbeat = DateTime.UtcNow;
var a = CreateConnection("inv-a", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
a.AveragePingMs = 10;
a.LastHeartbeatUtc = heartbeat;
var b = CreateConnection("inv-b", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
b.AveragePingMs = 10;
b.LastHeartbeatUtc = heartbeat;
var ctx = CreateContext(requestedVersion: "1.0.0", candidates: [a, b]);
var decision1 = await plugin.ChooseInstanceAsync(ctx, CancellationToken.None);
var decision2 = await plugin.ChooseInstanceAsync(ctx, CancellationToken.None);
decision1.Should().NotBeNull();
decision2.Should().NotBeNull();
decision1!.Connection.ConnectionId.Should().Be("inv-a");
decision2!.Connection.ConnectionId.Should().Be("inv-b");
}
private static DefaultRoutingPlugin CreatePlugin(
string gatewayRegion,
RoutingOptions? options = null,
IReadOnlyList<string>? gatewayNeighbors = null)
{
options ??= new RoutingOptions
{
StrictVersionMatching = true,
DefaultVersion = null,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.RoundRobin
};
var node = new RouterNodeConfig
{
Region = gatewayRegion,
NeighborRegions = gatewayNeighbors?.ToList() ?? []
};
return new DefaultRoutingPlugin(
Options.Create(options),
Options.Create(node));
}
private static RoutingContext CreateContext(
string? requestedVersion,
IReadOnlyList<ConnectionState> candidates)
{
return new RoutingContext
{
Method = "GET",
Path = "/items",
Headers = new Dictionary<string, string>(),
Endpoint = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
},
AvailableConnections = candidates,
GatewayRegion = "eu1",
RequestedVersion = requestedVersion,
CancellationToken = CancellationToken.None
};
}
private static ConnectionState CreateConnection(
string connectionId,
string serviceName,
string version,
string region,
InstanceHealthStatus status)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = connectionId,
ServiceName = serviceName,
Version = version,
Region = region
},
Status = status,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.InMemory
};
}
}

View File

@@ -0,0 +1,139 @@
using FluentAssertions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.State;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class InMemoryRoutingStateTests
{
[Fact]
public void ResolveEndpoint_WhenExactMatch_ReturnsEndpointDescriptor()
{
var routingState = new InMemoryRoutingState();
var connection = CreateConnection("conn-1", "inventory", "1.0.0", region: "test");
connection.Endpoints[("GET", "/items")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
};
routingState.AddConnection(connection);
var resolved = routingState.ResolveEndpoint("GET", "/items");
resolved.Should().NotBeNull();
resolved!.ServiceName.Should().Be("inventory");
resolved.Version.Should().Be("1.0.0");
resolved.Method.Should().Be("GET");
resolved.Path.Should().Be("/items");
}
[Fact]
public void ResolveEndpoint_WhenTemplateMatch_ReturnsEndpointDescriptor()
{
var routingState = new InMemoryRoutingState();
var connection = CreateConnection("conn-1", "inventory", "1.0.0", region: "test");
connection.Endpoints[("GET", "/items/{sku}")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items/{sku}"
};
routingState.AddConnection(connection);
var resolved = routingState.ResolveEndpoint("GET", "/items/SKU-001");
resolved.Should().NotBeNull();
resolved!.Path.Should().Be("/items/{sku}");
}
[Fact]
public void RemoveConnection_RemovesEndpointsFromIndex()
{
var routingState = new InMemoryRoutingState();
var connection = CreateConnection("conn-1", "inventory", "1.0.0", region: "test");
connection.Endpoints[("GET", "/items")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
};
routingState.AddConnection(connection);
routingState.ResolveEndpoint("GET", "/items").Should().NotBeNull();
routingState.RemoveConnection("conn-1");
routingState.ResolveEndpoint("GET", "/items").Should().BeNull();
routingState.GetAllConnections().Should().BeEmpty();
}
[Fact]
public void GetConnectionsFor_FiltersByServiceAndVersion()
{
var routingState = new InMemoryRoutingState();
var inventoryV1 = CreateConnection("inv-v1", "inventory", "1.0.0", region: "test");
inventoryV1.Endpoints[("GET", "/items")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
};
var inventoryV2 = CreateConnection("inv-v2", "inventory", "2.0.0", region: "test");
inventoryV2.Endpoints[("GET", "/items")] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "2.0.0",
Method = "GET",
Path = "/items"
};
routingState.AddConnection(inventoryV1);
routingState.AddConnection(inventoryV2);
var connections = routingState.GetConnectionsFor(
serviceName: "inventory",
version: "1.0.0",
method: "GET",
path: "/items");
connections.Should().HaveCount(1);
connections[0].ConnectionId.Should().Be("inv-v1");
}
private static ConnectionState CreateConnection(
string connectionId,
string serviceName,
string version,
string region)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = connectionId,
ServiceName = serviceName,
Version = version,
Region = region
},
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.InMemory
};
}
}

View File

@@ -0,0 +1,218 @@
using System.Security.Claims;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway.Middleware;
using StellaOps.Router.Gateway.State;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class MiddlewareErrorScenarioTests
{
[Fact]
public async Task EndpointResolutionMiddleware_WhenNoEndpoint_Returns404StructuredError()
{
var context = CreateContext(method: "GET", path: "/missing");
var routingState = new InMemoryRoutingState();
var nextCalled = false;
var middleware = new EndpointResolutionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await middleware.Invoke(context, routingState);
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
var body = ReadJson(context);
body.GetProperty("error").GetString().Should().Be("Endpoint not found");
body.GetProperty("status").GetInt32().Should().Be(404);
body.GetProperty("method").GetString().Should().Be("GET");
body.GetProperty("path").GetString().Should().Be("/missing");
body.GetProperty("traceId").GetString().Should().Be("trace-1");
}
[Fact]
public async Task RoutingDecisionMiddleware_WhenNoInstances_Returns503StructuredError()
{
var context = CreateContext(method: "GET", path: "/items");
context.Items[RouterHttpContextKeys.EndpointDescriptor] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items"
};
var routingState = new InMemoryRoutingState();
var plugin = new NullRoutingPlugin();
var nextCalled = false;
var middleware = new RoutingDecisionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await middleware.Invoke(
context,
plugin,
routingState,
Options.Create(new RouterNodeConfig { Region = "eu1", NodeId = "gw-eu1-01" }),
Options.Create(new RoutingOptions { DefaultVersion = null }));
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
var body = ReadJson(context);
body.GetProperty("error").GetString().Should().Be("No instances available");
body.GetProperty("status").GetInt32().Should().Be(503);
body.GetProperty("service").GetString().Should().Be("inventory");
body.GetProperty("version").GetString().Should().Be("1.0.0");
}
[Fact]
public async Task AuthorizationMiddleware_WhenMissingClaim_Returns403StructuredError()
{
var context = CreateContext(method: "GET", path: "/items");
context.User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("scope", "user")],
authenticationType: "test"));
context.Items[RouterHttpContextKeys.EndpointDescriptor] = new EndpointDescriptor
{
ServiceName = "inventory",
Version = "1.0.0",
Method = "GET",
Path = "/items",
RequiringClaims = []
};
var claimsStore = new StaticClaimsStore(
[new ClaimRequirement { Type = "scope", Value = "admin" }]);
var nextCalled = false;
var middleware = new AuthorizationMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
claimsStore,
NullLogger<AuthorizationMiddleware>.Instance);
await middleware.InvokeAsync(context);
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var body = ReadJson(context);
body.GetProperty("error").GetString().Should().Be("Forbidden");
body.GetProperty("status").GetInt32().Should().Be(403);
body.GetProperty("service").GetString().Should().Be("inventory");
body.GetProperty("version").GetString().Should().Be("1.0.0");
body.GetProperty("details").GetProperty("requiredClaimType").GetString().Should().Be("scope");
body.GetProperty("details").GetProperty("requiredClaimValue").GetString().Should().Be("admin");
}
[Fact]
public async Task GlobalErrorHandlerMiddleware_WhenUnhandledException_Returns500StructuredError()
{
var context = CreateContext(method: "GET", path: "/boom");
var environment = new TestHostEnvironment { EnvironmentName = Environments.Development };
var middleware = new GlobalErrorHandlerMiddleware(
_ => throw new InvalidOperationException("boom"),
NullLogger<GlobalErrorHandlerMiddleware>.Instance,
environment);
await middleware.Invoke(context);
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
var body = ReadJson(context);
body.GetProperty("error").GetString().Should().Be("Internal Server Error");
body.GetProperty("status").GetInt32().Should().Be(500);
body.GetProperty("message").GetString().Should().Be("boom");
}
private static DefaultHttpContext CreateContext(string method, string path, string? queryString = null)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddOptions();
var context = new DefaultHttpContext
{
RequestServices = services.BuildServiceProvider()
};
context.TraceIdentifier = "trace-1";
context.Request.Method = method;
context.Request.Path = path;
context.Request.QueryString = string.IsNullOrWhiteSpace(queryString) ? QueryString.Empty : new QueryString(queryString);
context.Response.Body = new MemoryStream();
return context;
}
private static JsonElement ReadJson(DefaultHttpContext context)
{
context.Response.Body.Position = 0;
using var doc = JsonDocument.Parse(context.Response.Body);
return doc.RootElement.Clone();
}
private sealed class NullRoutingPlugin : IRoutingPlugin
{
public Task<RoutingDecision?> ChooseInstanceAsync(RoutingContext context, CancellationToken cancellationToken)
{
return Task.FromResult<RoutingDecision?>(null);
}
}
private sealed class StaticClaimsStore : IEffectiveClaimsStore
{
private readonly IReadOnlyList<ClaimRequirement> _claims;
public StaticClaimsStore(IReadOnlyList<ClaimRequirement> claims)
{
_claims = claims;
}
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path) => _claims;
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
{
}
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
{
}
public void RemoveService(string serviceName)
{
}
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = Environments.Production;
public string ApplicationName { get; set; } = "StellaOps.Router.Gateway.Tests";
public string ContentRootPath { get; set; } = Environment.CurrentDirectory;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
}

View File

@@ -0,0 +1,40 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway.DependencyInjection;
using Xunit;
namespace StellaOps.Router.Gateway.Tests;
public sealed class RouterNodeConfigValidationTests
{
[Fact]
public void RouterNodeConfig_WhenRegionMissing_ThrowsOptionsValidationException()
{
var services = new ServiceCollection();
services.AddRouterGatewayCore();
using var provider = services.BuildServiceProvider();
var act = () => provider.GetRequiredService<IOptions<RouterNodeConfig>>().Value;
act.Should().Throw<OptionsValidationException>();
}
[Fact]
public void RouterNodeConfig_WhenRegionProvided_GeneratesNodeIdIfMissing()
{
var services = new ServiceCollection();
services.AddRouterGatewayCore();
services.Configure<RouterNodeConfig>(c => c.Region = "test");
using var provider = services.BuildServiceProvider();
var config = provider.GetRequiredService<IOptions<RouterNodeConfig>>().Value;
config.Region.Should().Be("test");
config.NodeId.Should().StartWith("gw-test-");
config.NodeId.Should().HaveLength("gw-test-".Length + 8);
}
}