refactor(ui/scripts): cache diagnostics + semantic forbidden-API check

Two pre-flagged follow-ups from the Monaco integration:

1. IMemoryCache for diagnostics keyed by SHA256 of the script body.
   Same-code Diagnose() now short-circuits the Roslyn compile and
   forbidden-API walk. SizeLimit 200 entries with 5-minute sliding
   expiration. Completions aren't cached — position + form context
   vary too much for a useful hit rate.

2. Forbidden-API analyzer now resolves identifiers through the
   SemanticModel instead of matching names. A user identifier
   named File / Thread / Process / etc. no longer false-positives
   — only references that resolve to a NamedTypeSymbol whose
   containing namespace is on the banned list are flagged. The
   diagnostic message now names the offending namespace, e.g.
   "Type 'File' from forbidden namespace 'System.IO' is not
   allowed in scripts."

Refactor: extracted ISharedScriptCatalog so ScriptAnalysisService
can be unit-tested without standing up SharedScriptService's EF
chain. Concrete SharedScriptCatalog wraps the existing service.

16 new xUnit tests in ScriptAnalysisServiceTests:
  - Empty / clean / missing-semicolon paths
  - SCADA001 on each banned using namespace (theory)
  - SCADA002 on real File.ReadAllText through System.IO
  - No-false-positive checks for user-defined File / Thread locals
  - Cache returns the same response instance on repeat
  - Different code → different cache entries
  - String-literal completions for Parameters / CallScript / CallShared
  - General completion at file scope returns ScriptHost members

Total CentralUI test count: 113 -> 129.
This commit is contained in:
Joseph Doherty
2026-05-12 05:05:35 -04:00
parent 225817eac9
commit cd0ec583e1
4 changed files with 256 additions and 49 deletions

View File

@@ -0,0 +1,156 @@
using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using ScadaLink.CentralUI.ScriptAnalysis;
namespace ScadaLink.CentralUI.Tests.ScriptAnalysis;
public class ScriptAnalysisServiceTests
{
private readonly ISharedScriptCatalog _catalog = Substitute.For<ISharedScriptCatalog>();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 });
private readonly ScriptAnalysisService _svc;
public ScriptAnalysisServiceTests()
{
_catalog.GetNamesAsync().Returns(Array.Empty<string>());
_svc = new ScriptAnalysisService(_catalog, _cache);
}
[Fact]
public void EmptyCode_NoMarkers()
{
var resp = _svc.Diagnose(new DiagnoseRequest(""));
Assert.Empty(resp.Markers);
}
[Fact]
public void CleanScript_NoMarkers()
{
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1 + 2; return x;"));
Assert.Empty(resp.Markers);
}
[Fact]
public void MissingSemicolon_ReportsRoslynDiagnostic()
{
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1\n"));
Assert.Contains(resp.Markers, m => m.Code.StartsWith("CS"));
}
[Fact]
public void ForbiddenUsingDirective_RaisesSCADA001()
{
var resp = _svc.Diagnose(new DiagnoseRequest("using System.IO;"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains("System.IO"));
}
[Theory]
[InlineData("System.Diagnostics")]
[InlineData("System.Reflection")]
[InlineData("System.Net")]
public void ForbiddenUsing_AllBannedNamespaces(string ns)
{
var resp = _svc.Diagnose(new DiagnoseRequest($"using {ns};"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains(ns));
}
[Fact]
public void ForbiddenTypeUsage_ResolvesViaSemanticModel()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"using System.IO; var s = File.ReadAllText(\"x\");"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("File"));
}
[Fact]
public void UserIdentifierNamedFile_DoesNotFalsePositive()
{
// No System.IO import; user defines their own 'File' local.
var resp = _svc.Diagnose(new DiagnoseRequest(
"var File = \"hello\"; return File.Length;"));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public void UserIdentifierNamedThread_DoesNotFalsePositive()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"var Thread = 42; return Thread;"));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public void DiagnosticsAreCached_SecondCallSkipsRecompile()
{
var req = new DiagnoseRequest("using System.IO;");
var first = _svc.Diagnose(req);
var second = _svc.Diagnose(req);
// Same instance reference indicates the cache returned the prior result.
Assert.Same(first, second);
}
[Fact]
public void DifferentCode_GetsDifferentCacheEntries()
{
var a = _svc.Diagnose(new DiagnoseRequest("var x = 1;"));
var b = _svc.Diagnose(new DiagnoseRequest("var y = 2;"));
Assert.NotSame(a, b);
}
[Fact]
public async Task ParametersStringLiteral_ReturnsDeclaredParameterNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Parameters[\"",
Line: 1,
Column: 21,
DeclaredParameters: new[] { "name", "temperature" });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "name" && i.Detail == "declared parameter");
Assert.Contains(resp.Items, i => i.Label == "temperature");
}
[Fact]
public async Task CallScriptStringLiteral_ReturnsSiblingNames()
{
var req = new CompletionsRequest(
CodeText: "var x = CallScript(\"",
Line: 1,
Column: 21,
SiblingScripts: new[] { "SiblingA", "SiblingB" });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "SiblingA" && i.Detail == "sibling script");
Assert.Contains(resp.Items, i => i.Label == "SiblingB");
}
[Fact]
public async Task CallSharedStringLiteral_ResolvesViaCatalog()
{
_catalog.GetNamesAsync().Returns(new[] { "GetWeather", "Greet" });
var req = new CompletionsRequest(
CodeText: "var x = CallShared(\"",
Line: 1,
Column: 21);
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "GetWeather" && i.Detail == "shared script");
Assert.Contains(resp.Items, i => i.Label == "Greet");
}
[Fact]
public async Task GeneralCompletion_ReturnsInScopeSymbols()
{
// At file scope of a script, ScriptHost members + the System namespace are visible.
var req = new CompletionsRequest("var x = ", 1, 9);
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "Parameters");
Assert.Contains(resp.Items, i => i.Label == "CallShared");
}
}