fix(transport): drop blocker false positives for stdlib + member access

The DetectBlockersAsync heuristic was catching every PascalCase
"Identifier(" or "Identifier." token in script bodies and treating it
as a candidate SharedScript or ExternalSystem reference. On a normal
template catalog this surfaced 30+ blocker rows for .NET stdlib
(DateTimeOffset, Convert, ToString, Dispose, UtcNow...), ScadaLink
runtime API roots (Notify, Database, ExternalSystem, Scripts...), and
SQL keywords inside string literals (COUNT), blocking the import.

Two surgical fixes:

1. Skip identifiers preceded by `.` so `obj.Method()` no longer flags
   `Method` as a top-level reference.
2. Maintain a `KnownNonReferenceNames` denylist for the small set of
   well-known stdlib / runtime / SQL tokens that can never be
   user-defined SharedScripts or ExternalSystems.

The documented use case -- a top-level free-standing call to a missing
SharedScript or ExternalSystem (e.g. `MissingHelper()` at the start of
an expression, or `ErpSystem.Call(...)` where ErpSystem is the
external-system identifier) -- still produces a blocker row, pinned
by the existing test plus a new noise-filter regression test.
This commit is contained in:
Joseph Doherty
2026-05-24 07:46:24 -04:00
parent 6299743a35
commit 6bdada7549
2 changed files with 90 additions and 6 deletions

View File

@@ -250,4 +250,53 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.DoesNotContain(preview.Items, i =>
i.Kind == ConflictKind.Blocker && i.Name == "HelperFn");
}
[Fact]
public async Task PreviewAsync_does_not_flag_stdlib_or_runtime_member_accesses_as_blockers()
{
// Arrange: a template script that uses a representative mix of stdlib
// calls, runtime-API roots, and member-access patterns. None of these
// are user-defined SharedScripts or ExternalSystems and the previous
// heuristic was flagging every one of them.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var t = new Template("Pump") { Description = "noise-check" };
t.Scripts.Add(new TemplateScript("init", """
var now = DateTimeOffset.UtcNow;
var s = Convert.ToString(123);
await Notify.Send("alerts", "msg");
var x = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM t");
var y = await ExternalSystem.Call("erp", "ping");
obj.Dispose();
"""));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var bundleStream = await ExportTemplatesAsync();
var bytes = await StreamToBytes(bundleStream);
// Act
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
// Assert: none of the well-known names produce blocker rows.
string[] noiseNames =
{
"DateTimeOffset", "UtcNow", "Convert", "ToString", "Notify", "Send",
"Database", "ExecuteScalarAsync", "COUNT", "ExternalSystem", "Call",
"Dispose",
};
foreach (var name in noiseNames)
{
Assert.DoesNotContain(preview.Items, i =>
i.Kind == ConflictKind.Blocker && i.Name == name);
}
}
}