feat: Add tests for RichGraphPublisher and RichGraphWriter
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.
This commit is contained in:
StellaOps Bot
2025-12-01 07:34:50 +02:00
parent 7df0677e34
commit c11d87d252
108 changed files with 4773 additions and 351 deletions

View File

@@ -30,6 +30,7 @@ public static class ScanAnalysisKeys
public const string ReachabilityUnionGraph = "analysis.reachability.union.graph";
public const string ReachabilityUnionCas = "analysis.reachability.union.cas";
public const string ReachabilityRichGraphCas = "analysis.reachability.richgraph.cas";
public const string FileEntries = "analysis.files.entries";
public const string EntropyReport = "analysis.entropy.report";

View File

@@ -0,0 +1,52 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Builds canonical CodeIDs used by richgraph-v1 to anchor symbols when names are missing.
/// </summary>
/// <remarks>
/// Format: <c>code:&lt;lang&gt;:&lt;base64url-sha256&gt;</c> where the hash is computed over a
/// canonical tuple that is stable across machines and paths.
/// </remarks>
public static class CodeId
{
public static string ForBinary(string buildId, string section, string? relativePath)
{
var tuple = $"{Norm(buildId)}\0{Norm(section)}\0{Norm(relativePath)}";
return Build("binary", tuple);
}
public static string ForDotNet(string assemblyName, string moduleName, string? mvid)
{
var tuple = $"{Norm(assemblyName)}\0{Norm(moduleName)}\0{Norm(mvid)}";
return Build("dotnet", tuple);
}
public static string ForNode(string packageName, string entryPath)
{
var tuple = $"{Norm(packageName)}\0{Norm(entryPath)}";
return Build("node", tuple);
}
public static string FromSymbolId(string symbolId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(symbolId);
return Build("sym", symbolId.Trim());
}
private static string Build(string lang, string tuple)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(tuple));
var base64 = Convert.ToBase64String(hash)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return $"code:{lang}:{base64}";
}
private static string Norm(string? value) => (value ?? string.Empty).Trim();
}

View File

@@ -144,17 +144,18 @@ public sealed partial class DotNetReachabilityLifter : IReachabilityLifter
// Add assembly node
var assemblySymbol = SymbolId.ForDotNet(info.AssemblyName, string.Empty, string.Empty, string.Empty);
builder.AddNode(
symbolId: assemblySymbol,
lang: SymbolId.Lang.DotNet,
kind: "assembly",
display: info.AssemblyName,
sourceFile: relativePath,
attributes: new Dictionary<string, string>
{
["target_framework"] = info.TargetFramework,
["root_namespace"] = info.RootNamespace
});
builder.AddNode(
symbolId: assemblySymbol,
lang: SymbolId.Lang.DotNet,
kind: "assembly",
display: info.AssemblyName,
sourceFile: relativePath,
attributes: new Dictionary<string, string>
{
["target_framework"] = info.TargetFramework,
["root_namespace"] = info.RootNamespace,
["code_id"] = CodeId.ForDotNet(info.AssemblyName, info.AssemblyName, null)
});
// Add namespace node
if (!string.IsNullOrWhiteSpace(info.RootNamespace))
@@ -348,7 +349,8 @@ public sealed partial class DotNetReachabilityLifter : IReachabilityLifter
attributes: new Dictionary<string, string>
{
["version"] = version,
["purl"] = $"pkg:nuget/{packageName}@{version}"
["purl"] = $"pkg:nuget/{packageName}@{version}",
["code_id"] = CodeId.ForDotNet(packageName, packageName, null)
});
// Process dependencies

View File

@@ -94,7 +94,8 @@ public sealed class NodeReachabilityLifter : IReachabilityLifter
attributes: new Dictionary<string, string>
{
["version"] = pkgVersion ?? "0.0.0",
["purl"] = $"pkg:npm/{EncodePackageName(pkgName)}@{pkgVersion}"
["purl"] = $"pkg:npm/{EncodePackageName(pkgName)}@{pkgVersion}",
["code_id"] = CodeId.ForNode(pkgName, "module")
});
// Process entrypoints (main, module, exports)
@@ -137,7 +138,11 @@ public sealed class NodeReachabilityLifter : IReachabilityLifter
lang: SymbolId.Lang.Node,
kind: "entrypoint",
display: $"{pkgName}:{mainPath}",
sourceFile: NormalizePath(mainPath));
sourceFile: NormalizePath(mainPath),
attributes: new Dictionary<string, string>
{
["code_id"] = CodeId.ForNode(pkgName, NormalizePath(mainPath))
});
builder.AddEdge(
from: moduleSymbol,
@@ -162,7 +167,11 @@ public sealed class NodeReachabilityLifter : IReachabilityLifter
lang: SymbolId.Lang.Node,
kind: "entrypoint",
display: $"{pkgName}:{modulePath} (ESM)",
sourceFile: NormalizePath(modulePath));
sourceFile: NormalizePath(modulePath),
attributes: new Dictionary<string, string>
{
["code_id"] = CodeId.ForNode(pkgName, NormalizePath(modulePath))
});
builder.AddEdge(
from: moduleSymbol,
@@ -219,7 +228,11 @@ public sealed class NodeReachabilityLifter : IReachabilityLifter
kind: "binary",
display: $"{binName} -> {binPath}",
sourceFile: NormalizePath(binPath),
attributes: new Dictionary<string, string> { ["bin_name"] = binName });
attributes: new Dictionary<string, string>
{
["bin_name"] = binName,
["code_id"] = CodeId.ForNode(pkgName, NormalizePath(binPath))
});
builder.AddEdge(
from: moduleSymbol,

View File

@@ -0,0 +1,86 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Cache.Abstractions;
namespace StellaOps.Scanner.Reachability;
public interface IRichGraphPublisher
{
Task<RichGraphPublishResult> PublishAsync(RichGraph graph, string analysisId, IFileContentAddressableStore cas, string workRoot, CancellationToken cancellationToken = default);
}
/// <summary>
/// Packages richgraph-v1 JSON + meta into a deterministic zip and stores it in CAS.
/// </summary>
public sealed class ReachabilityRichGraphPublisher : IRichGraphPublisher
{
private readonly RichGraphWriter _writer;
public ReachabilityRichGraphPublisher(RichGraphWriter writer)
{
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
}
public async Task<RichGraphPublishResult> PublishAsync(
RichGraph graph,
string analysisId,
IFileContentAddressableStore cas,
string workRoot,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(cas);
ArgumentException.ThrowIfNullOrWhiteSpace(analysisId);
ArgumentException.ThrowIfNullOrWhiteSpace(workRoot);
Directory.CreateDirectory(workRoot);
var writeResult = await _writer.WriteAsync(graph, workRoot, analysisId, cancellationToken).ConfigureAwait(false);
var folder = Path.GetDirectoryName(writeResult.GraphPath)!;
var zipPath = Path.Combine(folder, "richgraph.zip");
CreateDeterministicZip(folder, zipPath);
await using var stream = File.OpenRead(zipPath);
var sha = ComputeSha256(zipPath);
var casEntry = await cas.PutAsync(new FileCasPutRequest(sha, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
return new RichGraphPublishResult(writeResult.GraphHash, casEntry.RelativePath, writeResult.NodeCount, writeResult.EdgeCount);
}
private static void CreateDeterministicZip(string sourceDir, string destinationZip)
{
if (File.Exists(destinationZip))
{
File.Delete(destinationZip);
}
var files = Directory.EnumerateFiles(sourceDir, "*", SearchOption.TopDirectoryOnly)
.OrderBy(f => f, StringComparer.Ordinal)
.ToList();
using var zip = ZipFile.Open(destinationZip, ZipArchiveMode.Create);
foreach (var file in files)
{
var entryName = Path.GetFileName(file);
zip.CreateEntryFromFile(file, entryName, CompressionLevel.Optimal);
}
}
private static string ComputeSha256(string path)
{
using var sha = System.Security.Cryptography.SHA256.Create();
using var stream = File.OpenRead(path);
return Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant();
}
}
public sealed record RichGraphPublishResult(
string GraphHash,
string RelativePath,
int NodeCount,
int EdgeCount);

View File

@@ -0,0 +1,41 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.Surface.Env;
namespace StellaOps.Scanner.Reachability;
public interface IRichGraphPublisherService
{
Task<RichGraphPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Service wrapper that builds richgraph-v1 from a union graph and stores it in CAS.
/// </summary>
public sealed class ReachabilityRichGraphPublisherService : IRichGraphPublisherService
{
private readonly ISurfaceEnvironment _environment;
private readonly IFileContentAddressableStore _cas;
private readonly IRichGraphPublisher _publisher;
public ReachabilityRichGraphPublisherService(
ISurfaceEnvironment environment,
IFileContentAddressableStore cas,
IRichGraphPublisher publisher)
{
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_cas = cas ?? throw new ArgumentNullException(nameof(cas));
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
}
public Task<RichGraphPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default)
{
var richGraph = RichGraphBuilder.FromUnion(graph, "scanner.reachability", "0.1.0");
var workRoot = Path.Combine(_environment.Settings.CacheRoot.FullName, "reachability");
Directory.CreateDirectory(workRoot);
return _publisher.PublishAsync(richGraph, analysisId, _cas, workRoot, cancellationToken);
}
}

View File

@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Canonical richgraph-v1 document used for CAS storage and Signals ingestion.
/// </summary>
public sealed record RichGraph(
IReadOnlyList<RichGraphNode> Nodes,
IReadOnlyList<RichGraphEdge> Edges,
IReadOnlyList<RichGraphRoot> Roots,
RichGraphAnalyzer Analyzer,
string Schema = "richgraph-v1")
{
public RichGraph Trimmed()
{
var nodes = (Nodes ?? Array.Empty<RichGraphNode>())
.Where(n => !string.IsNullOrWhiteSpace(n.Id))
.Select(n => n.Trimmed())
.OrderBy(n => n.Id, StringComparer.Ordinal)
.ToList();
var edges = (Edges ?? Array.Empty<RichGraphEdge>())
.Where(e => !string.IsNullOrWhiteSpace(e.From) && !string.IsNullOrWhiteSpace(e.To))
.Select(e => e.Trimmed())
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.ThenBy(e => e.Kind, StringComparer.Ordinal)
.ToList();
var roots = (Roots ?? Array.Empty<RichGraphRoot>())
.Select(r => r.Trimmed())
.OrderBy(r => r.Id, StringComparer.Ordinal)
.ToList();
return this with { Nodes = nodes, Edges = edges, Roots = roots, Analyzer = Analyzer.Trimmed() };
}
}
public sealed record RichGraphNode(
string Id,
string SymbolId,
string? CodeId,
string? Purl,
string Lang,
string Kind,
string? Display,
string? BuildId,
IReadOnlyList<string>? Evidence,
IReadOnlyDictionary<string, string>? Attributes,
string? SymbolDigest)
{
public RichGraphNode Trimmed()
{
return this with
{
Id = Id.Trim(),
SymbolId = SymbolId.Trim(),
CodeId = string.IsNullOrWhiteSpace(CodeId) ? null : CodeId.Trim(),
Purl = string.IsNullOrWhiteSpace(Purl) ? null : Purl.Trim(),
Lang = Lang.Trim(),
Kind = Kind.Trim(),
Display = string.IsNullOrWhiteSpace(Display) ? null : Display.Trim(),
BuildId = string.IsNullOrWhiteSpace(BuildId) ? null : BuildId.Trim(),
SymbolDigest = string.IsNullOrWhiteSpace(SymbolDigest) ? null : SymbolDigest.Trim(),
Evidence = Evidence is null
? Array.Empty<string>()
: Evidence.Where(e => !string.IsNullOrWhiteSpace(e)).Select(e => e.Trim()).OrderBy(e => e, StringComparer.Ordinal).ToArray(),
Attributes = Attributes is null
? ImmutableDictionary<string, string>.Empty
: Attributes.Where(kv => !string.IsNullOrWhiteSpace(kv.Key) && kv.Value is not null)
.ToImmutableSortedDictionary(kv => kv.Key.Trim(), kv => kv.Value!.Trim(), StringComparer.Ordinal)
};
}
}
public sealed record RichGraphEdge(
string From,
string To,
string Kind,
string? Purl,
string? SymbolDigest,
IReadOnlyList<string>? Evidence,
double Confidence,
IReadOnlyList<string>? Candidates)
{
public RichGraphEdge Trimmed()
{
return this with
{
From = From.Trim(),
To = To.Trim(),
Kind = string.IsNullOrWhiteSpace(Kind) ? "call" : Kind.Trim(),
Purl = string.IsNullOrWhiteSpace(Purl) ? null : Purl.Trim(),
SymbolDigest = string.IsNullOrWhiteSpace(SymbolDigest) ? null : SymbolDigest.Trim(),
Evidence = Evidence is null
? Array.Empty<string>()
: Evidence.Where(e => !string.IsNullOrWhiteSpace(e)).Select(e => e.Trim()).OrderBy(e => e, StringComparer.Ordinal).ToArray(),
Candidates = Candidates is null
? Array.Empty<string>()
: Candidates.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()).OrderBy(c => c, StringComparer.Ordinal).ToArray(),
Confidence = ClampConfidence(Confidence)
};
}
private static double ClampConfidence(double value) => Math.Min(1.0, Math.Max(0.0, value));
}
public sealed record RichGraphRoot(string Id, string Phase, string? Source)
{
public RichGraphRoot Trimmed()
=> new(Id.Trim(), string.IsNullOrWhiteSpace(Phase) ? "runtime" : Phase.Trim(), string.IsNullOrWhiteSpace(Source) ? null : Source.Trim());
}
public sealed record RichGraphAnalyzer(string Name, string Version, string? ToolchainDigest)
{
public RichGraphAnalyzer Trimmed()
=> new(
string.IsNullOrWhiteSpace(Name) ? "scanner.reachability" : Name.Trim(),
string.IsNullOrWhiteSpace(Version) ? "0.1.0" : Version.Trim(),
string.IsNullOrWhiteSpace(ToolchainDigest) ? null : ToolchainDigest.Trim());
}
/// <summary>
/// Transforms the union graph into a richgraph-v1 payload with purl/symbol digests.
/// </summary>
public static class RichGraphBuilder
{
public static RichGraph FromUnion(ReachabilityUnionGraph union, string analyzerName, string analyzerVersion)
{
ArgumentNullException.ThrowIfNull(union);
var nodePurls = new Dictionary<string, string>(StringComparer.Ordinal);
var nodeDigests = new Dictionary<string, string>(StringComparer.Ordinal);
var nodes = new List<RichGraphNode>();
foreach (var node in union.Nodes ?? Array.Empty<ReachabilityUnionNode>())
{
if (string.IsNullOrWhiteSpace(node.SymbolId))
{
continue;
}
var symbolId = node.SymbolId.Trim();
var purl = node.Attributes is not null && node.Attributes.TryGetValue("purl", out var p) ? p : null;
if (!string.IsNullOrWhiteSpace(purl))
{
nodePurls[symbolId] = purl!;
}
var symbolDigest = ComputeSymbolDigest(symbolId);
nodeDigests[symbolId] = symbolDigest;
var codeId = node.Attributes is not null && node.Attributes.TryGetValue("code_id", out var cid)
? cid
: CodeId.FromSymbolId(symbolId);
nodes.Add(new RichGraphNode(
Id: symbolId,
SymbolId: symbolId,
CodeId: codeId,
Purl: purl,
Lang: node.Lang,
Kind: node.Kind,
Display: node.Display,
BuildId: node.Attributes is not null && node.Attributes.TryGetValue("build_id", out var bid) ? bid : null,
Evidence: node.Source?.Evidence is null ? Array.Empty<string>() : new[] { node.Source.Evidence },
Attributes: node.Attributes,
SymbolDigest: symbolDigest));
}
var edges = new List<RichGraphEdge>();
foreach (var edge in union.Edges ?? Array.Empty<ReachabilityUnionEdge>())
{
if (string.IsNullOrWhiteSpace(edge.From) || string.IsNullOrWhiteSpace(edge.To))
{
continue;
}
var toId = edge.To.Trim();
nodePurls.TryGetValue(toId, out var edgePurl);
nodeDigests.TryGetValue(toId, out var edgeDigest);
edges.Add(new RichGraphEdge(
From: edge.From.Trim(),
To: toId,
Kind: edge.EdgeType,
Purl: edgePurl ?? "pkg:unknown",
SymbolDigest: edgeDigest,
Evidence: edge.Source?.Evidence is null ? Array.Empty<string>() : new[] { edge.Source.Evidence },
Confidence: ConfidenceToProbability(edge.Confidence),
Candidates: edgePurl is null ? new[] { "pkg:unknown" } : Array.Empty<string>()));
}
// include any synthetic roots if provided as attributes
var roots = nodes
.Where(n => n.Attributes is not null && n.Attributes.ContainsKey("root"))
.Select(n => new RichGraphRoot(n.Id, "runtime", n.Attributes!["root"]))
.ToList();
return new RichGraph(nodes, edges, roots, new RichGraphAnalyzer(analyzerName, analyzerVersion, null)).Trimmed();
}
private static string ComputeSymbolDigest(string symbolId)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(symbolId));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static double ConfidenceToProbability(string? confidence)
{
return (confidence ?? string.Empty).Trim().ToLowerInvariant() switch
{
"certain" => 1.0,
"high" => 0.9,
"medium" => 0.6,
"low" => 0.3,
_ => 0.6
};
}
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Writes richgraph-v1 documents to disk with canonical ordering and BLAKE3 hash.
/// </summary>
public sealed class RichGraphWriter
{
private static readonly JsonWriterOptions JsonOptions = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false,
SkipValidation = false
};
public async Task<RichGraphWriteResult> WriteAsync(
RichGraph graph,
string outputRoot,
string analysisId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot);
ArgumentException.ThrowIfNullOrWhiteSpace(analysisId);
var trimmed = graph.Trimmed();
var root = Path.Combine(outputRoot, "reachability_graphs", analysisId);
Directory.CreateDirectory(root);
var graphPath = Path.Combine(root, "richgraph-v1.json");
await using (var stream = File.Create(graphPath))
await using (var writer = new Utf8JsonWriter(stream, JsonOptions))
{
WriteGraph(writer, trimmed);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
var bytes = await File.ReadAllBytesAsync(graphPath, cancellationToken).ConfigureAwait(false);
var graphHash = ComputeSha256(bytes);
var metaPath = Path.Combine(root, "meta.json");
await using (var stream = File.Create(metaPath))
await using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }))
{
writer.WriteStartObject();
writer.WriteString("schema", trimmed.Schema);
writer.WriteString("graph_hash", graphHash);
writer.WritePropertyName("files");
writer.WriteStartArray();
writer.WriteStartObject();
writer.WriteString("path", graphPath);
writer.WriteString("hash", graphHash);
writer.WriteEndObject();
writer.WriteEndArray();
writer.WriteEndObject();
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
return new RichGraphWriteResult(graphPath, metaPath, graphHash, trimmed.Nodes.Count, trimmed.Edges.Count);
}
private static void WriteGraph(Utf8JsonWriter writer, RichGraph graph)
{
writer.WriteStartObject();
writer.WriteString("schema", graph.Schema);
writer.WritePropertyName("analyzer");
writer.WriteStartObject();
writer.WriteString("name", graph.Analyzer.Name);
writer.WriteString("version", graph.Analyzer.Version);
if (!string.IsNullOrWhiteSpace(graph.Analyzer.ToolchainDigest))
{
writer.WriteString("toolchain_digest", graph.Analyzer.ToolchainDigest);
}
writer.WriteEndObject();
writer.WritePropertyName("nodes");
writer.WriteStartArray();
foreach (var node in graph.Nodes)
{
writer.WriteStartObject();
writer.WriteString("id", node.Id);
writer.WriteString("symbol_id", node.SymbolId);
writer.WriteString("lang", node.Lang);
writer.WriteString("kind", node.Kind);
if (!string.IsNullOrWhiteSpace(node.Display)) writer.WriteString("display", node.Display);
if (!string.IsNullOrWhiteSpace(node.CodeId)) writer.WriteString("code_id", node.CodeId);
if (!string.IsNullOrWhiteSpace(node.Purl)) writer.WriteString("purl", node.Purl);
if (!string.IsNullOrWhiteSpace(node.BuildId)) writer.WriteString("build_id", node.BuildId);
if (!string.IsNullOrWhiteSpace(node.SymbolDigest)) writer.WriteString("symbol_digest", node.SymbolDigest);
if (node.Evidence is { Count: > 0 })
{
writer.WritePropertyName("evidence");
writer.WriteStartArray();
foreach (var e in node.Evidence) writer.WriteStringValue(e);
writer.WriteEndArray();
}
if (node.Attributes is { Count: > 0 })
{
writer.WritePropertyName("attributes");
writer.WriteStartObject();
foreach (var kv in node.Attributes)
{
writer.WriteString(kv.Key, kv.Value);
}
writer.WriteEndObject();
}
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("edges");
writer.WriteStartArray();
foreach (var edge in graph.Edges)
{
writer.WriteStartObject();
writer.WriteString("from", edge.From);
writer.WriteString("to", edge.To);
writer.WriteString("kind", edge.Kind);
if (!string.IsNullOrWhiteSpace(edge.Purl)) writer.WriteString("purl", edge.Purl);
if (!string.IsNullOrWhiteSpace(edge.SymbolDigest)) writer.WriteString("symbol_digest", edge.SymbolDigest);
writer.WriteNumber("confidence", edge.Confidence);
if (edge.Evidence is { Count: > 0 })
{
writer.WritePropertyName("evidence");
writer.WriteStartArray();
foreach (var e in edge.Evidence) writer.WriteStringValue(e);
writer.WriteEndArray();
}
if (edge.Candidates is { Count: > 0 })
{
writer.WritePropertyName("candidates");
writer.WriteStartArray();
foreach (var c in edge.Candidates) writer.WriteStringValue(c);
writer.WriteEndArray();
}
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("roots");
writer.WriteStartArray();
foreach (var root in graph.Roots)
{
writer.WriteStartObject();
writer.WriteString("id", root.Id);
writer.WriteString("phase", root.Phase);
if (!string.IsNullOrWhiteSpace(root.Source)) writer.WriteString("source", root.Source);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static string ComputeSha256(IReadOnlyList<byte> bytes)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(bytes.ToArray());
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
}
public sealed record RichGraphWriteResult(
string GraphPath,
string MetaPath,
string GraphHash,
int NodeCount,
int EdgeCount);