|
|
|
@@ -1,6 +1,9 @@
|
|
|
|
|
using System.IO.Compression;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using Microsoft.CodeAnalysis;
|
|
|
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
|
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
@@ -779,10 +782,22 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
// Name is a valid identifier, if Name appears in NEITHER set, surface
|
|
|
|
|
// it as a Blocker. This catches the documented use-case
|
|
|
|
|
// (HelperFn() / ErpSystem.Call()) without combinatorial blowup.
|
|
|
|
|
var referencedFromBundle = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
|
// #05-T19 severity split: track candidate references by ORIGIN. Template-
|
|
|
|
|
// script (and attribute-default-expression) references are advisory
|
|
|
|
|
// warnings — the design-time deploy gate re-validates them; ApiMethod
|
|
|
|
|
// references are hard blockers (no downstream gate). Local-function /
|
|
|
|
|
// method declarations in a body are excluded so a script's own helper
|
|
|
|
|
// isn't mistaken for a missing SharedScript.
|
|
|
|
|
var referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
|
var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
|
var locallyDeclared = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
|
foreach (var t in content.Templates)
|
|
|
|
|
{
|
|
|
|
|
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referencedFromBundle);
|
|
|
|
|
foreach (var s in t.Scripts)
|
|
|
|
|
{
|
|
|
|
|
CollectCallIdentifiers(s.Code, referencedFromTemplates);
|
|
|
|
|
CollectLocalDeclarations(s.Code, locallyDeclared);
|
|
|
|
|
}
|
|
|
|
|
foreach (var a in t.Attributes)
|
|
|
|
|
{
|
|
|
|
|
// Attribute.Value carries the design-time default expression, which
|
|
|
|
@@ -791,37 +806,44 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
// it's never script source and must NOT be scanned, or the dot
|
|
|
|
|
// delimiter trips the heuristic into flagging the address segments
|
|
|
|
|
// as missing SharedScript/ExternalSystem references.
|
|
|
|
|
CollectCallIdentifiers(a.Value, referencedFromBundle);
|
|
|
|
|
CollectCallIdentifiers(a.Value, referencedFromTemplates);
|
|
|
|
|
CollectLocalDeclarations(a.Value, locallyDeclared);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
foreach (var m in content.ApiMethods)
|
|
|
|
|
{
|
|
|
|
|
CollectCallIdentifiers(m.Script, referencedFromBundle);
|
|
|
|
|
CollectCallIdentifiers(m.Script, referencedFromApiMethods);
|
|
|
|
|
CollectLocalDeclarations(m.Script, locallyDeclared);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For each candidate, only report it as a blocker if it looks like a
|
|
|
|
|
// 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))
|
|
|
|
|
// For each candidate, only report it if it looks like a resource
|
|
|
|
|
// reference (PascalCase, length > 1), isn't a well-known language /
|
|
|
|
|
// runtime / SQL token, isn't the body's own local declaration, AND
|
|
|
|
|
// isn't present anywhere we can satisfy it. A candidate that appears in
|
|
|
|
|
// an ApiMethod script is a hard Blocker; a template-only candidate is a
|
|
|
|
|
// non-blocking Warning.
|
|
|
|
|
var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
|
|
|
|
|
allCandidates.UnionWith(referencedFromApiMethods);
|
|
|
|
|
foreach (var candidate in allCandidates.OrderBy(n => n, StringComparer.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
if (!LooksLikeResourceName(candidate)) continue;
|
|
|
|
|
if (KnownNonReferenceNames.Contains(candidate)) continue;
|
|
|
|
|
if (locallyDeclared.Contains(candidate)) continue;
|
|
|
|
|
var isShared = sharedScriptNames.Contains(candidate);
|
|
|
|
|
var isExternal = externalSystemNames.Contains(candidate);
|
|
|
|
|
if (isShared || isExternal) continue;
|
|
|
|
|
|
|
|
|
|
var fromApiMethod = referencedFromApiMethods.Contains(candidate);
|
|
|
|
|
blockers.Add(new ImportPreviewItem(
|
|
|
|
|
EntityType: "Reference",
|
|
|
|
|
Name: candidate,
|
|
|
|
|
ExistingVersion: null,
|
|
|
|
|
IncomingVersion: null,
|
|
|
|
|
Kind: ConflictKind.Blocker,
|
|
|
|
|
Kind: fromApiMethod ? ConflictKind.Blocker : ConflictKind.Warning,
|
|
|
|
|
FieldDiffJson: null,
|
|
|
|
|
BlockerReason: $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."));
|
|
|
|
|
BlockerReason: fromApiMethod
|
|
|
|
|
? $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."
|
|
|
|
|
: $"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target — advisory; the deploy-time gate re-validates."));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return blockers;
|
|
|
|
@@ -850,6 +872,40 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Collects the names of every local function / method DECLARED inside a
|
|
|
|
|
/// script body, so the reference heuristic doesn't mistake a script's own
|
|
|
|
|
/// helper (<c>decimal ComputeRate(...) => ...; return ComputeRate(x);</c>)
|
|
|
|
|
/// for a missing SharedScript / ExternalSystem. Uses a Roslyn script-kind
|
|
|
|
|
/// parse (partial trees are fine — a body with syntax errors still yields
|
|
|
|
|
/// the declarations Roslyn could recover). Purely additive noise-suppression:
|
|
|
|
|
/// on any parse failure the sink is simply left unchanged.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static void CollectLocalDeclarations(string? body, HashSet<string> sink)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(body)) return;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var root = CSharpSyntaxTree
|
|
|
|
|
.ParseText(body, new CSharpParseOptions(kind: SourceCodeKind.Script))
|
|
|
|
|
.GetRoot();
|
|
|
|
|
foreach (var fn in root.DescendantNodes().OfType<LocalFunctionStatementSyntax>())
|
|
|
|
|
{
|
|
|
|
|
sink.Add(fn.Identifier.ValueText);
|
|
|
|
|
}
|
|
|
|
|
foreach (var m in root.DescendantNodes().OfType<MethodDeclarationSyntax>())
|
|
|
|
|
{
|
|
|
|
|
sink.Add(m.Identifier.ValueText);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
// Best-effort: a parse failure just means no declarations are
|
|
|
|
|
// excluded for this body — the finding (at worst) surfaces as an
|
|
|
|
|
// advisory warning, never a hard error for template scripts.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Names that look like PascalCase references but are never user-defined
|
|
|
|
|
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
|
|
|
|
@@ -877,7 +933,20 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
|
|
|
|
|
// SQL keywords commonly seen inside string literals
|
|
|
|
|
"COUNT", "FROM", "GROUP", "INSERT", "JOIN", "ORDER", "SELECT",
|
|
|
|
|
"UPDATE", "WHERE",
|
|
|
|
|
"UPDATE", "WHERE", "HAVING", "VALUES", "DELETE", "DISTINCT", "LIMIT",
|
|
|
|
|
|
|
|
|
|
// #05-T19 — extended stdlib / BCL surface commonly reached from scripts.
|
|
|
|
|
// The list will still drift as scripts use more of the BCL — that is
|
|
|
|
|
// precisely why template-script findings are downgraded to warnings
|
|
|
|
|
// (the deploy-time gate re-validates authoritatively).
|
|
|
|
|
"Regex", "Match", "Matches", "IsMatch", "Replace", "Split",
|
|
|
|
|
"StringBuilder", "Append", "AppendLine", "Parse", "TryParse",
|
|
|
|
|
"Format", "Join", "Abs", "Round", "Min", "Max", "Floor", "Ceiling",
|
|
|
|
|
"Pow", "Sqrt", "Json", "Serialize", "Deserialize", "Where", "Select",
|
|
|
|
|
"First", "FirstOrDefault", "Any", "All", "Count", "Sum", "Average",
|
|
|
|
|
"OrderBy", "OrderByDescending", "GroupBy", "Distinct", "Add", "Remove",
|
|
|
|
|
"Clear", "Contains", "ContainsKey", "TryGetValue", "StartsWith",
|
|
|
|
|
"EndsWith", "Substring", "Trim", "ToUpper", "ToLower",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static bool LooksLikeResourceName(string name)
|
|
|
|
@@ -982,7 +1051,8 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
// Skip-resolved DTOs are excluded from the in-bundle name set so
|
|
|
|
|
// a Skip on a dependency surfaces as a missing-reference error
|
|
|
|
|
// rather than silently passing.
|
|
|
|
|
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
|
|
|
|
|
var (validationErrors, validationWarnings) =
|
|
|
|
|
await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
|
|
|
|
|
// Validate every site / connection / template reference the
|
|
|
|
|
// site-instance payload depends on BEFORE any row is staged. Running
|
|
|
|
|
// this in the validation phase (not as an apply-pass guard) preserves
|
|
|
|
@@ -999,6 +1069,17 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
throw new SemanticValidationException(validationErrors);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Advisory template-script reference findings never block the import;
|
|
|
|
|
// log them and surface them on the ImportResult so the operator sees
|
|
|
|
|
// the same advisory the preview showed. The deploy-time gate is the
|
|
|
|
|
// authoritative re-validation.
|
|
|
|
|
if (validationWarnings.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
_logger?.LogWarning(
|
|
|
|
|
"Bundle import {BundleImportId}: {Count} advisory template-script reference warning(s): {Warnings}",
|
|
|
|
|
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Site/instance-scoped apply: sites + connections FIRST ----
|
|
|
|
|
// Sites are the FK target for both data connections (SiteId) and
|
|
|
|
|
// instances (SiteId); data connections are the FK target for instance
|
|
|
|
@@ -1152,7 +1233,8 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
Renamed: summary.Renamed,
|
|
|
|
|
StaleInstanceIds: staleInstanceIds,
|
|
|
|
|
AuditEventCorrelation: bundleImportId.ToString(),
|
|
|
|
|
ApiKeysIgnored: apiKeysIgnored);
|
|
|
|
|
ApiKeysIgnored: apiKeysIgnored,
|
|
|
|
|
Warnings: validationWarnings);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
@@ -4086,12 +4168,13 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
/// operator isn't trying to import) would block the import.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task<IReadOnlyList<string>> RunSemanticValidationAsync(
|
|
|
|
|
private async Task<(IReadOnlyList<string> Errors, IReadOnlyList<string> Warnings)> RunSemanticValidationAsync(
|
|
|
|
|
BundleContentDto content,
|
|
|
|
|
Dictionary<(string, string), ImportResolution> resolutionMap,
|
|
|
|
|
CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var errors = new List<string>();
|
|
|
|
|
var warnings = new List<string>();
|
|
|
|
|
|
|
|
|
|
// ---- Pass 1: minimal name-resolution scan ----
|
|
|
|
|
|
|
|
|
@@ -4135,17 +4218,25 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect every identifier-shaped call target from the bundle's
|
|
|
|
|
// templates + api methods. We only check the bundle's bodies here
|
|
|
|
|
// (matching PreviewAsync's blocker scan); pre-existing target rows are
|
|
|
|
|
// assumed already validated when they were originally written.
|
|
|
|
|
var referenced = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
|
// templates + api methods, keyed by ORIGIN (#05-T19 severity split).
|
|
|
|
|
// We only check the bundle's bodies here (matching PreviewAsync's blocker
|
|
|
|
|
// scan); pre-existing target rows are assumed already validated when they
|
|
|
|
|
// were originally written. Local-function / method declarations in a body
|
|
|
|
|
// are collected too so a script's own helper isn't flagged as missing.
|
|
|
|
|
var referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
|
var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
|
var locallyDeclared = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
|
foreach (var t in content.Templates)
|
|
|
|
|
{
|
|
|
|
|
// Skip-resolved templates aren't being written, so their script
|
|
|
|
|
// references don't need to resolve.
|
|
|
|
|
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
|
|
|
|
|
if (resolution.Action == ResolutionAction.Skip) continue;
|
|
|
|
|
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referenced);
|
|
|
|
|
foreach (var s in t.Scripts)
|
|
|
|
|
{
|
|
|
|
|
CollectCallIdentifiers(s.Code, referencedFromTemplates);
|
|
|
|
|
CollectLocalDeclarations(s.Code, locallyDeclared);
|
|
|
|
|
}
|
|
|
|
|
foreach (var a in t.Attributes)
|
|
|
|
|
{
|
|
|
|
|
// Value can hold script-callable design-time expressions;
|
|
|
|
@@ -4153,29 +4244,46 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
// "ns=3;s=Tank.Level") and must NOT be scanned, or the dot
|
|
|
|
|
// delimiter will flag tag-path segments as missing references.
|
|
|
|
|
// Symmetric with DetectBlockersAsync.
|
|
|
|
|
CollectCallIdentifiers(a.Value, referenced);
|
|
|
|
|
CollectCallIdentifiers(a.Value, referencedFromTemplates);
|
|
|
|
|
CollectLocalDeclarations(a.Value, locallyDeclared);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
foreach (var m in content.ApiMethods)
|
|
|
|
|
{
|
|
|
|
|
var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name);
|
|
|
|
|
if (resolution.Action == ResolutionAction.Skip) continue;
|
|
|
|
|
CollectCallIdentifiers(m.Script, referenced);
|
|
|
|
|
CollectCallIdentifiers(m.Script, referencedFromApiMethods);
|
|
|
|
|
CollectLocalDeclarations(m.Script, locallyDeclared);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var candidate in referenced.OrderBy(n => n, StringComparer.Ordinal))
|
|
|
|
|
// ApiMethod references are hard errors (no downstream deploy gate);
|
|
|
|
|
// template-only references are advisory warnings (the design-time deploy
|
|
|
|
|
// gate re-validates with a real compile).
|
|
|
|
|
var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
|
|
|
|
|
allCandidates.UnionWith(referencedFromApiMethods);
|
|
|
|
|
foreach (var candidate in allCandidates.OrderBy(n => n, StringComparer.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
if (!LooksLikeResourceName(candidate)) continue;
|
|
|
|
|
if (KnownNonReferenceNames.Contains(candidate)) continue;
|
|
|
|
|
if (locallyDeclared.Contains(candidate)) continue;
|
|
|
|
|
if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue;
|
|
|
|
|
errors.Add(
|
|
|
|
|
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
|
|
|
|
|
if (referencedFromApiMethods.Contains(candidate))
|
|
|
|
|
{
|
|
|
|
|
errors.Add(
|
|
|
|
|
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
warnings.Add(
|
|
|
|
|
$"Template script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target — advisory; re-validated at deploy time.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fail fast — running the full validator over templates that already
|
|
|
|
|
// failed name resolution would produce duplicate / lower-quality errors
|
|
|
|
|
// (the missing identifier shows up there as "callee not found" too).
|
|
|
|
|
if (errors.Count > 0) return errors;
|
|
|
|
|
// Warnings ride along regardless — they never gate the import.
|
|
|
|
|
if (errors.Count > 0) return (errors, warnings);
|
|
|
|
|
|
|
|
|
|
// ---- Pass 2: full SemanticValidator over imported templates ----
|
|
|
|
|
|
|
|
|
@@ -4230,7 +4338,7 @@ public sealed class BundleImporter : IBundleImporter
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return errors;
|
|
|
|
|
return (errors, warnings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|