up
This commit is contained in:
382
src/Signals/StellaOps.Signals/Parsing/CallgraphSchemaMigrator.cs
Normal file
382
src/Signals/StellaOps.Signals/Parsing/CallgraphSchemaMigrator.cs
Normal file
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Migrates call graphs from legacy formats to stella.callgraph.v1.
|
||||
/// </summary>
|
||||
public static class CallgraphSchemaMigrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures document conforms to v1 schema, migrating if necessary.
|
||||
/// </summary>
|
||||
public static CallgraphDocument EnsureV1(CallgraphDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (!string.Equals(document.Schema, CallgraphSchemaVersions.V1, StringComparison.Ordinal))
|
||||
{
|
||||
document.Schema = CallgraphSchemaVersions.V1;
|
||||
}
|
||||
|
||||
// Migrate language string to enum
|
||||
if (document.LanguageType == CallgraphLanguage.Unknown && !string.IsNullOrWhiteSpace(document.Language))
|
||||
{
|
||||
document.LanguageType = ParseLanguage(document.Language);
|
||||
}
|
||||
|
||||
// Ensure all nodes have visibility inferred if not set
|
||||
var updatedNodes = new List<CallgraphNode>(document.Nodes.Count);
|
||||
foreach (var node in document.Nodes)
|
||||
{
|
||||
var visibility = node.Visibility == SymbolVisibility.Unknown
|
||||
? InferVisibility(node.Name, node.Namespace)
|
||||
: node.Visibility;
|
||||
|
||||
var symbolKey = string.IsNullOrWhiteSpace(node.SymbolKey)
|
||||
? BuildSymbolKey(node)
|
||||
: node.SymbolKey;
|
||||
|
||||
var isEntrypointCandidate = node.IsEntrypointCandidate || IsEntrypointCandidate(node);
|
||||
|
||||
if (visibility != node.Visibility ||
|
||||
!string.Equals(symbolKey, node.SymbolKey, StringComparison.Ordinal) ||
|
||||
isEntrypointCandidate != node.IsEntrypointCandidate)
|
||||
{
|
||||
var updatedNode = node with
|
||||
{
|
||||
Visibility = visibility,
|
||||
SymbolKey = symbolKey,
|
||||
IsEntrypointCandidate = isEntrypointCandidate
|
||||
};
|
||||
updatedNodes.Add(updatedNode);
|
||||
}
|
||||
else
|
||||
{
|
||||
updatedNodes.Add(node);
|
||||
}
|
||||
}
|
||||
document.Nodes = updatedNodes
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Ensure all edges have reasons inferred if not set
|
||||
var updatedEdges = new List<CallgraphEdge>(document.Edges.Count);
|
||||
foreach (var edge in document.Edges)
|
||||
{
|
||||
if (edge.Reason == EdgeReason.Unknown)
|
||||
{
|
||||
var reason = InferEdgeReason(edge);
|
||||
var updatedEdge = edge with { Reason = reason };
|
||||
updatedEdges.Add(updatedEdge);
|
||||
}
|
||||
else
|
||||
{
|
||||
updatedEdges.Add(edge);
|
||||
}
|
||||
}
|
||||
document.Edges = updatedEdges
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Type, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Offset ?? -1)
|
||||
.ToList();
|
||||
|
||||
// Build entrypoints from nodes if not present
|
||||
if (document.Entrypoints.Count == 0)
|
||||
{
|
||||
document.Entrypoints = InferEntrypoints(document.Nodes, document.LanguageType, document.Roots);
|
||||
}
|
||||
else
|
||||
{
|
||||
document.Entrypoints = NormalizeEntrypoints(document.Entrypoints);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static CallgraphLanguage ParseLanguage(string language)
|
||||
{
|
||||
return language.ToLowerInvariant() switch
|
||||
{
|
||||
"dotnet" or ".net" or "csharp" or "c#" => CallgraphLanguage.DotNet,
|
||||
"java" => CallgraphLanguage.Java,
|
||||
"node" or "nodejs" or "javascript" or "typescript" => CallgraphLanguage.Node,
|
||||
"python" => CallgraphLanguage.Python,
|
||||
"go" or "golang" => CallgraphLanguage.Go,
|
||||
"rust" => CallgraphLanguage.Rust,
|
||||
"ruby" => CallgraphLanguage.Ruby,
|
||||
"php" => CallgraphLanguage.Php,
|
||||
"binary" or "native" or "elf" => CallgraphLanguage.Binary,
|
||||
"swift" => CallgraphLanguage.Swift,
|
||||
"kotlin" => CallgraphLanguage.Kotlin,
|
||||
_ => CallgraphLanguage.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static SymbolVisibility InferVisibility(string name, string? ns)
|
||||
{
|
||||
// Heuristic: symbols with "Internal" in namespace are internal
|
||||
if (!string.IsNullOrEmpty(ns) && ns.Contains("Internal", StringComparison.OrdinalIgnoreCase))
|
||||
return SymbolVisibility.Internal;
|
||||
|
||||
// Heuristic: private symbols often have underscore prefix or specific patterns
|
||||
if (name.StartsWith('_') || name.StartsWith("<"))
|
||||
return SymbolVisibility.Private;
|
||||
|
||||
// Default to public for exposed symbols
|
||||
return SymbolVisibility.Public;
|
||||
}
|
||||
|
||||
private static string BuildSymbolKey(CallgraphNode node)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(node.Namespace))
|
||||
parts.Add(node.Namespace);
|
||||
|
||||
parts.Add(node.Name);
|
||||
|
||||
return string.Join(".", parts);
|
||||
}
|
||||
|
||||
private static bool IsEntrypointCandidate(CallgraphNode node)
|
||||
{
|
||||
var name = node.Name;
|
||||
var kind = node.Kind;
|
||||
|
||||
// Main methods
|
||||
if (name.Equals("Main", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Controller methods
|
||||
if (name.Contains("Controller") || name.Contains("Handler"))
|
||||
return true;
|
||||
|
||||
// Test methods
|
||||
if (node.Analyzer?.ContainsKey("test") == true)
|
||||
return true;
|
||||
|
||||
// Module initializers
|
||||
if (name.Contains(".cctor") || name.Contains("ModuleInitializer"))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static EdgeReason InferEdgeReason(CallgraphEdge edge)
|
||||
{
|
||||
// Heuristic based on edge kind and type
|
||||
if (edge.Kind == EdgeKind.Runtime)
|
||||
return EdgeReason.RuntimeMinted;
|
||||
|
||||
if (edge.Kind == EdgeKind.Heuristic)
|
||||
return EdgeReason.DynamicImport;
|
||||
|
||||
// Infer from legacy type field
|
||||
var type = edge.Type.ToLowerInvariant();
|
||||
return type switch
|
||||
{
|
||||
"call" or "direct" => EdgeReason.DirectCall,
|
||||
"virtual" or "callvirt" => EdgeReason.VirtualCall,
|
||||
"newobj" or "new" => EdgeReason.NewObj,
|
||||
"ldftn" or "delegate" => EdgeReason.DelegateCreate,
|
||||
"reflection" => EdgeReason.ReflectionString,
|
||||
"di" or "injection" => EdgeReason.DiBinding,
|
||||
"async" or "continuation" => EdgeReason.AsyncContinuation,
|
||||
"event" => EdgeReason.EventHandler,
|
||||
"generic" => EdgeReason.GenericInstantiation,
|
||||
"native" or "pinvoke" or "ffi" => EdgeReason.NativeInterop,
|
||||
_ => EdgeReason.DirectCall
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CallgraphEntrypoint> InferEntrypoints(
|
||||
List<CallgraphNode> nodes,
|
||||
CallgraphLanguage language,
|
||||
List<CallgraphRoot>? roots)
|
||||
{
|
||||
var entrypoints = new List<CallgraphEntrypoint>();
|
||||
var order = 0;
|
||||
|
||||
// First, add any explicitly declared roots
|
||||
if (roots != null)
|
||||
{
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var node = nodes.FirstOrDefault(n => n.Id == root.Id);
|
||||
if (node == null) continue;
|
||||
|
||||
var kind = InferEntrypointKindFromPhase(root.Phase);
|
||||
var phase = ParsePhase(root.Phase);
|
||||
var framework = InferFramework(node.Name, language);
|
||||
|
||||
entrypoints.Add(new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = root.Id,
|
||||
Kind = kind,
|
||||
Framework = framework,
|
||||
Source = root.Source ?? "root_declaration",
|
||||
Phase = phase,
|
||||
Order = order++
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Then, add inferred entrypoint candidates
|
||||
foreach (var node in nodes.Where(n => n.IsEntrypointCandidate))
|
||||
{
|
||||
// Skip if already added from roots
|
||||
if (entrypoints.Any(e => e.NodeId == node.Id))
|
||||
continue;
|
||||
|
||||
var kind = InferEntrypointKind(node.Name, language);
|
||||
var framework = InferFramework(node.Name, language);
|
||||
|
||||
entrypoints.Add(new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = node.Id,
|
||||
Kind = kind,
|
||||
Framework = framework,
|
||||
Source = "inference",
|
||||
Phase = kind == EntrypointKind.ModuleInit ? EntrypointPhase.ModuleInit : EntrypointPhase.Runtime,
|
||||
Order = order++
|
||||
});
|
||||
}
|
||||
|
||||
return entrypoints
|
||||
.OrderBy(e => (int)e.Phase)
|
||||
.ThenBy(e => e.Order)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<CallgraphEntrypoint> NormalizeEntrypoints(List<CallgraphEntrypoint> entrypoints)
|
||||
{
|
||||
var normalized = new List<CallgraphEntrypoint>(entrypoints.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var entrypoint in entrypoints)
|
||||
{
|
||||
var nodeId = entrypoint.NodeId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(nodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedEntrypoint = new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Kind = entrypoint.Kind,
|
||||
Route = string.IsNullOrWhiteSpace(entrypoint.Route) ? null : entrypoint.Route.Trim(),
|
||||
HttpMethod = string.IsNullOrWhiteSpace(entrypoint.HttpMethod) ? null : entrypoint.HttpMethod.Trim().ToUpperInvariant(),
|
||||
Framework = entrypoint.Framework,
|
||||
Source = string.IsNullOrWhiteSpace(entrypoint.Source) ? null : entrypoint.Source.Trim(),
|
||||
Phase = entrypoint.Phase,
|
||||
Order = 0
|
||||
};
|
||||
|
||||
var key = $"{normalizedEntrypoint.NodeId}|{normalizedEntrypoint.Kind}|{normalizedEntrypoint.Framework}|{normalizedEntrypoint.Phase}|{normalizedEntrypoint.Route}|{normalizedEntrypoint.HttpMethod}|{normalizedEntrypoint.Source}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
normalized.Add(normalizedEntrypoint);
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = normalized
|
||||
.OrderBy(e => (int)e.Phase)
|
||||
.ThenBy(e => e.NodeId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind)
|
||||
.ThenBy(e => e.Framework)
|
||||
.ThenBy(e => e.Route, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.HttpMethod, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var ordered = new List<CallgraphEntrypoint>(sorted.Count);
|
||||
foreach (var group in sorted.GroupBy(e => e.Phase).OrderBy(g => (int)g.Key))
|
||||
{
|
||||
var order = 0;
|
||||
foreach (var entrypoint in group)
|
||||
{
|
||||
entrypoint.Order = order++;
|
||||
ordered.Add(entrypoint);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static EntrypointKind InferEntrypointKindFromPhase(string phase)
|
||||
{
|
||||
return phase.ToLowerInvariant() switch
|
||||
{
|
||||
"init" or "module_init" or "static_init" => EntrypointKind.ModuleInit,
|
||||
"main" or "entry" => EntrypointKind.Main,
|
||||
"http" or "request" => EntrypointKind.Http,
|
||||
"grpc" => EntrypointKind.Grpc,
|
||||
"cli" or "command" => EntrypointKind.Cli,
|
||||
"job" or "background" => EntrypointKind.Job,
|
||||
"event" => EntrypointKind.Event,
|
||||
"queue" or "message" => EntrypointKind.MessageQueue,
|
||||
"timer" or "scheduled" => EntrypointKind.Timer,
|
||||
"test" => EntrypointKind.Test,
|
||||
_ => EntrypointKind.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static EntrypointPhase ParsePhase(string phase)
|
||||
{
|
||||
return phase.ToLowerInvariant() switch
|
||||
{
|
||||
"init" or "module_init" or "static_init" => EntrypointPhase.ModuleInit,
|
||||
"startup" or "main" or "entry" => EntrypointPhase.AppStart,
|
||||
"shutdown" or "cleanup" => EntrypointPhase.Shutdown,
|
||||
_ => EntrypointPhase.Runtime
|
||||
};
|
||||
}
|
||||
|
||||
private static EntrypointKind InferEntrypointKind(string name, CallgraphLanguage language)
|
||||
{
|
||||
if (name.Contains("Controller", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("Handler", StringComparison.OrdinalIgnoreCase))
|
||||
return EntrypointKind.Http;
|
||||
|
||||
if (name.Equals("Main", StringComparison.OrdinalIgnoreCase))
|
||||
return EntrypointKind.Main;
|
||||
|
||||
if (name.Contains(".cctor") || name.Contains("ModuleInitializer"))
|
||||
return EntrypointKind.ModuleInit;
|
||||
|
||||
if (name.Contains("Test", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("Fact", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("Theory", StringComparison.OrdinalIgnoreCase))
|
||||
return EntrypointKind.Test;
|
||||
|
||||
return EntrypointKind.Unknown;
|
||||
}
|
||||
|
||||
private static EntrypointFramework InferFramework(string name, CallgraphLanguage language)
|
||||
{
|
||||
return language switch
|
||||
{
|
||||
CallgraphLanguage.DotNet when name.Contains("Controller") => EntrypointFramework.AspNetCore,
|
||||
CallgraphLanguage.DotNet when name.Contains("Function") => EntrypointFramework.AzureFunctions,
|
||||
CallgraphLanguage.Java when name.Contains("Controller") => EntrypointFramework.Spring,
|
||||
CallgraphLanguage.Java when name.Contains("Handler") => EntrypointFramework.AwsLambda,
|
||||
CallgraphLanguage.Node when name.Contains("express") => EntrypointFramework.Express,
|
||||
CallgraphLanguage.Node when name.Contains("fastify") => EntrypointFramework.Fastify,
|
||||
CallgraphLanguage.Python when name.Contains("fastapi") => EntrypointFramework.FastApi,
|
||||
CallgraphLanguage.Python when name.Contains("flask") => EntrypointFramework.Flask,
|
||||
CallgraphLanguage.Python when name.Contains("django") => EntrypointFramework.Django,
|
||||
CallgraphLanguage.Ruby => EntrypointFramework.Rails,
|
||||
CallgraphLanguage.Go when name.Contains("gin") => EntrypointFramework.Gin,
|
||||
CallgraphLanguage.Go when name.Contains("echo") => EntrypointFramework.Echo,
|
||||
CallgraphLanguage.Rust when name.Contains("actix") => EntrypointFramework.Actix,
|
||||
CallgraphLanguage.Rust when name.Contains("rocket") => EntrypointFramework.Rocket,
|
||||
_ => EntrypointFramework.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user