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
@@ -129,6 +129,7 @@ public sealed class BundleImporterApplyTests : IDisposable
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(e => e.Id).ToListAsync();
var apiMethodIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: templateIds,
SharedScriptIds: sharedScriptIds,
@@ -136,7 +137,7 @@ public sealed class BundleImporterApplyTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
ApiMethodIds: apiMethodIds,
IncludeDependencies: false);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
@@ -159,6 +160,7 @@ public sealed class BundleImporterApplyTests : IDisposable
ctx.Templates.RemoveRange(ctx.Templates);
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
await ctx.SaveChangesAsync();
}
@@ -320,15 +322,15 @@ public sealed class BundleImporterApplyTests : IDisposable
[Fact]
public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails()
{
// Arrange: seed a template whose script body calls MissingHelper().
// Arrange: seed an ApiMethod whose script body calls MissingHelper().
// No SharedScript by that name exists in source or (after wipe) in the
// target, so semantic validation must reject the apply.
// target. ApiMethod name-resolution findings are HARD errors (unlike
// template-script findings, which are advisory as of #05-T19), so this
// must reject the apply and roll everything back.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("BrokenPump") { Description = "broken" };
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
ctx.Templates.Add(t);
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
@@ -340,16 +342,16 @@ public sealed class BundleImporterApplyTests : IDisposable
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
user: "bob"));
}
// Assert — target still wiped (template not committed), AND a
// Assert — target still wiped (ApiMethod not committed), AND a
// BundleImportFailed row exists.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.Equal(0, await ctx.Templates.CountAsync());
Assert.Equal(0, await ctx.ApiMethods.CountAsync());
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
}
@@ -488,9 +490,7 @@ public sealed class BundleImporterApplyTests : IDisposable
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("BrokenPump") { Description = "broken" };
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
ctx.Templates.Add(t);
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
@@ -502,7 +502,7 @@ public sealed class BundleImporterApplyTests : IDisposable
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
user: "bob"));
}
@@ -1400,14 +1400,13 @@ public sealed class BundleImporterApplyTests : IDisposable
[Fact]
public async Task ApplyAsync_failed_apply_publishes_nothing()
{
// A template whose script calls an unresolved helper fails semantic
// validation BEFORE commit, so nothing is published.
// An ApiMethod whose script calls an unresolved helper fails semantic
// validation BEFORE commit (ApiMethod findings are hard errors), so
// nothing is published.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("Bad");
t.Scripts.Add(new TemplateScript("init", "var x = UnknownHelper();"));
ctx.Templates.Add(t);
ctx.ApiMethods.Add(new ApiMethod("Bad", "var x = UnknownHelper();"));
await ctx.SaveChangesAsync();
}
@@ -1417,13 +1416,13 @@ public sealed class BundleImporterApplyTests : IDisposable
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var selection = new ExportSelection(
TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
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: Array.Empty<int>(),
ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
IncludeDependencies: false);
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
@@ -1439,7 +1438,7 @@ public sealed class BundleImporterApplyTests : IDisposable
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Bad", ResolutionAction.Add, null) },
new List<ImportResolution> { new("ApiMethod", "Bad", ResolutionAction.Add, null) },
user: "bob"));
}