feat: integrate performance upgrade and agent inspection tooling
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Agent 验收任务
|
||||
|
||||
用冷上下文开始,每项只给需求与 CLI 使用入口。记录查询次数、无关文件读取、事实错误、无必要新增代码和最终验证点;不得把关键词命中算作完成。
|
||||
|
||||
| 导航任务 | 应找到的证据 |
|
||||
|---|---|
|
||||
| 启动应用 | Starter 组合与 LoaderHost |
|
||||
| 事件未触发 | 发布、订阅以及是否实际绑定的区别 |
|
||||
| 远程命令 | command/execute、Network handler、Command 执行 |
|
||||
| provider 缺失 | Context key、候选 provider、运行态等待 |
|
||||
| 存档入口 | ShrinkSave、模块采集、存储实现 |
|
||||
| 模组退役 | Mod host、fiber、effect 撤回 |
|
||||
| Godot 主线程派发 | ShrinkGodotHost 和主线程队列 |
|
||||
| 修改网络服务的影响 | 直接调用、桥接和相关验证入口 |
|
||||
|
||||
精确查询通常应在三次命令内到达关键链路;若索引覆盖不足,应明确说明并查看原始证据。
|
||||
|
||||
| 复用任务 | 正确改动层级 |
|
||||
|---|---|
|
||||
| 默认网络派发每帧数量调整 | 先查宿主已有配置,不新写调度器 |
|
||||
| Context 卸载时取消事件订阅 | 组合已有 Context EventBus adapter,不新写全局订阅生命周期 |
|
||||
| 为新存储介质保存既有槽位 | 实现 IStorageProvider,并确认宿主初始化注入入口;缺口需要窄扩展,沿用存档和迁移机制 |
|
||||
| 没有任何业务模块的新怒气玩法 | 承认计量/触发等业务缺口;可复用基础设施但不假称有完整玩法 |
|
||||
|
||||
真实消费项目的业务能力由该项目提供,不从上述 SDK 示例推断其存在。新 CLI 的静态回归测试不能替代冷上下文 agent 验收。
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../RuntimeTests/RuntimeTests.csproj" />
|
||||
<Compile Include="../../../Artifacts/AgentSupport/BaselineContext/**/*.cs" />
|
||||
<PackageReference Include="System.IO.Pipelines" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../..'))
|
||||
$destination = Join-Path $root 'Artifacts/AgentSupport/BaselineContext'
|
||||
New-Item -ItemType Directory -Force -Path $destination | Out-Null
|
||||
$module = Join-Path $root 'Assets/Modules/ShrinkContext.Core'
|
||||
$files = git -C $module ls-tree -r --name-only HEAD Runtime
|
||||
foreach ($file in $files) {
|
||||
if (-not $file.EndsWith('.cs')) { continue }
|
||||
$target = Join-Path $destination $file
|
||||
New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($target)) | Out-Null
|
||||
$source = (git -C $module show "HEAD:$file") -join "`n"
|
||||
[IO.File]::WriteAllText($target, $source.Replace('ShrinkContext', 'BaselineContext'), [Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
git -C $module rev-parse HEAD | Set-Content -Encoding utf8 (Join-Path $destination 'revision.txt')
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipelines;
|
||||
using System.Text.Json;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkContext;
|
||||
using ShrinkNetwork;
|
||||
using ShrinkNetwork.MessagePack;
|
||||
using ShrinkEventBus;
|
||||
using ShrinkDataSaver;
|
||||
using BC = BaselineContext;
|
||||
|
||||
if (args.Length > 0 && args[0] == "--wire")
|
||||
{
|
||||
using var frame = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Opcode = 42, Route = "跨宿主" }, new ShrinkJsonNetworkSerializer(), new NetworkTransportTests.Request { Text = "跨宿主-v2" });
|
||||
var path = "Assets/AgentSupportTests/Runtime/Resources/ShrinkV2Golden.bytes";
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllBytes(path, frame.WrittenMemory.ToArray());
|
||||
var mp = new ShrinkMessagePackNetworkSerializer(TestMessageResolver.Instance); mp.Register<CodecMessage>();
|
||||
using var packed = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Opcode = 43, Route = "messagepack" }, mp, new CodecMessage { Value = 42, Text = "跨宿主-v2" });
|
||||
File.WriteAllBytes("Artifacts/AgentSupport/messagepack-golden.bytes", packed.WrittenMemory.ToArray()); return;
|
||||
}
|
||||
if (args.Length > 0 && args[0] == "--tcp") { await TcpBenchmarks.Run(args.Length > 1 ? args[1] : "Artifacts/AgentSupport/tcp-benchmark.json"); return; }
|
||||
var rows = new List<object>();
|
||||
void Measure(string scenario, int iterations, Action action, object? parameters = null)
|
||||
{
|
||||
for (var i = 0; i < Math.Min(iterations, 100); i++) action();
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var latencies = new double[iterations];
|
||||
var allocated = GC.GetAllocatedBytesForCurrentThread();
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var tick = Stopwatch.GetTimestamp(); action();
|
||||
latencies[i] = (Stopwatch.GetTimestamp() - tick) * 1e6 / Stopwatch.Frequency;
|
||||
}
|
||||
var elapsed = (Stopwatch.GetTimestamp() - start) / (double)Stopwatch.Frequency;
|
||||
allocated = GC.GetAllocatedBytesForCurrentThread() - allocated;
|
||||
Array.Sort(latencies);
|
||||
rows.Add(new { scenario, parameters, iterations, throughputPerSecond = iterations / elapsed,
|
||||
allocatedBytesPerOperation = allocated / (double)iterations, p50Microseconds = latencies[(int)(iterations * .50)],
|
||||
p95Microseconds = latencies[(int)(iterations * .95)], p99Microseconds = latencies[(int)(iterations * .99)] });
|
||||
}
|
||||
|
||||
var json = new ShrinkJsonNetworkSerializer();
|
||||
var messagePack = new ShrinkMessagePackNetworkSerializer(TestMessageResolver.Instance); messagePack.Register<CodecMessage>();
|
||||
foreach (var size in new[] { 64, 1024, 32768 })
|
||||
{
|
||||
var message = new CodecMessage { Text = new string('x', size) };
|
||||
var payload = json.Serialize(message);
|
||||
Measure("network/legacy-json-envelope", 1000, () => json.Serialize(new LegacyPacket { Payload = payload }), new { payloadBytes = size });
|
||||
var packet = new ShrinkNetworkPacket { Payload = payload };
|
||||
Measure("network/v2-envelope", 1000, () => { using var encoded = ShrinkPacketCodec.Encode(packet); }, new { payloadBytes = size });
|
||||
Measure("network/v2-json-message", 1000, () => { using var encoded = ShrinkPacketCodec.Encode(new(), json, message); }, new { payloadBytes = size });
|
||||
Measure("network/v2-generated-messagepack", 1000, () => { using var encoded = ShrinkPacketCodec.Encode(new(), messagePack, message); }, new { payloadBytes = size });
|
||||
foreach (var connections in new[] { 1, 32, 256 })
|
||||
{
|
||||
var client = new ShrinkNetworkService(); var server = new ShrinkNetworkService();
|
||||
var a = new ShrinkLoopbackTransport(); var b = new ShrinkLoopbackTransport(); a.LinkPeer(b);
|
||||
client.RegisterMessage<CodecMessage>(1); server.RegisterMessage<CodecMessage>(1);
|
||||
server.RegisterHandler<CodecMessage>((_, m) => UniTask.CompletedTask);
|
||||
client.BindTransport(a); server.BindTransport(b);
|
||||
for (var i = 0; i < connections; i++) a.OpenSession(i);
|
||||
var session = 0;
|
||||
Measure("network/loopback-session-send", 1000, () => client.SendAsync(client.Sessions[session++ % connections], message).GetAwaiter().GetResult(), new { payloadBytes = size, connections });
|
||||
Measure("network/loopback-broadcast-shared-payload", 25, () => {
|
||||
foreach (var peer in client.Sessions.Values) client.SendSerializedAsync(peer, typeof(CodecMessage), payload).GetAwaiter().GetResult();
|
||||
}, new { payloadBytes = size, connections });
|
||||
a.Stop(); b.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var count in new[] { 10, 100, 1000 })
|
||||
{
|
||||
var current = new ShrinkContextRuntime(); var baseline = new BC.BaselineContextRuntime();
|
||||
for (var i = 0; i < count; i++) { current.Use(new Component("k" + i, i == 0 ? null : "k" + (i-1))); baseline.Use(new BaselineComponent("k" + i, i == 0 ? null : "k" + (i-1))); }
|
||||
Measure("context/current-diagnostics", 100, () => current.CaptureDiagnostic(), new { components = count });
|
||||
Measure("context/baseline-diagnostics", 100, () => baseline.CaptureDiagnostic(), new { components = count });
|
||||
current.ShutdownAsync().GetAwaiter().GetResult();
|
||||
// Baseline recursively unloads deep chains; retire leaves first to obtain diagnostic measurements safely.
|
||||
foreach (var fiber in baseline.Fibers.Reverse().ToArray()) baseline.RetireAsync(fiber).GetAwaiter().GetResult();
|
||||
baseline.ShutdownAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
var live = new ShrinkContextRuntime(); var old = new BC.BaselineContextRuntime();
|
||||
var memoryBefore = GC.GetTotalMemory(true);
|
||||
for (var i = 0; i < 10000; i++) live.RetireAsync(live.Use(new Component("cycle"))).GetAwaiter().GetResult();
|
||||
var currentRetained = GC.GetTotalMemory(true) - memoryBefore;
|
||||
memoryBefore = GC.GetTotalMemory(true);
|
||||
for (var i = 0; i < 10000; i++) old.RetireAsync(old.Use(new BaselineComponent("cycle"))).GetAwaiter().GetResult();
|
||||
var baselineRetained = GC.GetTotalMemory(true) - memoryBefore;
|
||||
rows.Add(new { scenario = "context/10000-retirements", currentRetainedBytes = currentRetained, baselineRetainedBytes = baselineRetained,
|
||||
currentFibers = live.Fibers.Count, currentHistory = live.CaptureDiagnostic().Fibers.Count, baselineFibers = old.Fibers.Count });
|
||||
GC.KeepAlive(live); GC.KeepAlive(old);
|
||||
|
||||
var bus = EventBus.Default;
|
||||
using (var binding = ShrinkGeneratedBinding.Subscribe<Event>(ShrinkGeneratedBinding.RuntimeResolver, null, null, null, _ => { }, ShrinkEventPriority.Normal, 0, false))
|
||||
{
|
||||
Measure("event/sync", 100000, () => bus.Post(new Event()));
|
||||
Measure("event/async-sync-subscriber", 10000, () => bus.PostAsync(new Event()).GetAwaiter().GetResult());
|
||||
Measure("event/binding-diagnostics", 1000, () => EventBus.CaptureBindings(bus));
|
||||
}
|
||||
Measure("event/register-withdraw", 10000, () => ShrinkGeneratedBinding.Subscribe<Event>(ShrinkGeneratedBinding.RuntimeResolver, null, null, null, _ => { }, ShrinkEventPriority.Normal, 0, false).Dispose());
|
||||
using (var binding = ShrinkGeneratedBinding.SubscribeAsync<Event>(ShrinkGeneratedBinding.RuntimeResolver, null, null, null, (_, _) => UniTask.CompletedTask, ShrinkEventPriority.Normal, 0, false))
|
||||
Measure("event/async-subscriber", 10000, () => bus.PostAsync(new Event()).GetAwaiter().GetResult());
|
||||
|
||||
using (var queue = new ShrinkNetworkWorkQueue(1024, 1024 * 1024))
|
||||
{
|
||||
var completions = new List<UniTask<ShrinkNetworkQueueResult>>();
|
||||
for (var i = 0; i < 10000; i++) completions.Add(queue.EnqueueAsync(i % 32, "state", 1024, () => UniTask.CompletedTask, "player"));
|
||||
var before = queue.CaptureDiagnostics();
|
||||
await queue.PumpAsync(1024, 4096, TimeSpan.FromMilliseconds(2));
|
||||
rows.Add(new { scenario = "queue/burst-state", before, after = queue.CaptureDiagnostics() });
|
||||
queue.Dispose(); foreach (var completion in completions) await completion;
|
||||
}
|
||||
|
||||
var saveRoot = Path.GetFullPath(args.Length > 1 ? args[1] : "Artifacts/AgentSupport/BenchmarkSaves");
|
||||
ShrinkDataSaverRuntime.Initialize(new ShrinkDataSaverRuntimeConfig { RootPath = saveRoot });
|
||||
foreach (var size in new[] { 100 * 1024, 1024 * 1024, 10 * 1024 * 1024 })
|
||||
foreach (var background in new[] { false, true })
|
||||
{
|
||||
var state = new string('s', size);
|
||||
ShrinkSave.RegisterModule("benchmark", () => new { state }, _ => { });
|
||||
foreach (var encrypted in new[] { false, true })
|
||||
{
|
||||
var samples = new List<SavePhaseTimings>();
|
||||
var allocated = GC.GetTotalAllocatedBytes(true); var elapsed = Stopwatch.StartNew();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var options = new SaveOptions { CollectTimings = true, EncodeInBackground = background, Encrypt = encrypted, EncryptionKey = "benchmark-only" };
|
||||
await ShrinkSave.SaveSlotAsync(0, options); samples.Add(options.LastTimings);
|
||||
}
|
||||
rows.Add(new { scenario = "save/phases", snapshotBytes = size, background, encrypted, elapsedMilliseconds = elapsed.Elapsed.TotalMilliseconds,
|
||||
processAllocatedBytes = GC.GetTotalAllocatedBytes(true) - allocated, samples });
|
||||
}
|
||||
ShrinkSave.UnregisterModule("benchmark");
|
||||
}
|
||||
|
||||
// In-process backend comparison only: not a socket throughput claim and never a runtime dependency.
|
||||
var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: long.MaxValue, resumeWriterThreshold: long.MaxValue));
|
||||
var chunk = new byte[1024];
|
||||
Measure("experiment/pipelines-buffer-roundtrip", 10000, () => {
|
||||
pipe.Writer.WriteAsync(chunk).GetAwaiter().GetResult();
|
||||
var result = pipe.Reader.ReadAsync().GetAwaiter().GetResult(); pipe.Reader.AdvanceTo(result.Buffer.End);
|
||||
});
|
||||
await pipe.Writer.CompleteAsync(); await pipe.Reader.CompleteAsync();
|
||||
var output = Path.GetFullPath(args.Length > 0 ? args[0] : "Artifacts/AgentSupport/benchmark.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(output)!);
|
||||
await File.WriteAllTextAsync(output, JsonSerializer.Serialize(new { runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
|
||||
host = "standalone .NET; Loopback sessions are not OS connections; diagnostic microbenchmarks are not Player data", atUtc = DateTime.UtcNow, rows }, new JsonSerializerOptions { WriteIndented = true }));
|
||||
Console.WriteLine(output);
|
||||
|
||||
public sealed class LegacyPacket
|
||||
{
|
||||
public int ProtocolVersion = 1, SchemaVersion = 1, Opcode = 1;
|
||||
public ShrinkRequestToken RequestToken;
|
||||
public string SessionToken = "", Route = "";
|
||||
public long SessionTokenExpiresAtUnixTimeSeconds;
|
||||
public ShrinkNetworkPacketKind Kind;
|
||||
public byte[] Payload = [];
|
||||
}
|
||||
public readonly struct Event : IShrinkEvent { }
|
||||
public sealed class Component(string key, string? dependency = null) : IShrinkComponent
|
||||
{
|
||||
public string Name => key;
|
||||
public IReadOnlyList<string> Inject { get; } = dependency == null ? Array.Empty<string>() : new[] { dependency };
|
||||
public IReadOnlyList<string> Provide { get; } = new[] { key };
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config) { ctx.Set(key, this); return UniTask.CompletedTask; }
|
||||
}
|
||||
public sealed class BaselineComponent(string key, string? dependency = null) : BC.IShrinkComponent
|
||||
{
|
||||
public string Name => key;
|
||||
public IReadOnlyList<string> Inject { get; } = dependency == null ? Array.Empty<string>() : new[] { dependency };
|
||||
public IReadOnlyList<string> Provide { get; } = new[] { key };
|
||||
public UniTask ApplyAsync(BC.ShrinkCtx ctx, object? config) { ctx.Set(key, this); return UniTask.CompletedTask; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkNetwork;
|
||||
|
||||
public static class TcpBenchmarks
|
||||
{
|
||||
public static async Task Run(string output)
|
||||
{
|
||||
var rows = new List<object>();
|
||||
foreach (var connections in new[] { 1, 32, 256 })
|
||||
{
|
||||
var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); var port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop();
|
||||
var transport = new ShrinkTcpServerTransport(IPAddress.Loopback, port);
|
||||
var server = Service();
|
||||
server.RegisterRequestHandler<NetworkTransportTests.Request, NetworkTransportTests.Response>((_, request) => UniTask.FromResult(new NetworkTransportTests.Response { Text = request.Text }));
|
||||
server.BindTransport(transport);
|
||||
var clients = new List<(ShrinkNetworkService service, ShrinkTcpClientTransport transport, ShrinkNetworkSession session)>();
|
||||
long notifications = 0;
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < connections; i++)
|
||||
{
|
||||
var client = Service(); var link = new ShrinkTcpClientTransport("127.0.0.1", port);
|
||||
var connected = new TaskCompletionSource<ShrinkNetworkSession>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.OnSessionConnected += s => connected.TrySetResult(s);
|
||||
client.RegisterHandler<NetworkTransportTests.Request>((_, _) => { Interlocked.Increment(ref notifications); return UniTask.CompletedTask; });
|
||||
client.BindTransport(link); clients.Add((client, link, await connected.Task.WaitAsync(TimeSpan.FromSeconds(10))));
|
||||
}
|
||||
foreach (var bytes in new[] { 64, 1024, 32768 })
|
||||
{
|
||||
var samples = new List<double>();
|
||||
var message = new NetworkTransportTests.Request { Text = new string('t', bytes) };
|
||||
var allocated = GC.GetTotalAllocatedBytes(true); var elapsed = Stopwatch.StartNew();
|
||||
for (var i = 0; i < 256; i++)
|
||||
{
|
||||
var client = clients[i % clients.Count]; var start = Stopwatch.GetTimestamp();
|
||||
var response = await client.service.CallAsync<NetworkTransportTests.Request, NetworkTransportTests.Response>(client.session, message).AsTask().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
if (response.Text.Length != bytes) throw new InvalidDataException("RPC content mismatch");
|
||||
samples.Add(Stopwatch.GetElapsedTime(start).TotalMicroseconds);
|
||||
}
|
||||
samples.Sort();
|
||||
rows.Add(new { scenario = "tcp/rpc", connections, bytes, operations = 256, throughputPerSecond = 256 / elapsed.Elapsed.TotalSeconds,
|
||||
p50Microseconds = samples[128], p95Microseconds = samples[243], p99Microseconds = samples[253], processAllocatedBytes = GC.GetTotalAllocatedBytes(true) - allocated });
|
||||
var payload = new ShrinkJsonNetworkSerializer().Serialize(message); var before = Interlocked.Read(ref notifications); elapsed.Restart();
|
||||
foreach (var session in server.Sessions.Values) await server.SendSerializedAsync(session, typeof(NetworkTransportTests.Request), payload);
|
||||
while (Interlocked.Read(ref notifications) - before < connections)
|
||||
{ if (elapsed.Elapsed > TimeSpan.FromSeconds(10)) throw new TimeoutException("Broadcast drain"); await Task.Delay(1); }
|
||||
rows.Add(new { scenario = "tcp/broadcast", connections, bytes, elapsedMilliseconds = elapsed.Elapsed.TotalMilliseconds, delivered = Interlocked.Read(ref notifications) - before });
|
||||
}
|
||||
using var queue = new ShrinkNetworkDispatchQueue(32, byteCapacity: 32768, perSessionCapacity: 2);
|
||||
server.DispatchScheduler = queue;
|
||||
var attempts = connections * 8;
|
||||
for (var i = 0; i < attempts; i++)
|
||||
{
|
||||
var c = clients[i % clients.Count];
|
||||
if (c.service.Sessions.Count != 0) try { await c.service.SendAsync(c.session, new NetworkTransportTests.Request { Text = new string('s', 1024) }); } catch (IOException) { }
|
||||
}
|
||||
await Task.Delay(100);
|
||||
var beforePump = queue.CaptureDiagnostics(); await queue.PumpAsync(32, 8192, TimeSpan.FromMilliseconds(2));
|
||||
rows.Add(new { scenario = "tcp/slow-consumer-burst", connections, attempts, beforePump, afterPump = queue.CaptureDiagnostics(), metrics = server.GetDiagnosticsSnapshot() });
|
||||
}
|
||||
finally { foreach (var c in clients) c.transport.Stop(); transport.Stop(); }
|
||||
Console.WriteLine("TCP completed: " + connections);
|
||||
}
|
||||
await File.WriteAllTextAsync(output, JsonSerializer.Serialize(new { runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, host = "Windows loopback TCP; sequential round-trip samples, multiple real connections", rows }, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
private static ShrinkNetworkService Service()
|
||||
{
|
||||
var service = new ShrinkNetworkService(); service.RegisterMessage<NetworkTransportTests.Request>(1); service.RegisterMessage<NetworkTransportTests.Response>(2); return service;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$project = Join-Path $root 'Assets/Modules/ShrinkShared.CodeGen/DotNet~/ShrinkSDK.CodeGen.Analyzers'
|
||||
$output = Join-Path $root 'Artifacts/AgentSupport/unity-analyzer'
|
||||
& dotnet build $project -c Release -p:RoslynVersion=3.8.0 "-p:OutputPath=$output/" --nologo /nodeReuse:false
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Unity analyzer build failed.' }
|
||||
Copy-Item -LiteralPath (Join-Path $output 'ShrinkSDK.CodeGen.Analyzers.dll') -Destination (Join-Path $root 'Assets/Modules/ShrinkShared.CodeGen/Analyzers/ShrinkSDK.CodeGen.Analyzers.dll')
|
||||
# Keep the Unity-generated .meta: RoslynAnalyzer label and disabled runtime plugin platforms.
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Build portable local UPM packages and update only ShrinkSDK dependencies in a consumer."""
|
||||
import argparse, json, tarfile, shutil
|
||||
from pathlib import Path
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("consumer", type=Path)
|
||||
parser.add_argument("--messagepack", action="store_true", help="Requires MessagePack 3.x and its generator already installed in Unity")
|
||||
args = parser.parse_args()
|
||||
sdk = Path(__file__).resolve().parents[2]
|
||||
consumer = args.consumer.resolve()
|
||||
manifest_path = consumer / "Packages/manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
|
||||
catalog = {}
|
||||
for path in list((sdk / "Assets/Modules").glob("*/package.json")) + [sdk / "Assets/ShrinkInspection/package.json", sdk / "Assets/Modules/ShrinkNetwork/Adapters~/MessagePack/package.json"]:
|
||||
meta = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
catalog[meta["name"]] = (path.parent, meta)
|
||||
selected = set()
|
||||
def include(name):
|
||||
if name in selected: return
|
||||
if name not in catalog: raise ValueError("Missing local SDK package: " + name)
|
||||
selected.add(name)
|
||||
for dep in catalog[name][1].get("dependencies", {}):
|
||||
if dep.startswith("com.cneicy.shrink-"): include(dep)
|
||||
for name in manifest["dependencies"]:
|
||||
if name.startswith("com.cneicy.shrink-"): include(name)
|
||||
include("com.cneicy.shrink-inspection")
|
||||
if args.messagepack: include("com.cneicy.shrink-network-messagepack")
|
||||
output = consumer / "Packages/ShrinkSDK"
|
||||
output.mkdir(exist_ok=True)
|
||||
backup = sdk / "Artifacts/AgentSupport" / (consumer.name + "-manifest-before.json")
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not backup.exists(): shutil.copy2(manifest_path, backup)
|
||||
excluded = {".git", ".vs", "bin", "obj", "node_modules", "Library", "Temp", "Development~"}
|
||||
for name in sorted(selected):
|
||||
root, meta = catalog[name]
|
||||
filename = name + "-" + meta["version"] + ".tgz"
|
||||
with tarfile.open(output / filename, "w:gz") as archive:
|
||||
for path in sorted(root.rglob("*")):
|
||||
relative = path.relative_to(root)
|
||||
if path.is_file() and not any(part in excluded for part in relative.parts) and not path.is_symlink():
|
||||
archive.add(path, arcname="package/" + relative.as_posix(), recursive=False)
|
||||
manifest["dependencies"][name] = "file:ShrinkSDK/" + filename
|
||||
print(name, meta["version"])
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
@@ -0,0 +1,36 @@
|
||||
param([string]$Feed = 'Artifacts/AgentSupport/packages')
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$feedPath = [IO.Path]::GetFullPath((Join-Path $root $Feed))
|
||||
New-Item -ItemType Directory -Force -Path $feedPath | Out-Null
|
||||
$catalog = Get-Content (Join-Path $root 'Tools/Release/package-versions.json') -Raw | ConvertFrom-Json
|
||||
$projects = @{}
|
||||
foreach ($project in Get-ChildItem (Join-Path $root 'Assets/Modules') -Filter '*.csproj' -Recurse) {
|
||||
if ($project.FullName -match '[\\/](obj|bin|Development~|Tools~|packages)[\\/]') { continue }
|
||||
[xml]$xml = Get-Content $project.FullName -Raw
|
||||
$id = @($xml.Project.PropertyGroup.PackageId | Where-Object { $_ })[0]
|
||||
if ($id) { $projects[[string]$id] = $project.FullName }
|
||||
}
|
||||
foreach ($project in Get-ChildItem (Join-Path $root 'Godot/Packages') -Filter '*.csproj' -Recurse) {
|
||||
[xml]$xml = Get-Content $project.FullName -Raw
|
||||
$id = @($xml.Project.PropertyGroup.PackageId | Where-Object { $_ })[0]
|
||||
if ($id) { $projects[[string]$id] = $project.FullName }
|
||||
}
|
||||
$projects['ShrinkSDK.Inspection'] = Join-Path $root 'Assets/ShrinkInspection/DotNet~/ShrinkSDK.Inspection.csproj'
|
||||
$done = [Collections.Generic.HashSet[string]]::new()
|
||||
$visiting = [Collections.Generic.HashSet[string]]::new()
|
||||
function Build-Package([string]$id) {
|
||||
if ($done.Contains($id)) { return }
|
||||
if (-not $visiting.Add($id)) { throw "NuGet dependency cycle: $id" }
|
||||
$project = $projects[$id]
|
||||
[xml]$xml = Get-Content $project -Raw
|
||||
foreach ($dependency in $xml.Project.ItemGroup.PackageReference) {
|
||||
if ($dependency -and $projects.ContainsKey([string]$dependency.Include)) { Build-Package ([string]$dependency.Include) }
|
||||
}
|
||||
$log = Join-Path $feedPath "$id.pack.log"
|
||||
& dotnet pack $project -c Release -o $feedPath "-p:RestoreSources=$feedPath" -p:NuGetAudit=false --nologo --verbosity quiet *> $log
|
||||
if ($LASTEXITCODE -ne 0) { Get-Content $log -Tail 20; throw "Pack failed: $id" }
|
||||
[void]$visiting.Remove($id); [void]$done.Add($id)
|
||||
Write-Output "Packed $id"
|
||||
}
|
||||
foreach ($id in @($projects.Keys | Sort-Object)) { Build-Package $id }
|
||||
@@ -0,0 +1,37 @@
|
||||
# ShrinkSDK Inspect
|
||||
|
||||
本地语义导航与能力查询工具。无需模型 API、向量数据库或常驻服务。
|
||||
|
||||
```powershell
|
||||
dotnet pack Tools/AgentSupport/Shrink.Inspect -c Release -o Artifacts/Inspect
|
||||
dotnet tool install ShrinkSDK.Inspect --tool-path Artifacts/Inspect/bin --add-source Artifacts/Inspect --version 0.1.2
|
||||
Artifacts/Inspect/bin/shrink inspect capabilities "存档" --root .
|
||||
Artifacts/Inspect/bin/shrink inspect modules --root .
|
||||
```
|
||||
|
||||
支持 `modules`、`capabilities`、`symbol`、`flow`、`impact`、`check`。能力默认 3 项;其他结果默认 20 项,链路深度 2。使用 `--module`、`--limit`、`--depth` 缩小或展开,`--json` 返回结构化数据。
|
||||
|
||||
Unity 使用菜单 **Tools/ShrinkSDK/Inspection/Export compilation inputs** 导出实际编译引用、源文件和宏,然后传入 `--compilation Artifacts/Inspection/compilation.json`。没有导出时 IDE 工程输入只作为可能过期的辅助;无工程输入时采用标记不完整的源码候选索引。
|
||||
|
||||
.NET/Godot 消费项目先正常 restore/build,再使用 `--project path/to/project.csproj`,通过 MSBuildWorkspace 获取语义。工具需要 .NET 8 或兼容运行时,以及可加载该项目的 SDK。
|
||||
|
||||
```text
|
||||
shrink inspect flow "route:command/execute" --compilation Artifacts/Inspection/compilation.json
|
||||
shrink inspect symbol ShrinkContextRuntime --compilation Artifacts/Inspection/compilation.json
|
||||
shrink inspect impact ShrinkNetworkService.cs --compilation Artifacts/Inspection/compilation.json
|
||||
shrink inspect check --compilation Artifacts/Inspection/compilation.json --json
|
||||
```
|
||||
|
||||
`static` 是静态绑定,`candidate` 是动态候选/待注册关系,`runtime-bound` 来自相同构建的按需快照,`unresolved` 表示信息不足。静态订阅关系不等于活跃订阅;影响查询不证明没有其他间接影响。索引每次从当前输入构建,不保存不可见的跨任务记忆。
|
||||
|
||||
通过 `--snapshot` 加载宿主导出的快照;无可验证构建标识或标识不符时不合并。工具不自动连接生产宿主,不输出消息正文或令牌。
|
||||
|
||||
新增内容的工作方式见 [复用优先设计](../../Docs/ReuseFirstDesign.md)。源码、配置与行为验证优先于描述;无命中不意味着不存在能力。
|
||||
|
||||
退出码:0 查询完成,1 `check` 发现确定错误,2 参数/输入错误。覆盖不足以结构化 coverage 返回,不伪装为完整检查通过。
|
||||
|
||||
当前目录下的 Unity PackageCache 只纳入已安装的 `com.cneicy.shrink-*` 源码与能力说明,其他第三方包作为引用。消费项目用自己的 README 声明业务能力,工具不会从 SDK 基础设施推断玩法已经存在。
|
||||
|
||||
本机仅安装较新 .NET SDK 时,CLI 允许前滚到最新已安装主版本;共享内核仍为 netstandard2.1。能力查询无需 MSBuild,`--project` 需要可加载目标项目的 SDK。
|
||||
|
||||
本地发布与验证入口:`Pack-Local.ps1` 打包 NuGet,`Install-Unity.py <工程路径>` 生成可随工程保存的 UPM tarball;它只改消费工程的 ShrinkSDK 依赖并保留原 manifest 备份。新版安装和回退说明见 [迁移指南](../../Docs/NetworkV2Migration.md)。
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using NUnit.Framework;
|
||||
using ShrinkCommand;
|
||||
using ShrinkCommand.Integration;
|
||||
using ShrinkNetwork;
|
||||
|
||||
public class BridgeRetentionTests
|
||||
{
|
||||
[Test]
|
||||
public void CommandBridgeDoesNotKeepRetiredNetworkServiceAlive()
|
||||
{
|
||||
var weak = Register(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
Assert.That(weak.IsAlive, Is.False);
|
||||
}
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static WeakReference Register()
|
||||
{
|
||||
var service = new ShrinkNetworkService();
|
||||
ShrinkNetworkCommandBridge.RegisterService(service, new ShrinkCommandService());
|
||||
return new WeakReference(service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
using ShrinkNetwork;
|
||||
using ShrinkNetwork.MessagePack;
|
||||
|
||||
[MessagePackObject]
|
||||
public partial class CodecMessage : IShrinkNetworkMessage
|
||||
{
|
||||
[Key(0)] public int Value { get; set; }
|
||||
[Key(1)] public string Text { get; set; } = "";
|
||||
}
|
||||
|
||||
[GeneratedMessagePackResolver]
|
||||
public partial class TestMessageResolver { }
|
||||
|
||||
public class NetworkCodecTests
|
||||
{
|
||||
[TestCase(false)] [TestCase(true)]
|
||||
public void EnvelopeRoundTripsPayloadWithoutEncodingItAgain(bool messagePack)
|
||||
{
|
||||
IShrinkNetworkBufferSerializer serializer;
|
||||
if (messagePack) { var mp = new ShrinkMessagePackNetworkSerializer(TestMessageResolver.Instance); mp.Register<CodecMessage>(); serializer = mp; }
|
||||
else serializer = new ShrinkJsonNetworkSerializer();
|
||||
var counting = new CountingSerializer(serializer);
|
||||
var message = new CodecMessage { Value = 42, Text = "跨宿主消息" };
|
||||
using var encoded = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Opcode = 123, Route = "中文/route", SessionToken = "secret", RequestToken = new(9) }, counting, message);
|
||||
var packet = ShrinkPacketCodec.Decode(encoded.WrittenMemory);
|
||||
var decoded = (CodecMessage)serializer.Deserialize(packet.Payload, typeof(CodecMessage));
|
||||
Assert.That(counting.Count, Is.EqualTo(1));
|
||||
Assert.That(decoded.Text, Is.EqualTo(message.Text));
|
||||
Assert.That(decoded.Value, Is.EqualTo(42));
|
||||
Assert.That(packet.Route, Is.EqualTo("中文/route"));
|
||||
Assert.That(packet.SessionToken, Is.EqualTo("secret"));
|
||||
Assert.That(packet.RequestToken.Value, Is.EqualTo(9));
|
||||
Assert.That(encoded.WrittenCount, Is.EqualTo(ShrinkPacketCodec.HeaderSize + System.Text.Encoding.UTF8.GetByteCount(packet.Route!) + 6 + packet.Payload.Length));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RejectsOldVersionMalformedLengthsAndTrailingData()
|
||||
{
|
||||
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(System.Text.Encoding.UTF8.GetBytes("{\"ProtocolVersion\":1}")));
|
||||
using var encoded = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Payload = new byte[] { 1, 2, 3 } });
|
||||
var bytes = encoded.WrittenMemory.ToArray();
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(4), 1);
|
||||
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(bytes));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(4), 2);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(29), int.MaxValue);
|
||||
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(bytes));
|
||||
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(encoded.WrittenMemory[..^1]));
|
||||
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(encoded.WrittenMemory.ToArray().Concat(new byte[1]).ToArray()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvalidUtf8HeaderIsAProtocolRejection()
|
||||
{
|
||||
using var encoded = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Route = "r", SessionToken = "t" });
|
||||
foreach (var offset in new[] { ShrinkPacketCodec.HeaderSize, ShrinkPacketCodec.HeaderSize + 1 })
|
||||
{
|
||||
var bytes = encoded.WrittenMemory.ToArray(); bytes[offset] = 0xff;
|
||||
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BorrowedDecodeAndWriterDisposeHaveExplicitLifetimes()
|
||||
{
|
||||
var writer = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Payload = new byte[] { 7 } });
|
||||
var packet = ShrinkPacketCodec.Decode(writer.WrittenMemory);
|
||||
Assert.That(packet.Payload.Span[0], Is.EqualTo(7));
|
||||
writer.Dispose(); writer.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(() => writer.GetMemory());
|
||||
}
|
||||
|
||||
[TestCase(false)] [TestCase(true)]
|
||||
public async Task ServiceKeepsBufferAliveUntilTransportCompletes(bool fail)
|
||||
{
|
||||
var transport = new DeferredTransport();
|
||||
var service = new ShrinkNetworkService();
|
||||
service.RegisterMessage<CodecMessage>(1);
|
||||
service.BindTransport(transport);
|
||||
transport.Connect();
|
||||
var sending = service.SendAsync(service.Sessions[1], new CodecMessage { Text = "alive" }).AsTask();
|
||||
Assert.That(sending.IsCompleted, Is.False);
|
||||
Assert.That(((CodecMessage)new ShrinkJsonNetworkSerializer().Deserialize(ShrinkPacketCodec.Decode(transport.Memory).Payload, typeof(CodecMessage))).Text, Is.EqualTo("alive"));
|
||||
if (fail) transport.Completion.TrySetException(new IOException("disconnect")); else transport.Completion.TrySetResult();
|
||||
if (fail) Assert.ThrowsAsync<IOException>(async () => await sending); else await sending;
|
||||
Assert.That(transport.Memory.Span.ToArray(), Is.All.EqualTo(0), "pooled buffer is cleared and released after completion/failure");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task QueuedLoopbackReceiverOwnsPacketAfterSenderReturns()
|
||||
{
|
||||
using var queue = new ShrinkNetworkDispatchQueue(4);
|
||||
var client = new ShrinkNetworkService();
|
||||
var server = new ShrinkNetworkService { DispatchScheduler = queue };
|
||||
client.RegisterMessage<CodecMessage>(1); server.RegisterMessage<CodecMessage>(1);
|
||||
var received = "";
|
||||
server.RegisterHandler<CodecMessage>((ctx, message) => { received = message.Text; return UniTask.CompletedTask; });
|
||||
var left = new ShrinkLoopbackTransport(); var right = new ShrinkLoopbackTransport(); left.LinkPeer(right);
|
||||
client.BindTransport(left); server.BindTransport(right); left.OpenSession(1);
|
||||
await client.SendAsync(client.Sessions[1], new CodecMessage { Text = "retained" });
|
||||
Assert.That(received, Is.Empty);
|
||||
await queue.PumpAsync(4);
|
||||
Assert.That(received, Is.EqualTo("retained"));
|
||||
}
|
||||
|
||||
private sealed class CountingSerializer(IShrinkNetworkBufferSerializer inner) : IShrinkNetworkBufferSerializer
|
||||
{
|
||||
public int Count;
|
||||
public void Serialize(IBufferWriter<byte> writer, object value) { Count++; inner.Serialize(writer, value); }
|
||||
public object Deserialize(ReadOnlyMemory<byte> payload, Type type) => inner.Deserialize(payload, type);
|
||||
public byte[] Serialize(object value) => throw new AssertionException("Legacy allocation path used");
|
||||
public object Deserialize(byte[] payload, Type type) => inner.Deserialize(payload, type);
|
||||
public T Deserialize<T>(byte[] payload) => inner.Deserialize<T>(payload);
|
||||
}
|
||||
private sealed class DeferredTransport : IShrinkNetworkMemoryTransport
|
||||
{
|
||||
public bool IsStarted { get; private set; }
|
||||
public event Action<ShrinkNetworkTransportEvent>? OnEvent;
|
||||
public UniTaskCompletionSource Completion = new();
|
||||
public ReadOnlyMemory<byte> Memory;
|
||||
public void Start() => IsStarted = true;
|
||||
public void Stop() => IsStarted = false;
|
||||
public void Connect() => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(1, "test"));
|
||||
public void Send(long id, byte[] data) => throw new AssertionException("Legacy transport used");
|
||||
public UniTask SendAsync(long id, byte[] data) => throw new AssertionException("Legacy transport used");
|
||||
public UniTask SendAsync(long id, ReadOnlyMemory<byte> data, CancellationToken ct = default) { Memory = data; return Completion.Task; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Cysharp.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using ShrinkNetwork;
|
||||
|
||||
public class NetworkQueueTests
|
||||
{
|
||||
[Test]
|
||||
public async Task StateReplacementIsScopedToSessionAndMovesAfterReliableWork()
|
||||
{
|
||||
using var queue = new ShrinkNetworkWorkQueue(10, 100);
|
||||
var output = new List<int>();
|
||||
UniTask Write(int i) { output.Add(i); return UniTask.CompletedTask; }
|
||||
var old = queue.EnqueueAsync(1, "position", 10, () => Write(1), "player");
|
||||
var reliable = queue.EnqueueAsync(1, "position", 10, () => Write(2));
|
||||
var other = queue.EnqueueAsync(2, "position", 10, () => Write(3), "player");
|
||||
var latest = queue.EnqueueAsync(1, "position", 20, () => Write(4), "player");
|
||||
Assert.That(await old, Is.EqualTo(ShrinkNetworkQueueResult.Replaced));
|
||||
Assert.That(queue.CaptureDiagnostics().PendingBytes, Is.EqualTo(40));
|
||||
Assert.That(await queue.PumpAsync(10), Is.EqualTo(3));
|
||||
Assert.That(output, Is.EqualTo(new[] { 2, 3, 4 }));
|
||||
foreach (var result in new[] { await reliable, await other, await latest }) Assert.That(result, Is.EqualTo(ShrinkNetworkQueueResult.Completed));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CapacityRejectionDoesNotLoseExistingReliableWorkOrState()
|
||||
{
|
||||
using var queue = new ShrinkNetworkWorkQueue(2, 20, 1);
|
||||
var a = queue.EnqueueAsync(1, "a", 10, () => UniTask.CompletedTask, "x");
|
||||
Assert.That(await queue.EnqueueAsync(1, "a", 1, () => UniTask.CompletedTask), Is.EqualTo(ShrinkNetworkQueueResult.Rejected));
|
||||
Assert.That(await queue.EnqueueAsync(1, "a", 21, () => UniTask.CompletedTask, "x"), Is.EqualTo(ShrinkNetworkQueueResult.Rejected));
|
||||
await queue.PumpAsync(1);
|
||||
Assert.That(await a, Is.EqualTo(ShrinkNetworkQueueResult.Completed));
|
||||
Assert.That(queue.CaptureDiagnostics().PendingBytes, Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BudgetDoesNotPreemptHandlerAndConcurrentPumpsDoNotRunHandlersInParallel()
|
||||
{
|
||||
using var queue = new ShrinkNetworkWorkQueue(4, 100);
|
||||
var completion = new UniTaskCompletionSource();
|
||||
var first = queue.EnqueueAsync(1, "a", 10, () => completion.Task);
|
||||
var second = queue.EnqueueAsync(1, "a", 10, () => UniTask.CompletedTask);
|
||||
var pump = queue.PumpAsync(4, 100, TimeSpan.FromMilliseconds(1)).AsTask();
|
||||
await Task.Delay(10);
|
||||
Assert.That(pump.IsCompleted, Is.False);
|
||||
Assert.That(await queue.PumpAsync(4), Is.Zero);
|
||||
completion.TrySetResult();
|
||||
Assert.That(await pump, Is.EqualTo(1));
|
||||
Assert.That(await first, Is.EqualTo(ShrinkNetworkQueueResult.Completed));
|
||||
queue.Dispose();
|
||||
Assert.That(await second, Is.EqualTo(ShrinkNetworkQueueResult.Canceled));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ByteBudgetMakesProgressForLargePacketsAndHandlerFailureDoesNotLeakQueue()
|
||||
{
|
||||
using var queue = new ShrinkNetworkWorkQueue(4, 100);
|
||||
var failed = queue.EnqueueAsync(1, "a", 30, () => UniTask.FromException(new IOException("handler"))).AsTask();
|
||||
var next = queue.EnqueueAsync(1, "a", 10, () => UniTask.CompletedTask);
|
||||
Assert.That(await queue.PumpAsync(4, 1), Is.EqualTo(1));
|
||||
Assert.ThrowsAsync<IOException>(async () => await failed);
|
||||
Assert.That(await queue.PumpAsync(4), Is.EqualTo(1));
|
||||
Assert.That(await next, Is.EqualTo(ShrinkNetworkQueueResult.Completed));
|
||||
Assert.That(queue.CaptureDiagnostics().PendingCount, Is.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using ShrinkNetwork;
|
||||
|
||||
[NonParallelizable]
|
||||
public class NetworkTransportTests
|
||||
{
|
||||
[Test]
|
||||
public async Task TcpRpcRoundTripAndCanceledSendPreserveNextFrame()
|
||||
{
|
||||
var reservation = new TcpListener(IPAddress.Loopback, 0); reservation.Start();
|
||||
var port = ((IPEndPoint)reservation.LocalEndpoint).Port; reservation.Stop();
|
||||
var serverTransport = new ShrinkTcpServerTransport(IPAddress.Loopback, port);
|
||||
var clientTransport = new ShrinkTcpClientTransport("127.0.0.1", port);
|
||||
var server = Service(); var client = Service();
|
||||
var connected = new TaskCompletionSource<ShrinkNetworkSession>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.OnSessionConnected += session => connected.TrySetResult(session);
|
||||
server.RegisterRequestHandler<Request, Response>((_, request) => UniTask.FromResult(new Response { Text = request.Text }));
|
||||
try
|
||||
{
|
||||
server.BindTransport(serverTransport); client.BindTransport(clientTransport);
|
||||
var session = await connected.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
|
||||
Assert.CatchAsync<OperationCanceledException>(async () => await clientTransport.SendAsync(1, new ReadOnlyMemory<byte>(new byte[100]), cancellation.Token));
|
||||
var reply = await client.CallAsync<Request, Response>(session, new Request { Text = new string('t', 32768) }).AsTask().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.That(reply.Text.Length, Is.EqualTo(32768));
|
||||
Assert.That(client.GetDiagnosticsSnapshot().RpcCompleted, Is.EqualTo(1));
|
||||
}
|
||||
finally { clientTransport.Stop(); serverTransport.Stop(); }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NestedRpcResponseBypassesSerialHandlerQueue()
|
||||
{
|
||||
var a = new ShrinkLoopbackTransport(); var b = new ShrinkLoopbackTransport(); a.LinkPeer(b);
|
||||
var client = Service(); var server = Service();
|
||||
client.RegisterRequestHandler<Request, Response>((_, req) => UniTask.FromResult(new Response { Text = "nested:" + req.Text }));
|
||||
server.RegisterRequestHandler<Request, Response>(async (ctx, req) => await server.CallAsync<Request, Response>(ctx.Session, req));
|
||||
client.BindTransport(a); server.BindTransport(b); a.OpenSession(1);
|
||||
var response = await client.CallAsync<Request, Response>(client.Sessions[1], new Request { Text = "ok" }).AsTask().WaitAsync(TimeSpan.FromSeconds(3));
|
||||
Assert.That(response.Text, Is.EqualTo("nested:ok"));
|
||||
a.Stop(); b.Stop();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CongestionDisconnectsReliablePeerAndDoesNotResurrectSession()
|
||||
{
|
||||
var a = new ShrinkLoopbackTransport(); var b = new ShrinkLoopbackTransport(); a.LinkPeer(b);
|
||||
using var queue = new ShrinkNetworkDispatchQueue(1, byteCapacity: 1024);
|
||||
var client = Service(); var server = Service(); server.DispatchScheduler = queue;
|
||||
client.BindTransport(a); server.BindTransport(b); a.OpenSession(1);
|
||||
var session = client.Sessions[1];
|
||||
var rejected = 0; server.OnDispatchRejected += _ => rejected++;
|
||||
await client.SendAsync(session, new Request());
|
||||
await client.SendAsync(session, new Request());
|
||||
Assert.That(rejected, Is.EqualTo(1));
|
||||
Assert.That(server.Sessions, Is.Empty);
|
||||
await queue.PumpAsync(1);
|
||||
Assert.That(server.Sessions, Is.Empty);
|
||||
a.Stop(); b.Stop();
|
||||
}
|
||||
private static ShrinkNetworkService Service()
|
||||
{
|
||||
var service = new ShrinkNetworkService(); service.RegisterMessage<Request>(1); service.RegisterMessage<Response>(2); return service;
|
||||
}
|
||||
public sealed class Request : IShrinkNetworkRequest { public string Text { get; set; } = ""; }
|
||||
public sealed class Response : ShrinkRpcResponseBase { public string Text { get; set; } = ""; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||
<PackageReference Include="UniTask" Version="2.5.10" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Kcp-CSharp" Version="1.0.8" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.8" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkNetwork/Adapters~/MessagePack/Runtime/**/*.cs" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkRuntime.Abstractions/Runtime/**/*.cs" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkContext.Core/Runtime/**/*.cs" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkContext.Core/Tests/**/*.cs" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkNetwork/Runtime/**/*.cs" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkEventBus/Runtime/**/*.cs" Exclude="../../../Assets/Modules/ShrinkEventBus/Runtime/ShrinkMonoEventScope.cs" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkCommand/Runtime/**/*.cs" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkCommand.Integration.Network/ShrinkNetworkCommand*.cs" />
|
||||
<Compile Include="../../../Assets/ShrinkInspection/Runtime/**/*.cs" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkDataSaver/Runtime/*.cs" Exclude="../../../Assets/Modules/ShrinkDataSaver/Runtime/ShrinkDataSaverRuntime.cs;../../../Assets/Modules/ShrinkDataSaver/Runtime/ShrinkDataSaverSettings.cs;../../../Assets/Modules/ShrinkDataSaver/Runtime/ShrinkDataSaverBootstrap.cs;../../../Assets/Modules/ShrinkDataSaver/Runtime/ShrinkModuleRegistrar.cs" />
|
||||
<Compile Include="../../../Assets/Modules/ShrinkDataSaver/DotNet~/Runtime/*.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,79 @@
|
||||
using NUnit.Framework;
|
||||
using ShrinkDataSaver;
|
||||
using ShrinkSDK.Inspection;
|
||||
using ShrinkNetwork;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
[NonParallelizable]
|
||||
public class SaveSnapshotTests
|
||||
{
|
||||
private string _directory = null!;
|
||||
[SetUp] public void Setup()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "ShrinkSaveTests", Guid.NewGuid().ToString("N"));
|
||||
ShrinkSave.Initialize(new LocalStorageProvider(_directory), "saves", ".json", 1);
|
||||
}
|
||||
[TearDown] public void Cleanup()
|
||||
{
|
||||
ShrinkSave.UnregisterModule("test-snapshot");
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, true);
|
||||
}
|
||||
|
||||
[TestCase(false)] [TestCase(true)]
|
||||
public async Task CaptureRunsOnCallerAndWorkerEncodingPreservesFormat(bool encrypt)
|
||||
{
|
||||
var owner = Environment.CurrentManagedThreadId;
|
||||
var captured = 0;
|
||||
var value = new SaveDto { Value = "original" };
|
||||
ShrinkSave.RegisterModule("test-snapshot", () => { captured = Environment.CurrentManagedThreadId; return value; }, _ => { });
|
||||
var options = new SaveOptions { EncodeInBackground = true, CollectTimings = true, Encrypt = encrypt, EncryptionKey = "test-only" };
|
||||
var saving = ShrinkSave.SaveSlotAsync(0, options).AsTask();
|
||||
value.Value = "changed-after-capture";
|
||||
await saving;
|
||||
var file = Directory.GetFiles(Path.Combine(_directory, "saves"), "*.json").Single();
|
||||
var packet = DataSerializer.Deserialize<SavePacket>(await File.ReadAllBytesAsync(file));
|
||||
var text = encrypt ? System.Text.Encoding.UTF8.GetString(SaveEncryptor.Decrypt(Convert.FromBase64String(packet.EncryptedModules), "test-only")) : packet.Modules["test-snapshot"].ToString();
|
||||
Assert.That(captured, Is.EqualTo(owner));
|
||||
Assert.That(text, Does.Contain("original").And.Not.Contain("changed-after-capture"));
|
||||
Assert.That(options.LastTimings.EncodedBytes, Is.GreaterThan(0));
|
||||
Assert.That(options.LastTimings.CaptureMilliseconds, Is.GreaterThanOrEqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CancellationLeavesExistingFileAndBackupsRecoverCorruption()
|
||||
{
|
||||
var storage = new LocalStorageProvider(_directory);
|
||||
await storage.WriteAsync("slot.json", new byte[] { 1 });
|
||||
await storage.WriteAsync("slot.json", new byte[] { 2 });
|
||||
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
|
||||
Assert.CatchAsync<OperationCanceledException>(async () => await storage.WriteAsync("slot.json", new byte[] { 3 }, cancellation.Token));
|
||||
Assert.That(await storage.ReadAsync("slot.json"), Is.EqualTo(new byte[] { 2 }));
|
||||
Assert.That(await storage.ReadAsync("slot.json.bak1"), Is.EqualTo(new byte[] { 1 }));
|
||||
Assert.That(File.Exists(Path.Combine(_directory, "slot.json.tmp")), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CorruptPrimarySaveFallsBackToPreviousValidPacket()
|
||||
{
|
||||
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "previous" });
|
||||
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "latest" });
|
||||
var primary = Directory.GetFiles(Path.Combine(_directory, "saves"), "*.json").Single();
|
||||
await File.WriteAllTextAsync(primary, "corrupt");
|
||||
Assert.That((await ShrinkSave.GetMetaAsync(0)).SlotName, Is.EqualTo("previous"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RuntimeSnapshotExportsBindingsWithoutSessionSecrets()
|
||||
{
|
||||
var service = new ShrinkNetworkService(); service.RegisterMessage<CodecMessage>(42, "test/message");
|
||||
service.RegisterHandler<CodecMessage>(Receive);
|
||||
var snapshot = new ShrinkInspectionSnapshot("build-a");
|
||||
snapshot.AddNetwork("server", service);
|
||||
var json = snapshot.ToJson();
|
||||
Assert.That(snapshot.Relations.Any(r => r.From == "opcode:42" && r.To == "type:CodecMessage"));
|
||||
Assert.That(snapshot.Relations.Any(r => r.To == "M:SaveSnapshotTests.Receive(ShrinkNetwork.ShrinkNetworkContext,CodecMessage)"));
|
||||
Assert.That(json, Does.Not.Contain("Payload").And.Not.Contain("SessionToken"));
|
||||
}
|
||||
private static UniTask Receive(ShrinkNetworkContext ctx, CodecMessage message) => UniTask.CompletedTask;
|
||||
public sealed class SaveDto { public string Value { get; set; } = ""; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using NUnit.Framework;
|
||||
using ShrinkSDK.CodeGen.Analyzers;
|
||||
|
||||
public class AnalyzerTests
|
||||
{
|
||||
private static readonly MetadataReference[] SystemReferences = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator).Select(p => MetadataReference.CreateFromFile(p)).ToArray();
|
||||
private static CSharpCompilation Compile(string name, string source, params MetadataReference[] references) => CSharpCompilation.Create(name, [CSharpSyntaxTree.ParseText(source)], SystemReferences.Concat(references), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
private static MetadataReference Emit(CSharpCompilation compilation)
|
||||
{
|
||||
using var stream = new MemoryStream(); var result = compilation.Emit(stream);
|
||||
Assert.That(result.Success, Is.True, string.Join(";", result.Diagnostics));
|
||||
return MetadataReference.CreateFromImage(stream.ToArray());
|
||||
}
|
||||
[Test] public async Task DuplicateContractsAcrossReferencedAssembliesAreErrors()
|
||||
{
|
||||
var contract = Emit(Compile("Contract", "namespace ShrinkNetwork { public class ShrinkNetworkMessageAttribute : System.Attribute { public ShrinkNetworkMessageAttribute(int opcode, string route) {} } }"));
|
||||
var a = Emit(Compile("A", "[ShrinkNetwork.ShrinkNetworkMessage(1, \"one\")] public class A {}".Replace("\\", ""), contract));
|
||||
var b = Emit(Compile("B", "[ShrinkNetwork.ShrinkNetworkMessage(1, \"two\")] public class B {}".Replace("\\", ""), contract));
|
||||
var diagnostics = await Compile("Consumer", "class Consumer {}", contract, a, b).WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(new ShrinkCodeGenAnalyzer())).GetAnalyzerDiagnosticsAsync();
|
||||
Assert.That(diagnostics.Count(d => d.Id == "SHRINK002"), Is.EqualTo(1));
|
||||
}
|
||||
[Test] public async Task DefiniteKeyAndSubscriptionErrorsHaveStableCodes()
|
||||
{
|
||||
var compilation = Compile("Consumer", """
|
||||
namespace ShrinkContext { public class ShrinkKey<T> { public ShrinkKey(string package, string name, int major) {} } }
|
||||
namespace ShrinkEventBus { public interface IShrinkEvent {} public class ShrinkSubscribeAttribute : System.Attribute {} }
|
||||
class Consumer { object key = new ShrinkContext.ShrinkKey<int>("", "rage", 0); [ShrinkEventBus.ShrinkSubscribe] void On(int wrong) {} }
|
||||
""");
|
||||
var diagnostics = await compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(new ShrinkCodeGenAnalyzer())).GetAnalyzerDiagnosticsAsync();
|
||||
Assert.That(diagnostics.Count(d => d.Id == "SHRINK004"), Is.EqualTo(2));
|
||||
Assert.That(diagnostics.Count(d => d.Id == "SHRINK001"), Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
using ShrinkSDK.Inspect;
|
||||
using Index = ShrinkSDK.Inspect.Index;
|
||||
|
||||
public class IdentityTests
|
||||
{
|
||||
[TestCase("a", true)] [TestCase("b", false)] [TestCase("", false)]
|
||||
public void SnapshotNeedsMatchingVerifiedBuild(string build, bool accepted)
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, "{\"BuildId\":\"a\",\"Relations\":[{\"From\":\"type:Request\",\"To\":\"M:Handler.Run\",\"Kind\":\"handler\",\"Scope\":\"server\"}]}");
|
||||
var index = new Index { BuildId = build };
|
||||
InspectCommand.AttachSnapshot(index, path);
|
||||
Assert.That(index.Relations.Count, Is.EqualTo(accepted ? 1 : 0));
|
||||
if (accepted) Assert.That(index.Relations[0].Evidence, Is.EqualTo("runtime-bound"));
|
||||
else Assert.That(index.Coverage, Is.Not.Empty);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ChangedSourceInvalidatesRuntimeMerge()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ShrinkInspectIdentity", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var source = Path.Combine(dir, "A.cs"); File.WriteAllText(source, "class A {}");
|
||||
var manifest = new CompilationManifest { Root = dir, BuildId = "a", Assemblies = [new() { Name = "A", Sources = [source] }], SourceTicks = new() { [source] = 0 } };
|
||||
var path = Path.Combine(dir, "compilation.json"); File.WriteAllText(path, JsonSerializer.Serialize(manifest));
|
||||
var index = new Index { Root = dir };
|
||||
await SemanticIndex.Build(index, path, null);
|
||||
Assert.That(index.BuildId, Is.Empty);
|
||||
Assert.That(index.Coverage.Any(c => c.Contains("Stale")));
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
}
|
||||
[Test]
|
||||
public void MatchingSnapshotExposesBoundedStateItems()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, """{"BuildId":"a","State":{"app":{"Fibers":[{"Name":"wait","Status":"Waiting"},{"Name":"ready","Status":"Active"}]}}} """);
|
||||
var index = new Index { BuildId = "a" }; InspectCommand.AttachSnapshot(index, path);
|
||||
Assert.That(index.RuntimeStates.Count, Is.EqualTo(2));
|
||||
Assert.That(index.RuntimeStates[0].Value.GetProperty("Name").GetString(), Is.EqualTo("wait"));
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using NUnit.Framework;
|
||||
using ShrinkSDK.Inspect;
|
||||
using Index = ShrinkSDK.Inspect.Index;
|
||||
|
||||
public class NavigationTests
|
||||
{
|
||||
private string _root = null!;
|
||||
[SetUp] public void SetUp() { _root = Path.Combine(Path.GetTempPath(), "ShrinkInspectTests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); }
|
||||
[TearDown] public void TearDown() => Directory.Delete(_root, true);
|
||||
|
||||
[Test] public void ExactOverloadsAndVirtualCandidatesStayDistinct()
|
||||
{
|
||||
var index = Analyze("public class A { public void Start() { Call(1); } public void Call(int x) {} public void Call(string x) {} public virtual void Tick() {} public void Run() { Tick(); } }");
|
||||
Assert.That(index.Relations.Any(r => r.From == "M:A.Start" && r.To == "M:A.Call(System.Int32)" && r.Evidence == "static"));
|
||||
Assert.That(index.Relations.Any(r => r.To == "M:A.Call(System.String)"), Is.False);
|
||||
Assert.That(index.Relations.Any(r => r.To == "M:A.Tick" && r.Evidence == "candidate"));
|
||||
}
|
||||
|
||||
[Test] public void UnresolvedCallIsNotInventedAsStatic()
|
||||
{
|
||||
var index = Analyze("class A { void Run() { Missing.Go(); } }");
|
||||
Assert.That(index.Relations.Any(r => r.To == "Missing.Go" && r.Evidence == "unresolved"));
|
||||
Assert.That(index.Coverage, Is.Not.Empty);
|
||||
}
|
||||
|
||||
[Test] public void FrameworkEventEdgesBridgePublisherAndSubscriber()
|
||||
{
|
||||
var index = Analyze("""
|
||||
using System;
|
||||
namespace ShrinkEventBus { public class ShrinkSubscribeAttribute : Attribute {} public static class EventBus { public static void Post<T>(T value) {} } }
|
||||
public struct Killed {}
|
||||
public class Combat { public void Kill() { ShrinkEventBus.EventBus.Post(new Killed()); } }
|
||||
public class Rage { [ShrinkEventBus.ShrinkSubscribe] public void Gain(Killed value) {} }
|
||||
""");
|
||||
var edges = InspectCommand.Walk(index, "M:Combat.Kill", 2, null).ToArray();
|
||||
Assert.That(edges.Any(e => e.Kind == "publishes" && e.To == "type:Killed"));
|
||||
Assert.That(edges.Any(e => e.Kind == "subscribes" && e.To == "M:Rage.Gain(Killed)" && e.Evidence == "candidate"));
|
||||
}
|
||||
|
||||
[Test] public void OpcodeAndRouteUniquenessAreIndependent()
|
||||
{
|
||||
var index = Analyze("""
|
||||
using System;
|
||||
namespace ShrinkNetwork { public class ShrinkNetworkMessageAttribute : Attribute { public ShrinkNetworkMessageAttribute(int id, string route) {} } }
|
||||
[ShrinkNetwork.ShrinkNetworkMessage(1,"a")] class A {}
|
||||
[ShrinkNetwork.ShrinkNetworkMessage(1,"b")] class B {}
|
||||
[ShrinkNetwork.ShrinkNetworkMessage(2,"a")] class C {}
|
||||
""");
|
||||
SemanticIndex.CheckRelations(index);
|
||||
Assert.That(index.Findings.Any(f => f.Message.Contains("opcode:1")));
|
||||
Assert.That(index.Findings.Any(f => f.Message.Contains("route:a")));
|
||||
}
|
||||
|
||||
[Test] public void CapabilityMatchesDoNotImplyMissingAbilitiesAreAbsent()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, "Inventory"));
|
||||
File.WriteAllText(Path.Combine(_root, "Inventory", "inventory.asmdef"), "{\"name\":\"Inventory\"}");
|
||||
File.WriteAllText(Path.Combine(_root, "Inventory", "README.md"), """
|
||||
<!-- shrink:capabilities -->
|
||||
Capability: 配置物品容量
|
||||
Aliases: 背包 inventory 容量
|
||||
Limits: 不负责装备属性
|
||||
Extension: SlotOptions
|
||||
Evidence: Inventory.cs
|
||||
<!-- /shrink:capabilities -->
|
||||
""");
|
||||
Directory.CreateDirectory(Path.Combine(_root, "Combat"));
|
||||
File.WriteAllText(Path.Combine(_root, "Combat", "combat.asmdef"), "{\"name\":\"Combat\"}");
|
||||
var index = new Index { Root = _root }; ModuleCatalog.Load(index);
|
||||
Assert.That(ModuleCatalog.Search(index, "背包", null).Single().Limits, Is.EqualTo("不负责装备属性"));
|
||||
Assert.That(index.Modules.Single(m => m.Name == "Combat").SemanticDescriptionComplete, Is.False);
|
||||
Assert.That(ModuleCatalog.Search(index, "怒气", null), Is.Empty);
|
||||
}
|
||||
|
||||
private Index Analyze(string code)
|
||||
{
|
||||
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator).Select(p => MetadataReference.CreateFromFile(p));
|
||||
var compilation = CSharpCompilation.Create("Fixture", [CSharpSyntaxTree.ParseText(code, path: Path.Combine(_root, "Fixture.cs"))], references,
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
var index = new Index { Root = _root }; SemanticIndex.Analyze(index, compilation); return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0</TargetFramework><IsPackable>false</IsPackable></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Shrink.Inspect/Shrink.Inspect.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace ShrinkSDK.Inspect;
|
||||
|
||||
public sealed record SourceLocation(string File, int Line);
|
||||
public sealed record SymbolInfo(string Id, string Name, string Module, string Kind, SourceLocation Location);
|
||||
public sealed record Relation(string From, string To, string Kind, string Evidence, SourceLocation Location);
|
||||
public sealed record RuntimeState(string Scope, string Name, System.Text.Json.JsonElement Value, SourceLocation Location);
|
||||
public sealed record Finding(string Code, string Severity, string Message, SourceLocation Location);
|
||||
public sealed record Capability(string Module, string Summary, string Aliases, string Limits, string Extension,
|
||||
string Evidence, SourceLocation Location, int Score = 0, string MatchReason = "");
|
||||
public sealed record ModuleInfo(string Name, string Root, string Version, string[] Dependencies,
|
||||
string[] Assemblies, string[] Validation, bool SemanticDescriptionComplete);
|
||||
public sealed class CompilationInput
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string[] Sources { get; set; } = [];
|
||||
public string[] References { get; set; } = [];
|
||||
public string[] Defines { get; set; } = [];
|
||||
public string Output { get; set; } = "";
|
||||
}
|
||||
public sealed class CompilationManifest
|
||||
{
|
||||
public string Root { get; set; } = "";
|
||||
public string BuildId { get; set; } = "";
|
||||
public CompilationInput[] Assemblies { get; set; } = [];
|
||||
public Dictionary<string, long> SourceTicks { get; set; } = new();
|
||||
}
|
||||
public sealed class Index
|
||||
{
|
||||
public string Root { get; set; } = "";
|
||||
public string BuildId { get; set; } = "";
|
||||
public List<string> Coverage { get; } = [];
|
||||
public List<ModuleInfo> Modules { get; } = [];
|
||||
public List<Capability> Capabilities { get; } = [];
|
||||
public Dictionary<string, SymbolInfo> Symbols { get; } = new(StringComparer.Ordinal);
|
||||
public List<Relation> Relations { get; } = [];
|
||||
public List<RuntimeState> RuntimeStates { get; } = [];
|
||||
public List<Finding> Findings { get; } = [];
|
||||
public string ModuleFor(string file) => Modules.Where(m => file.StartsWith(m.Root + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase)).OrderByDescending(m => m.Root.Length).FirstOrDefault()?.Name ?? "consumer";
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ShrinkSDK.Inspect;
|
||||
|
||||
public static class ModuleCatalog
|
||||
{
|
||||
private static readonly HashSet<string> Excluded = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".git", "Library", "Temp", "obj", "bin", "Logs", "Builds", "Artifacts", "node_modules", "Development~", "Samples~" };
|
||||
|
||||
public static IEnumerable<string> Files(string root, string pattern)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(root, pattern)) yield return file;
|
||||
foreach (var dir in Directory.EnumerateDirectories(root))
|
||||
{
|
||||
if (Excluded.Contains(Path.GetFileName(dir)) || (File.GetAttributes(dir) & FileAttributes.ReparsePoint) != 0) continue;
|
||||
foreach (var file in Files(dir, pattern)) yield return file;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Load(Index index)
|
||||
{
|
||||
var installed = Path.Combine(index.Root, "Library", "PackageCache");
|
||||
var scanRoots = new List<string> { index.Root };
|
||||
if (Directory.Exists(installed)) scanRoots.AddRange(Directory.GetDirectories(installed, "com.cneicy.shrink-*@*"));
|
||||
var packages = scanRoots.SelectMany(r => Files(r, "package.json")).Select(path => (path, doc: Read(path)))
|
||||
.Where(x => x.doc.TryGetProperty("name", out _)).ToArray();
|
||||
var asmdefs = scanRoots.SelectMany(r => Files(r, "*.asmdef")).ToArray();
|
||||
var roots = packages.Select(p => Path.GetDirectoryName(p.path)!).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var path in asmdefs)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path)!;
|
||||
if (!roots.Any(root => dir.Equals(root, StringComparison.OrdinalIgnoreCase) || dir.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) roots.Add(dir);
|
||||
}
|
||||
// Business directories can share an asmdef while retaining separate ownership.
|
||||
foreach (var readme in scanRoots.SelectMany(r => Files(r, "README.md")))
|
||||
if (File.ReadAllText(readme).Contains("<!-- shrink:capabilities -->", StringComparison.Ordinal)) roots.Add(Path.GetDirectoryName(readme)!);
|
||||
|
||||
foreach (var root in roots.Order(StringComparer.Ordinal))
|
||||
{
|
||||
var package = packages.FirstOrDefault(p => Path.GetDirectoryName(p.path) == root);
|
||||
var name = package.doc.ValueKind == JsonValueKind.Object ? String(package.doc, "name") : Path.GetFileName(root);
|
||||
var version = package.doc.ValueKind == JsonValueKind.Object ? String(package.doc, "version") : "unversioned";
|
||||
var dependencies = package.doc.ValueKind == JsonValueKind.Object && package.doc.TryGetProperty("dependencies", out var deps)
|
||||
? deps.EnumerateObject().Select(p => p.Name + "@" + p.Value.GetString()).ToArray() : [];
|
||||
var assemblies = asmdefs.Where(p => p.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => String(Read(p), "name")).ToArray();
|
||||
var readme = Path.Combine(root, "README.md");
|
||||
var complete = File.Exists(readme) && ReadCapabilities(name, readme, index.Capabilities);
|
||||
var validation = Files(root, "*.cs").Where(p => p.Contains("Tests", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => Path.GetRelativePath(index.Root, p)).Take(8).ToArray();
|
||||
index.Modules.Add(new(name, root, version, dependencies, assemblies, validation, complete));
|
||||
}
|
||||
foreach (var module in index.Modules)
|
||||
foreach (var dep in module.Dependencies)
|
||||
{
|
||||
var split = dep.LastIndexOf('@');
|
||||
if (split <= 0) continue;
|
||||
var target = index.Modules.FirstOrDefault(m => m.Name == dep[..split]);
|
||||
if (target == null) continue;
|
||||
if (target.Version != dep[(split + 1)..]) index.Findings.Add(new("SHRINK101", "error", $"{module.Name} requires {dep}, workspace has {target.Version}", new(Path.Combine(module.Root, "package.json"), 1)));
|
||||
if (!module.Name.Contains("integration") && !module.Name.Contains("starter") && target.Name.Contains("-integration-"))
|
||||
index.Findings.Add(new("SHRINK102", "error", $"Main package {module.Name} depends on integration {target.Name}.", new(Path.Combine(module.Root, "package.json"), 1)));
|
||||
}
|
||||
foreach (var module in index.Modules)
|
||||
Visit(module, new HashSet<string>(), new HashSet<string>());
|
||||
void Visit(ModuleInfo current, HashSet<string> path, HashSet<string> seen)
|
||||
{
|
||||
if (!path.Add(current.Name)) { index.Findings.Add(new("SHRINK103", "error", "Package dependency cycle: " + string.Join(" -> ", path) + " -> " + current.Name, new(Path.Combine(current.Root, "package.json"), 1))); return; }
|
||||
if (seen.Add(current.Name))
|
||||
foreach (var dep in current.Dependencies)
|
||||
{
|
||||
var target = index.Modules.FirstOrDefault(m => dep.StartsWith(m.Name + "@", StringComparison.Ordinal));
|
||||
if (target != null) Visit(target, path, seen);
|
||||
}
|
||||
path.Remove(current.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ReadCapabilities(string module, string path, List<Capability> output)
|
||||
{
|
||||
var lines = File.ReadAllLines(path);
|
||||
var start = Array.IndexOf(lines, "<!-- shrink:capabilities -->");
|
||||
var end = Array.IndexOf(lines, "<!-- /shrink:capabilities -->");
|
||||
if (start < 0 || end <= start) return false;
|
||||
string Field(string key) => lines.Skip(start + 1).Take(end - start - 1)
|
||||
.FirstOrDefault(s => s.StartsWith(key + ":", StringComparison.Ordinal))?[(key.Length + 1)..].Trim() ?? "";
|
||||
output.Add(new(module, Field("Capability"), Field("Aliases"), Field("Limits"), Field("Extension"), Field("Evidence"), new(path, start + 1)));
|
||||
return new[] { "Capability", "Limits", "Extension", "Evidence" }.All(key => Field(key).Length > 0);
|
||||
}
|
||||
|
||||
public static IEnumerable<Capability> Search(Index index, string query, string? module)
|
||||
{
|
||||
var terms = Regex.Split(query.Trim(), @"[\s,,;;/]+").Where(t => t.Length > 0).ToArray();
|
||||
return index.Capabilities.Where(c => module == null || c.Module.Contains(module, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(c => {
|
||||
var text = c.Module + " " + c.Summary + " " + c.Aliases;
|
||||
var matches = terms.Where(t => text.Contains(t, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
return c with { Score = matches.Length, MatchReason = string.Join(", ", matches) };
|
||||
}).Where(c => terms.Length == 0 || c.Score > 0).OrderByDescending(c => c.Score).ThenBy(c => c.Module, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static JsonElement Read(string path) { using var doc = JsonDocument.Parse(File.ReadAllText(path)); return doc.RootElement.Clone(); }
|
||||
private static string String(JsonElement e, string name) => e.TryGetProperty(name, out var p) ? p.GetString() ?? "" : "";
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using ShrinkSDK.Inspect;
|
||||
using Index = ShrinkSDK.Inspect.Index;
|
||||
|
||||
return await InspectCommand.Run(args);
|
||||
|
||||
public static class InspectCommand
|
||||
{
|
||||
public static async Task<int> Run(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length < 2 || args[0] != "inspect")
|
||||
{
|
||||
Console.WriteLine("shrink inspect modules|capabilities|symbol|flow|impact|check [query] [--root dir] [--module name] [--limit n] [--depth n] [--json] [--compilation file|--project csproj] [--snapshot file]");
|
||||
return args.Length == 0 ? 0 : 2;
|
||||
}
|
||||
var options = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var words = new List<string>();
|
||||
for (var i = 2; i < args.Length; i++)
|
||||
{
|
||||
if (args[i] == "--json") options["json"] = "true";
|
||||
else if (args[i].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
var key = args[i][2..];
|
||||
if (key is not ("root" or "module" or "limit" or "offset" or "depth" or "compilation" or "project" or "snapshot")) throw new ArgumentException("Unknown option --" + key);
|
||||
if (++i >= args.Length) throw new ArgumentException("Missing value for --" + key);
|
||||
options[key] = args[i];
|
||||
}
|
||||
else words.Add(args[i]);
|
||||
}
|
||||
string? Option(string key) => options.GetValueOrDefault(key);
|
||||
var command = args[1];
|
||||
if (command is not ("modules" or "capabilities" or "symbol" or "flow" or "impact" or "check")) throw new ArgumentException("Unknown query " + command);
|
||||
var query = string.Join(" ", words);
|
||||
if (command is "symbol" or "flow" or "impact" && query.Length == 0) throw new ArgumentException("An exact symbol, route, key or file query is required.");
|
||||
var limit = int.Parse(Option("limit") ?? (command == "capabilities" ? "3" : "20"));
|
||||
var offset = int.Parse(Option("offset") ?? "0");
|
||||
var depth = int.Parse(Option("depth") ?? "2");
|
||||
if (limit is < 1 or > 1000 || depth is < 0 or > 10 || offset < 0) throw new ArgumentException("limit must be 1..1000; depth must be 0..10; offset must be nonnegative.");
|
||||
var index = new Index { Root = Path.GetFullPath(Option("root") ?? Environment.CurrentDirectory) };
|
||||
if (!Directory.Exists(index.Root)) throw new DirectoryNotFoundException(index.Root);
|
||||
if (Option("project") != null && Option("compilation") != null) throw new ArgumentException("Choose --project or --compilation, not both.");
|
||||
ModuleCatalog.Load(index);
|
||||
if (command is not ("modules" or "capabilities"))
|
||||
{
|
||||
await SemanticIndex.Build(index, Option("compilation"), Option("project"), checkRules: command == "check");
|
||||
index.Relations.RemoveAll(r => string.IsNullOrEmpty(r.From) || string.IsNullOrEmpty(r.To));
|
||||
var unique = index.Relations.Distinct().ToArray();
|
||||
index.Relations.Clear(); index.Relations.AddRange(unique);
|
||||
SemanticIndex.CheckRelations(index);
|
||||
}
|
||||
if (Option("snapshot") is { } snapshot) AttachSnapshot(index, snapshot);
|
||||
var moduleFilter = Option("module");
|
||||
var stateQuery = query.StartsWith("key:", StringComparison.Ordinal) ? query[4..] : query;
|
||||
bool MatchModule(string module) => moduleFilter == null || module.Contains(moduleFilter, StringComparison.OrdinalIgnoreCase);
|
||||
object[] all = command switch
|
||||
{
|
||||
"modules" => index.Modules.Where(m => MatchModule(m.Name)).Cast<object>().ToArray(),
|
||||
"capabilities" => ModuleCatalog.Search(index, query, moduleFilter).Cast<object>().ToArray(),
|
||||
"symbol" => index.Symbols.Values.Where(s => MatchModule(s.Module) && (s.Id.Contains(query, StringComparison.OrdinalIgnoreCase) || s.Name.Contains(query, StringComparison.OrdinalIgnoreCase))).OrderBy(s => s.Id).Cast<object>().Concat(Walk(index, query, 1, moduleFilter)).ToArray(),
|
||||
"flow" or "impact" => Walk(index, query, depth, moduleFilter).Cast<object>().Concat(index.RuntimeStates.Where(s => s.Scope.Contains(query, StringComparison.OrdinalIgnoreCase) || s.Value.ToString().Contains(stateQuery, StringComparison.OrdinalIgnoreCase))).ToArray(),
|
||||
"check" => index.Findings.Distinct().Cast<object>().ToArray(),
|
||||
_ => []
|
||||
};
|
||||
var missingDescriptions = index.Modules.Where(m => MatchModule(m.Name) && !m.SemanticDescriptionComplete).Select(m => m.Name).ToArray();
|
||||
var result = new {
|
||||
success = true, formatVersion = 1, toolVersion = typeof(InspectCommand).Assembly.GetName().Version?.ToString(), command, query, buildId = index.BuildId,
|
||||
offset, limit, nextOffset = all.Length > offset + limit ? (int?)(offset + limit) : null,
|
||||
coverage = index.Coverage.Distinct().Take(12), coverageTruncated = index.Coverage.Distinct().Count() > 12,
|
||||
semanticCoverage = new { incompleteCount = missingDescriptions.Length, examples = missingDescriptions.Take(5) },
|
||||
validation = command == "impact" ? index.Modules.Where(m => all.OfType<Relation>().Any(r => index.ModuleFor(r.Location.File) == m.Name)).SelectMany(m => m.Validation).Distinct().Take(8).ToArray() : Array.Empty<string>(),
|
||||
total = all.Length, truncated = all.Length > offset + limit, items = all.Skip(offset).Take(limit)
|
||||
};
|
||||
if (options.ContainsKey("json")) Console.WriteLine(JsonSerializer.Serialize(result, SemanticIndex.Json));
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"# {command}: {query}\n\nShowing {Math.Min(limit, Math.Max(0, all.Length - offset))}/{all.Length}." + (all.Length > limit ? " Truncated; narrow the query or continue with --offset." : ""));
|
||||
if (missingDescriptions.Length > 0) Console.WriteLine($"\nSemantic descriptions incomplete: {missingDescriptions.Length} modules. No match is not proof of absence.");
|
||||
foreach (var warning in index.Coverage.Distinct().Take(12)) Console.WriteLine("\n> " + warning);
|
||||
foreach (var item in all.Skip(offset).Take(limit)) Render(item);
|
||||
}
|
||||
return command == "check" && index.Findings.Any(f => f.Severity == "error") ? 1 : 0;
|
||||
}
|
||||
catch (Exception e) when (e is ArgumentException or IOException or JsonException or InvalidOperationException or FormatException or OverflowException)
|
||||
{
|
||||
if (args.Contains("--json")) Console.WriteLine(JsonSerializer.Serialize(new { success = false, code = "SHRINK-INSPECT-INPUT", error = e.Message }, SemanticIndex.Json));
|
||||
else Console.Error.WriteLine("SHRINK-INSPECT-INPUT: " + e.Message);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<Relation> Walk(Index index, string query, int depth, string? module)
|
||||
{
|
||||
var ids = index.Symbols.Values.Where(s =>
|
||||
(s.Id.Contains(query, StringComparison.OrdinalIgnoreCase) || s.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || s.Location.File.Contains(query, StringComparison.OrdinalIgnoreCase))).Select(s => s.Id).ToHashSet();
|
||||
foreach (var edge in index.Relations)
|
||||
foreach (var endpoint in new[] { edge.From, edge.To })
|
||||
if (endpoint.Contains(query, StringComparison.OrdinalIgnoreCase)) ids.Add(endpoint);
|
||||
var seen = new HashSet<Relation>();
|
||||
for (var step = 0; step < depth; step++)
|
||||
{
|
||||
var next = new HashSet<string>();
|
||||
foreach (var edge in index.Relations.Where(r => ids.Contains(r.From) || ids.Contains(r.To)))
|
||||
if (seen.Add(edge)) { // External utility methods do not connect every unrelated caller into one flow.
|
||||
foreach (var endpoint in new[] { edge.From, edge.To })
|
||||
if (index.Symbols.ContainsKey(endpoint) || !endpoint.StartsWith("M:", StringComparison.Ordinal)) next.Add(endpoint);
|
||||
if (module == null || index.ModuleFor(edge.Location.File).Contains(module, StringComparison.OrdinalIgnoreCase) ||
|
||||
new[] { edge.From, edge.To }.Any(id => index.Symbols.TryGetValue(id, out var symbol) && symbol.Module.Contains(module, StringComparison.OrdinalIgnoreCase))) yield return edge; }
|
||||
ids.UnionWith(next);
|
||||
}
|
||||
}
|
||||
|
||||
public static void AttachSnapshot(Index index, string path)
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var root = document.RootElement;
|
||||
var identity = root.TryGetProperty("BuildId", out var b) ? b.GetString() : null;
|
||||
if (string.IsNullOrEmpty(index.BuildId) || identity != index.BuildId)
|
||||
{ index.Coverage.Add("Runtime snapshot build mismatch or unverified source identity. Runtime relations were NOT merged."); return; }
|
||||
if (root.TryGetProperty("Coverage", out var coverage))
|
||||
foreach (var item in coverage.EnumerateArray()) index.Coverage.Add("Snapshot: " + item.GetString());
|
||||
if (root.TryGetProperty("State", out var state))
|
||||
foreach (var scope in state.EnumerateObject())
|
||||
foreach (var field in scope.Value.EnumerateObject())
|
||||
if (field.Value.ValueKind == JsonValueKind.Array)
|
||||
foreach (var entry in field.Value.EnumerateArray()) index.RuntimeStates.Add(new(scope.Name, field.Name, entry.Clone(), new(Path.GetFullPath(path), 1)));
|
||||
else index.RuntimeStates.Add(new(scope.Name, field.Name, field.Value.Clone(), new(Path.GetFullPath(path), 1)));
|
||||
if (!root.TryGetProperty("Relations", out var relations)) return;
|
||||
foreach (var relation in relations.EnumerateArray())
|
||||
{
|
||||
var from = relation.GetProperty("From").GetString()!;
|
||||
var to = relation.GetProperty("To").GetString()!;
|
||||
var kind = relation.GetProperty("Kind").GetString()!;
|
||||
var scope = relation.TryGetProperty("Scope", out var scopeElement) ? scopeElement.GetString() : "";
|
||||
index.Relations.Add(new(from, to, kind + " [" + scope + "]", "runtime-bound", new(Path.GetFullPath(path), 1)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void Render(object item)
|
||||
{
|
||||
string Link(SourceLocation loc) => $"[{Path.GetFileName(loc.File)}:{loc.Line}]({loc.File.Replace('\\', '/')}:{loc.Line})";
|
||||
Console.WriteLine(item switch {
|
||||
Capability c => $"\n- **{c.Module}** — {c.Summary}\n Match: {c.MatchReason}\n Limits: {c.Limits}\n Extension: {c.Extension}\n Evidence: {c.Evidence}; {Link(c.Location)}",
|
||||
ModuleInfo m => $"\n- **{m.Name} {m.Version}** — {m.Root}\n Dependencies: {string.Join(", ", m.Dependencies)}\n Validation: {string.Join(", ", m.Validation)}",
|
||||
SymbolInfo s => $"\n- `{s.Id}` ({s.Module}) — {Link(s.Location)}",
|
||||
Relation r => $"\n- `{r.From}` → `{r.To}` [{r.Kind}; {r.Evidence}] — {Link(r.Location)}",
|
||||
RuntimeState s => $"\n- Runtime `{s.Scope}/{s.Name}`: {s.Value} — {Link(s.Location)}",
|
||||
Finding f => $"\n- {f.Severity} **{f.Code}** {f.Message} — {Link(f.Location)}",
|
||||
_ => "\n" + JsonSerializer.Serialize(item)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Build.Locator;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.MSBuild;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using System.Collections.Immutable;
|
||||
using ShrinkSDK.CodeGen.Analyzers;
|
||||
|
||||
namespace ShrinkSDK.Inspect;
|
||||
|
||||
public static class SemanticIndex
|
||||
{
|
||||
public static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true, WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
public static async Task Build(Index index, string? manifestPath, string? projectPath, bool checkRules = false)
|
||||
{
|
||||
if (projectPath != null)
|
||||
{
|
||||
if (!MSBuildLocator.IsRegistered) MSBuildLocator.RegisterDefaults();
|
||||
await LoadProject(index, Path.GetFullPath(projectPath), checkRules);
|
||||
return;
|
||||
}
|
||||
if (manifestPath != null)
|
||||
{
|
||||
var manifest = JsonSerializer.Deserialize<CompilationManifest>(File.ReadAllText(manifestPath), Json) ?? throw new InvalidDataException("Invalid compilation manifest.");
|
||||
index.BuildId = manifest.BuildId;
|
||||
var stale = manifest.SourceTicks.Where(pair => !File.Exists(pair.Key) || File.GetLastWriteTimeUtc(pair.Key).Ticks != pair.Value).Take(5).Select(p => p.Key).ToArray();
|
||||
if (stale.Length > 0) { index.Coverage.Add("Stale compilation input: " + string.Join(", ", stale)); index.BuildId = ""; }
|
||||
if (manifest.SourceTicks.Count == 0 || manifest.Assemblies.SelectMany(a => a.Sources).Any(s => !manifest.SourceTicks.ContainsKey(s)))
|
||||
{ index.Coverage.Add("Compilation manifest lacks complete source state; runtime identity cannot be verified."); index.BuildId = ""; }
|
||||
foreach (var input in manifest.Assemblies)
|
||||
{
|
||||
var sources = input.Sources.Where(path => InScope(index.Root, path)).ToArray();
|
||||
if (sources.Length == 0) continue;
|
||||
input.Sources = sources;
|
||||
Analyze(index, Compile(input), checkRules: checkRules);
|
||||
}
|
||||
index.Coverage.Add("Installed ShrinkSDK package sources are included; other external packages are reference-only.");
|
||||
return;
|
||||
}
|
||||
var projects = Directory.GetFiles(index.Root, "*.csproj");
|
||||
var found = false;
|
||||
foreach (var path in projects)
|
||||
{
|
||||
var xml = XDocument.Load(path);
|
||||
var sources = xml.Descendants().Where(e => e.Name.LocalName == "Compile").Select(e => e.Attribute("Include")?.Value)
|
||||
.Where(s => s != null && !s.Contains('*')).Select(s => Path.GetFullPath(Path.Combine(index.Root, s!))).Where(File.Exists).ToArray();
|
||||
if (sources.Length == 0) continue;
|
||||
var references = xml.Descendants().Where(e => e.Name.LocalName == "HintPath").Select(e => Path.GetFullPath(Path.Combine(index.Root, e.Value))).Where(File.Exists).ToArray();
|
||||
var projectReferences = xml.Descendants().Where(e => e.Name.LocalName == "ProjectReference").Select(e => e.Attribute("Include")?.Value)
|
||||
.Where(p => p != null).Select(p => Path.Combine(index.Root, "Library", "ScriptAssemblies", Path.GetFileNameWithoutExtension(p!) + ".dll")).Where(File.Exists);
|
||||
references = references.Concat(projectReferences).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var defines = xml.Descendants().FirstOrDefault(e => e.Name.LocalName == "DefineConstants")?.Value.Split(';') ?? [];
|
||||
Analyze(index, Compile(new() { Name = Path.GetFileNameWithoutExtension(path), Sources = sources, References = references, Defines = defines }), checkRules: checkRules);
|
||||
found = true;
|
||||
}
|
||||
if (found) { index.Coverage.Add("Unity IDE project inputs may be stale. Export compilation.json from the Editor for verified references/defines and runtime identity."); return; }
|
||||
index.Coverage.Add("No compilation input: source-only candidate index; conditional compilation and references are incomplete. Use --compilation or --project.");
|
||||
Analyze(index, Compile(new() { Name = "SourceOnly", Sources = ModuleCatalog.Files(index.Root, "*.cs").ToArray(),
|
||||
References = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))?.Split(Path.PathSeparator) ?? [] }), candidateOnly: true);
|
||||
}
|
||||
|
||||
private static async Task LoadProject(Index index, string path, bool checkRules)
|
||||
{
|
||||
using var workspace = MSBuildWorkspace.Create();
|
||||
workspace.WorkspaceFailed += (_, e) => index.Coverage.Add(e.Diagnostic.Message);
|
||||
var project = await workspace.OpenProjectAsync(path);
|
||||
foreach (var p in project.Solution.Projects)
|
||||
{
|
||||
var compilation = await p.GetCompilationAsync();
|
||||
if (compilation != null) Analyze(index, compilation, checkRules: checkRules);
|
||||
}
|
||||
index.Coverage.Add("Current source project analysis; no runtime build identity unless a matching exported manifest is supplied.");
|
||||
}
|
||||
|
||||
private static bool InScope(string root, string path)
|
||||
{
|
||||
var relative = Path.GetRelativePath(root, path);
|
||||
var parts = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
if (parts.Length > 2 && parts[0] == "Library" && parts[1] == "PackageCache" && parts[2].StartsWith("com.cneicy.shrink-", StringComparison.Ordinal)) return true;
|
||||
return !relative.StartsWith("..") && !relative.Split(Path.DirectorySeparatorChar).Any(p => p is "Library" or "Temp" or "obj" or "bin" or "Artifacts");
|
||||
}
|
||||
|
||||
private static Compilation Compile(CompilationInput input)
|
||||
{
|
||||
var options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest).WithPreprocessorSymbols(input.Defines.Where(d => !string.IsNullOrWhiteSpace(d)));
|
||||
var trees = input.Sources.Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path => CSharpSyntaxTree.ParseText(File.ReadAllText(path), options, path));
|
||||
var references = input.References.Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase).Select(path => MetadataReference.CreateFromFile(path));
|
||||
return CSharpCompilation.Create(input.Name, trees, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true));
|
||||
}
|
||||
|
||||
public static string Id(ISymbol symbol) => symbol.OriginalDefinition.GetDocumentationCommentId() ?? symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
||||
private static SourceLocation At(SyntaxNode node) => new(node.SyntaxTree.FilePath, node.GetLocation().GetLineSpan().StartLinePosition.Line + 1);
|
||||
private static string TypeId(ITypeSymbol type) => "type:" + type.ToDisplayString();
|
||||
|
||||
public static void Analyze(Index index, Compilation compilation, bool candidateOnly = false, bool checkRules = false)
|
||||
{
|
||||
var errors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3).ToArray();
|
||||
if (errors.Length > 0) index.Coverage.Add($"{compilation.AssemblyName}: incomplete compilation: " + string.Join(" | ", errors.Select(e => e.Id + " " + e.GetMessage())));
|
||||
if (checkRules && !candidateOnly)
|
||||
{
|
||||
var diagnostics = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(new ShrinkCodeGenAnalyzer()))
|
||||
.GetAnalyzerDiagnosticsAsync().GetAwaiter().GetResult();
|
||||
foreach (var diagnostic in diagnostics)
|
||||
{
|
||||
var line = diagnostic.Location.GetLineSpan();
|
||||
index.Findings.Add(new(diagnostic.Id, diagnostic.Severity.ToString().ToLowerInvariant(), diagnostic.GetMessage(),
|
||||
new(string.IsNullOrEmpty(line.Path) ? index.Root : line.Path, line.StartLinePosition.Line + 1)));
|
||||
}
|
||||
}
|
||||
foreach (var tree in compilation.SyntaxTrees)
|
||||
{
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
foreach (var node in tree.GetRoot().DescendantNodes())
|
||||
{
|
||||
if (node is BaseTypeDeclarationSyntax or BaseMethodDeclarationSyntax or PropertyDeclarationSyntax)
|
||||
{
|
||||
var symbol = model.GetDeclaredSymbol(node);
|
||||
if (symbol == null) continue;
|
||||
var id = Id(symbol);
|
||||
index.Symbols[id] = new(id, symbol.ToDisplayString(), index.ModuleFor(tree.FilePath), symbol.Kind.ToString(), At(node));
|
||||
if (symbol is INamedTypeSymbol type)
|
||||
{
|
||||
Edge(id, TypeId(type), "declares", candidateOnly ? "candidate" : "static", node);
|
||||
foreach (var attr in type.GetAttributes().Where(a => a.AttributeClass?.ToDisplayString() == "ShrinkNetwork.ShrinkNetworkMessageAttribute"))
|
||||
{
|
||||
if (attr.ConstructorArguments.Length == 0) continue;
|
||||
Edge("opcode:" + attr.ConstructorArguments[0].Value, TypeId(type), "message", "static", node);
|
||||
if (attr.ConstructorArguments.Length > 1 && attr.ConstructorArguments[1].Value is string route && !string.IsNullOrWhiteSpace(route)) Edge("route:" + route.Trim(), TypeId(type), "message", "static", node);
|
||||
}
|
||||
}
|
||||
foreach (var attr in symbol.GetAttributes())
|
||||
{
|
||||
var attrName = attr.AttributeClass?.ToDisplayString();
|
||||
if (symbol is IMethodSymbol method && attrName == "ShrinkEventBus.ShrinkSubscribeAttribute" && method.Parameters.Length > 0)
|
||||
Edge(TypeId(method.Parameters[0].Type), id, "subscribes", "candidate", node);
|
||||
if (attrName?.Contains("ShrinkCommand", StringComparison.Ordinal) == true && attr.ConstructorArguments.FirstOrDefault().Value is string command)
|
||||
Edge("command:" + command, id, "command", "candidate", node);
|
||||
}
|
||||
}
|
||||
if (node is not InvocationExpressionSyntax invocation) continue;
|
||||
var info = model.GetSymbolInfo(invocation);
|
||||
var target = info.Symbol as IMethodSymbol;
|
||||
var enclosing = model.GetEnclosingSymbol(invocation.SpanStart);
|
||||
if (enclosing == null) continue;
|
||||
var from = Id(enclosing);
|
||||
if (target == null)
|
||||
{
|
||||
foreach (var candidate in info.CandidateSymbols) Edge(from, Id(candidate), "calls", "candidate", node);
|
||||
if (info.CandidateSymbols.Length == 0) Edge(from, invocation.Expression.ToString(), "calls", "unresolved", node);
|
||||
continue;
|
||||
}
|
||||
var evidence = candidateOnly || target.IsVirtual || target.ContainingType.TypeKind == TypeKind.Interface || target.MethodKind == MethodKind.DelegateInvoke ? "candidate" : "static";
|
||||
Edge(from, Id(target), "calls", evidence, node);
|
||||
var owner = target.ContainingNamespace.ToDisplayString();
|
||||
if (owner.StartsWith("ShrinkEventBus", StringComparison.Ordinal) && target.Name is "Post" or "PostAsync" && invocation.ArgumentList.Arguments.Count > 0)
|
||||
{
|
||||
var type = model.GetTypeInfo(invocation.ArgumentList.Arguments[0].Expression).Type;
|
||||
if (type != null) Edge(from, TypeId(type), "publishes", evidence, node);
|
||||
}
|
||||
if (owner.StartsWith("ShrinkContext", StringComparison.Ordinal) && target.Name is "Get" or "Set" or "TryGet" && invocation.ArgumentList.Arguments.Count > 0)
|
||||
{
|
||||
var arg = invocation.ArgumentList.Arguments[0].Expression;
|
||||
var key = model.GetConstantValue(arg);
|
||||
var keyName = key.HasValue && key.Value is string value ? value : model.GetSymbolInfo(arg).Symbol?.ToDisplayString() ?? arg.ToString();
|
||||
var type = target.TypeArguments.FirstOrDefault()?.ToDisplayString() ?? "unknown";
|
||||
Edge(from, "key:" + keyName, target.Name == "Set" ? "provides:" + type : "consumes:" + type, evidence, node);
|
||||
}
|
||||
if (owner.StartsWith("ShrinkNetwork", StringComparison.Ordinal) && target.Name.StartsWith("Register", StringComparison.Ordinal) && target.TypeArguments.Length > 0)
|
||||
{
|
||||
Edge(TypeId(target.TypeArguments[0]), from, "registers", "candidate", node);
|
||||
foreach (var arg in invocation.ArgumentList.Arguments)
|
||||
{
|
||||
var handler = model.GetSymbolInfo(arg.Expression).Symbol;
|
||||
if (handler is IMethodSymbol) Edge(TypeId(target.TypeArguments[0]), Id(handler), "handler", "candidate", arg);
|
||||
if (arg.Expression is LambdaExpressionSyntax lambda)
|
||||
foreach (var call in lambda.DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
if (model.GetSymbolInfo(call).Symbol is IMethodSymbol forwarded)
|
||||
Edge(TypeId(target.TypeArguments[0]), Id(forwarded), "handler-forward", "candidate", call);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
void Edge(string from, string to, string kind, string evidence, SyntaxNode node) => index.Relations.Add(new(from, to, kind, evidence, At(node)));
|
||||
}
|
||||
|
||||
public static void CheckRelations(Index index)
|
||||
{
|
||||
foreach (var group in index.Relations.Where(r => r.Kind == "message" && r.Evidence == "static").GroupBy(r => r.From))
|
||||
if (group.Select(r => r.To).Distinct().Count() > 1)
|
||||
foreach (var edge in group) index.Findings.Add(new("SHRINK002", "error", $"Duplicate {group.Key}. Assign a unique opcode and nonempty route; see Network README.", edge.Location));
|
||||
foreach (var group in index.Relations.Where(r => r.Evidence == "static" && (r.Kind.StartsWith("provides:") || r.Kind.StartsWith("consumes:"))).GroupBy(r => r.To))
|
||||
{
|
||||
var provided = group.Where(r => r.Kind.StartsWith("provides:")).Select(r => r.Kind[9..]).Distinct().ToArray();
|
||||
// Exact generic type mismatches may be legal covariance/interface use. Report for review, not as a proof of invalidity.
|
||||
if (provided.Length > 1) index.Findings.Add(new("SHRINK104", "warning", $"{group.Key} is provided with different generic types: {string.Join(", ", provided)}. Check realm and assignability.", group.First().Location));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RollForward>LatestMajor</RollForward>
|
||||
<PackAsTool>true</PackAsTool>
|
||||
<ToolCommandName>shrink</ToolCommandName>
|
||||
<PackageId>ShrinkSDK.Inspect</PackageId>
|
||||
<Version>0.1.2</Version>
|
||||
<Description>Bounded, evidence-based ShrinkSDK capability and code navigation.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Assets/Modules/ShrinkShared.CodeGen/DotNet~/ShrinkSDK.CodeGen.Analyzers/ShrinkSDK.CodeGen.Analyzers.csproj" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.14.0" />
|
||||
<PackageReference Include="Microsoft.Build.Locator" Version="1.7.8" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user