Files
git.stella-ops.org/src/Signals/StellaOps.Signals/Parsing/CallgraphSchemaMigrator.cs
StellaOps Bot b058dbe031 up
2025-12-14 23:20:14 +02:00

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
};
}
}