diff --git a/src/ScadaLink.Transport/Import/BundleImporter.cs b/src/ScadaLink.Transport/Import/BundleImporter.cs
index e5bb362..a388f64 100644
--- a/src/ScadaLink.Transport/Import/BundleImporter.cs
+++ b/src/ScadaLink.Transport/Import/BundleImporter.cs
@@ -384,13 +384,16 @@ public sealed class BundleImporter : IBundleImporter
}
// For each candidate, only report it as a blocker if it looks like a
- // resource reference (PascalCase, length > 1) AND it's not present
- // anywhere we can satisfy it. We deliberately do not look at language
- // keywords or stdlib helpers — the test surface only ever uses
- // well-named identifiers.
+ // resource reference (PascalCase, length > 1), isn't a well-known
+ // language / runtime / SQL token, AND isn't present anywhere we can
+ // satisfy it. The denylist is the noise filter that keeps the
+ // heuristic usable on real script bodies — without it, every member
+ // access (`obj.ToString()`) and stdlib type (`DateTimeOffset`) gets
+ // flagged.
foreach (var candidate in referencedFromBundle.OrderBy(n => n, StringComparer.Ordinal))
{
if (!LooksLikeResourceName(candidate)) continue;
+ if (KnownNonReferenceNames.Contains(candidate)) continue;
var isShared = sharedScriptNames.Contains(candidate);
var isExternal = externalSystemNames.Contains(candidate);
if (isShared || isExternal) continue;
@@ -413,11 +416,13 @@ public sealed class BundleImporter : IBundleImporter
if (string.IsNullOrEmpty(body)) return;
// Find every "Identifier(" or "Identifier." occurrence. The boundary
// before the identifier must NOT be an identifier char so we don't
- // match the trailing portion of a longer token.
+ // match the trailing portion of a longer token, AND must not be a
+ // dot — otherwise `obj.Method()` would flag `Method` as a top-level
+ // reference. Member-access trailing identifiers are skipped.
for (var i = 0; i < body.Length; i++)
{
if (!IsIdentifierStart(body[i])) continue;
- if (i > 0 && IsIdentifierChar(body[i - 1])) continue;
+ if (i > 0 && (IsIdentifierChar(body[i - 1]) || body[i - 1] == '.')) continue;
var start = i;
while (i < body.Length && IsIdentifierChar(body[i])) i++;
if (i >= body.Length) break;
@@ -429,6 +434,36 @@ public sealed class BundleImporter : IBundleImporter
}
}
+ ///
+ /// Names that look like PascalCase references but are never user-defined
+ /// SharedScripts or ExternalSystems. Filters the false-positive noise the
+ /// identifier scan produces against real script bodies: .NET stdlib types
+ /// and helpers, ScadaLink runtime API roots, and common SQL keywords that
+ /// appear inside string literals. Match is case-sensitive (Ordinal).
+ ///
+ private static readonly HashSet KnownNonReferenceNames = new(StringComparer.Ordinal)
+ {
+ // .NET / C# stdlib
+ "Boolean", "Byte", "Char", "Console", "Convert", "DateTime",
+ "DateTimeOffset", "Decimal", "Dispose", "Double", "Enumerable",
+ "Exception", "Guid", "Int16", "Int32", "Int64", "List", "Math",
+ "Now", "Object", "Single", "String", "Task", "TimeSpan", "ToBoolean",
+ "ToDateTime", "ToDecimal", "ToDouble", "ToInt16", "ToInt32", "ToInt64",
+ "ToList", "ToSingle", "ToString", "UtcNow",
+
+ // ScadaLink script runtime API roots and well-known members
+ "Attribute", "Attributes", "Call", "CallScript", "CallShared",
+ "Connection", "CreateCommand", "Database", "ExecuteAsync",
+ "ExecuteNonQueryAsync", "ExecuteReaderAsync", "ExecuteScalarAsync",
+ "ExternalSystem", "GetAsync", "GetAttribute", "Instance", "Notify",
+ "Request", "Response", "Route", "Scheduler", "Scripts", "Send",
+ "SetAsync", "SetAttribute",
+
+ // SQL keywords commonly seen inside string literals
+ "COUNT", "FROM", "GROUP", "INSERT", "JOIN", "ORDER", "SELECT",
+ "UPDATE", "WHERE",
+ };
+
private static bool LooksLikeResourceName(string name)
{
if (name.Length < 2) return false;
diff --git a/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs b/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs
index 8727851..4cf2ce9 100644
--- a/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs
+++ b/tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs
@@ -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();
+ 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();
+ 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);
+ }
+ }
}