fix(transport): blocker heuristic no longer hard-blocks valid imports — local declarations excluded, denylist extended, template-script findings downgraded to warnings

#05-T19. Import name-resolution findings are now split by origin: template-script
(and attribute-default-expression) references become non-blocking advisory
warnings (ConflictKind.Warning + ImportResult.Warnings) since the design-time
deploy gate re-validates authoritatively; ApiMethod-script references stay hard
errors (SemanticValidationException / ConflictKind.Blocker) as they have no
downstream gate. Names declared locally in a body (Roslyn-parsed local functions
/ methods) are excluded so a script's own helper isn't flagged; KnownNonReferenceNames
extended with common BCL/LINQ/SQL surface. DetectBlockersAsync and
RunSemanticValidationAsync Pass 1 apply the identical split so preview and apply
agree. Rollback/hard-fail tests retargeted to ApiMethods (the still-hard-failing
vehicle). CentralUI preview renders the new Warning badge/advisory.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 02:12:26 -04:00
parent 8e92198078
commit c60347c5e7
10 changed files with 426 additions and 121 deletions
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
@@ -330,7 +331,7 @@ public sealed class BundleImporterPreviewTests : IDisposable
}
[Fact]
public async Task PreviewAsync_emits_Blocker_when_required_dependency_missing()
public async Task PreviewAsync_emits_Warning_when_template_script_dependency_missing()
{
// Arrange: seed a template whose script body calls MissingHelper(), and
// an unrelated HelperFn() shared script that *is* defined but isn't the
@@ -338,10 +339,9 @@ public sealed class BundleImporterPreviewTests : IDisposable
// selection that only pulls the template — the bundle won't carry
// MissingHelper (it doesn't exist anywhere) so the preview must flag it.
//
// To get MissingHelper into the bundle script body without the export
// resolver pulling it in (it can't — it doesn't exist), we just seed
// the template with a script that mentions it; the resolver scan only
// matters for entity discovery, the body text is preserved verbatim.
// Under #05-T19 a TEMPLATE-script name-resolution finding is ADVISORY
// (the design-time deploy gate re-validates it), so the preview surfaces
// it as ConflictKind.Warning — NOT a Blocker.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
@@ -357,18 +357,6 @@ public sealed class BundleImporterPreviewTests : IDisposable
var bundleStream = await ExportTemplatesAsync();
var bytes = await StreamToBytes(bundleStream);
// Wipe the SharedScripts table so MissingHelper has no chance of being
// resolved in the target either. (HelperFn is intentionally seeded so
// we can verify the blocker check is specific — it should NOT flag
// HelperFn since it's in the target.)
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Keep HelperFn + ErpSystem so they're in the target's resolved set.
// Just confirm via assertion that MissingHelper is the blocker name.
await ctx.SaveChangesAsync();
}
// Act
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
@@ -378,16 +366,64 @@ public sealed class BundleImporterPreviewTests : IDisposable
preview = await importer.PreviewAsync(session.SessionId);
}
// Assert: there's at least one Blocker, and the MissingHelper one is in there.
Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker);
// Assert: MissingHelper surfaces as a Warning (advisory), not a Blocker.
Assert.Contains(preview.Items, i =>
i.Kind == ConflictKind.Warning
&& i.Name == "MissingHelper"
&& i.BlockerReason is not null
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
Assert.DoesNotContain(preview.Items, i =>
i.Name == "MissingHelper" && i.Kind == ConflictKind.Blocker);
// Conversely, HelperFn must NOT be flagged at all — it's seeded in the target.
Assert.DoesNotContain(preview.Items, i => i.Name == "HelperFn");
}
[Fact]
public async Task PreviewAsync_emits_Blocker_when_ApiMethod_dependency_missing()
{
// #05-T19 severity split: an ApiMethod script referencing a missing
// dependency has no downstream deploy gate, so the preview must surface
// it as a hard Blocker (parity with the apply-time SemanticValidationException).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.ApiMethods.Add(new ApiMethod("Ingest", "var x = MissingHelper();"));
await ctx.SaveChangesAsync();
}
byte[] bytes;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var apiIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: apiIds,
IncludeDependencies: false);
var stream = await exporter.ExportAsync(selection, user: "alice",
sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None);
bytes = await StreamToBytes(stream);
}
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.Contains(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.Name == "MissingHelper"
&& i.BlockerReason is not null
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
// Conversely, HelperFn must NOT be a blocker — it's seeded in the target.
Assert.DoesNotContain(preview.Items, i =>
i.Kind == ConflictKind.Blocker && i.Name == "HelperFn");
}
[Fact]