383 lines
14 KiB
C#
383 lines
14 KiB
C#
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
|
|
};
|
|
}
|
|
}
|