diff --git a/docs/Summary.md b/docs/Summary.md
index a9e15e62c..c89ab7b86 100644
--- a/docs/Summary.md
+++ b/docs/Summary.md
@@ -120,3 +120,4 @@ Acuminator does not perform static analysis of projects whose names contain `Tes
| [PX1116](diagnostics/PX1116.md) | A graph extension or a DAC extension has a circular reference in its type hierarchy. | Error | Unavailable |
| [PX1117](diagnostics/PX1117.md) | The type hierarchy of the DAC extension contains an extension that extends multiple independent DAC extensions. Extending multiple independent DAC extensions is forbidden for DAC extensions. | Error | Unavailable |
| [PX1120](diagnostics/PX1120.md) | Incorrect work with the `Task` types in the Acumatica asynchronous code. You should not store the `Task` instance in a local variable or parameter. The `Task`-typed expressions should be awaited or immediately returned, and a method returning a `Task`-typed expression should have the `Task` type as its return type. | Warning | Unavailable |
+| [PX1121](diagnostics/PX1121.md) | A `PXDatabase.Execute` call performs an unguarded schema mutation (`ALTER TABLE ADD`, `CREATE TABLE`, or `CREATE INDEX`). Customization plugins re-run `UpdateDatabase` on every publish; wrap the mutation in an existence guard (`IF NOT EXISTS`, `IF COL_LENGTH`, `IF OBJECT_ID`, or `IF INDEXPROPERTY`). | Warning | Unavailable |
diff --git a/docs/diagnostics/PX1121.md b/docs/diagnostics/PX1121.md
new file mode 100644
index 000000000..f345bb667
--- /dev/null
+++ b/docs/diagnostics/PX1121.md
@@ -0,0 +1,159 @@
+# PX1121
+This document describes the PX1121 diagnostic.
+
+## Summary
+
+| Code | Short Description | Type | Code Fix |
+| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ----------- |
+| PX1121 | A `PXDatabase.Execute` call performs an unguarded schema mutation (`ALTER TABLE ADD`, `CREATE TABLE`, or `CREATE INDEX`). Wrap the mutation in an existence guard. | Warning | Unavailable |
+
+## Diagnostic Description
+
+Acumatica customization plugins (classes derived from `Customization.CustomizationPlugin`) re-run their `UpdateDatabase` method on every publish. If `UpdateDatabase` performs a schema mutation without first checking whether the change already exists, the second publish will fail with a SQL error such as `Column names in each table must be unique` or `There is already an object named ...`. The failed publish leaves the customization in a partial state and blocks deployment.
+
+The PX1121 diagnostic inspects calls to `PXDatabase.Execute` and reports a warning when the SQL argument contains a schema mutation that is not preceded by an existence guard. Recognized guard patterns:
+
+- `IF NOT EXISTS (SELECT ... FROM sys.columns/tables/indexes ...)`
+- `IF EXISTS (...)`
+- `IF COL_LENGTH('
', '') IS NULL`
+- `IF OBJECT_ID('', '') IS NULL`
+- `IF INDEXPROPERTY(OBJECT_ID(''), '', 'IndexID') IS NULL`
+
+The check is per-mutation: a guard somewhere else in the SQL batch does not satisfy a different mutation. Comments (`--` line and `/* */` block) and string literals (`'...'`) inside the SQL are stripped before analysis, so keywords that happen to appear inside them do not produce false positives or false negatives.
+
+This diagnostic is not gated to ISV mode because customer-written customization plugins are equally affected by the double-publish failure mode.
+
+## Example of Incorrect Code
+
+### Bare ALTER TABLE ADD (canonical anti-pattern)
+
+```C#
+using PX.Data;
+using Customization;
+
+public class MyPlugin : CustomizationPlugin
+{
+ public override void UpdateDatabase()
+ {
+ // Error PX1121: this ALTER will fail on the second publish.
+ PXDatabase.Execute(@"
+ ALTER TABLE SOOrder ADD UsrPriority int NULL
+ ");
+ }
+}
+```
+
+### Multiple unguarded mutations in one batch
+
+```C#
+PXDatabase.Execute(@"
+ ALTER TABLE SOOrder ADD UsrPriority int NULL;
+ ALTER TABLE POOrder ADD UsrPriority int NULL;
+");
+// Error PX1121: both mutations are unguarded.
+```
+
+### Unrelated guard does not satisfy the mutation
+
+```C#
+PXDatabase.Execute(@"
+ IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'OtherTable')
+ PRINT 'logging note';
+ ALTER TABLE SOOrder ADD UsrPriority int NULL
+");
+// Error PX1121: the IF EXISTS guards the PRINT, not the ALTER.
+```
+
+### Constant string concatenation is still analyzed
+
+```C#
+PXDatabase.Execute("ALTER TABLE SOOrder " + "ADD UsrPriority int NULL");
+// Error PX1121: the constant-folded SQL is unguarded.
+```
+
+### Interpolated identifiers are still analyzed
+
+```C#
+string tableName = "SOOrder";
+string columnName = "UsrPriority";
+PXDatabase.Execute($"ALTER TABLE {tableName} ADD {columnName} int NULL");
+// Error PX1121: the literal segments contain an unguarded ALTER TABLE ADD.
+```
+
+## Example of Correct Code
+
+### `IF COL_LENGTH` guard (column existence)
+
+```C#
+PXDatabase.Execute(@"
+ IF COL_LENGTH('SOOrder', 'UsrPriority') IS NULL
+ ALTER TABLE SOOrder ADD UsrPriority int NULL
+");
+```
+
+### `IF NOT EXISTS` against `sys.columns`
+
+```C#
+PXDatabase.Execute(@"
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.columns
+ WHERE Name = 'UsrPriority' AND Object_ID = OBJECT_ID('SOOrder')
+ )
+ ALTER TABLE SOOrder ADD UsrPriority int NULL
+");
+```
+
+### `IF OBJECT_ID` guard (table existence)
+
+```C#
+PXDatabase.Execute(@"
+ IF OBJECT_ID('UsrCustomLookup', 'U') IS NULL
+ CREATE TABLE UsrCustomLookup (
+ Id int IDENTITY(1,1) PRIMARY KEY,
+ Code nvarchar(32) NOT NULL,
+ Description nvarchar(256) NULL
+ )
+");
+```
+
+### `IF INDEXPROPERTY` guard (index existence)
+
+```C#
+PXDatabase.Execute(@"
+ IF INDEXPROPERTY(OBJECT_ID('SOOrder'), 'IX_SOOrder_UsrPriority', 'IndexID') IS NULL
+ CREATE INDEX IX_SOOrder_UsrPriority ON SOOrder(UsrPriority)
+");
+```
+
+### `BEGIN...END` block under a guard
+
+```C#
+PXDatabase.Execute(@"
+ IF COL_LENGTH('SOOrder', 'UsrPriority') IS NULL
+ BEGIN
+ ALTER TABLE SOOrder ADD UsrPriority int NULL;
+ END
+");
+```
+
+## Suppression
+
+If a specific call is intentionally unguarded (for example, a one-shot migration plugin that is removed after a single publish), suppress the diagnostic with a comment:
+
+```C#
+// Acuminator disable once PX1121 — one-shot migration; plugin is removed after first publish.
+PXDatabase.Execute("ALTER TABLE LegacyTable DROP COLUMN OldColumn");
+```
+
+Or use a suppression file when Acuminator is installed as a VSIX extension.
+
+## Heuristic Limitations
+
+The diagnostic is regex-based and does not perform a full T-SQL parse. The following patterns are known limitations:
+
+- **Dynamic SQL built at runtime** (for example, `StringBuilder`-composed SQL with non-constant fragments) cannot be analyzed statically; PX1121 silently skips these calls.
+- **Interpolation holes that stand in for DDL keywords or modifiers** (for example, `$"CREATE {kind} INDEX ..."`) are not modeled.
+- **`SELECT` inside an `IF`'s then-branch followed by an unguarded mutation** (for example, `IF cond SELECT 1 ALTER TABLE ... ADD`) is treated as guarded because `SELECT` is excluded from the action-keyword set; this avoids false positives on `IF EXISTS (SELECT ... FROM sys.X)` guards but is a rare false negative.
+- **`ELSE`-branch mutations after a then-branch action** (for example, `IF cond PRINT 'x' ELSE ALTER TABLE ... ADD`) are flagged because the `PRINT` occludes the guard for the `ELSE`-branch mutation. Suppress with a comment for this pattern.
+
+These edge cases are documented rather than fixed because a heuristic implementation catches the canonical anti-patterns at near-zero false-positive cost. A full T-SQL parse (for example, via `Microsoft.SqlServer.TransactSql.ScriptDom`) would resolve them; if Acuminator adopts that dependency in the future, this analyzer can be upgraded to use the parser.
diff --git a/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.Designer.cs b/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.Designer.cs
index 55752d8be..53ab4780f 100644
--- a/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.Designer.cs
+++ b/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.Designer.cs
@@ -1193,5 +1193,14 @@ public static string PX1120_StoreTaskInVariable {
return ResourceManager.GetString("PX1120_StoreTaskInVariable", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to MissingSchemaMutationGuard.
+ ///
+ public static string PX1121 {
+ get {
+ return ResourceManager.GetString("PX1121", resourceCulture);
+ }
+ }
}
}
diff --git a/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.resx b/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.resx
index 4db12c833..81d4d240b 100644
--- a/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.resx
+++ b/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.resx
@@ -495,4 +495,7 @@
StoreTaskInVariableOrParameter
+
+ MissingSchemaMutationGuard
+
\ No newline at end of file
diff --git a/src/Acuminator/Acuminator.Analyzers/Resources.Designer.cs b/src/Acuminator/Acuminator.Analyzers/Resources.Designer.cs
index 6eb821427..fcb0580e3 100644
--- a/src/Acuminator/Acuminator.Analyzers/Resources.Designer.cs
+++ b/src/Acuminator/Acuminator.Analyzers/Resources.Designer.cs
@@ -2491,5 +2491,14 @@ public static string SuppressDiagnosticWithCommentNonNestedCodeActionTitle {
return ResourceManager.GetString("SuppressDiagnosticWithCommentNonNestedCodeActionTitle", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to The PXDatabase.Execute call performs an unguarded schema mutation.
+ ///
+ public static string PX1121Title {
+ get {
+ return ResourceManager.GetString("PX1121Title", resourceCulture);
+ }
+ }
}
}
diff --git a/src/Acuminator/Acuminator.Analyzers/Resources.resx b/src/Acuminator/Acuminator.Analyzers/Resources.resx
index 0aa7a4029..d7a3356b6 100644
--- a/src/Acuminator/Acuminator.Analyzers/Resources.resx
+++ b/src/Acuminator/Acuminator.Analyzers/Resources.resx
@@ -935,4 +935,7 @@ Reason: {2}
Suppress the {0} diagnostic with Acuminator in a comment
+
+ The PXDatabase.Execute call performs an unguarded schema mutation (ALTER TABLE ADD, CREATE TABLE, or CREATE INDEX). Acumatica customization plugins re-run UpdateDatabase on every publish; wrap the mutation in an existence guard (IF NOT EXISTS, IF COL_LENGTH, IF OBJECT_ID) so subsequent publishes do not fail.
+
\ No newline at end of file
diff --git a/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/Descriptors.cs b/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/Descriptors.cs
index e1be04a09..ded4fbd86 100644
--- a/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/Descriptors.cs
+++ b/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/Descriptors.cs
@@ -582,5 +582,9 @@ private static DiagnosticDescriptor Rule(string id, LocalizableString title, Cat
public static DiagnosticDescriptor PX1120_IncorrectTaskUsageInAsyncCode_NotAwaitedTaskReturningExpression { get; } =
Rule("PX1120", nameof(Resources.PX1120Title_NotAwaitedTaskReturningExpression).GetLocalized(), Category.Acuminator, DiagnosticSeverity.Warning,
DiagnosticsShortName.PX1120_NotAwaitedTaskReturningExpression);
+
+ public static DiagnosticDescriptor PX1121_MissingSchemaMutationGuard { get; } =
+ Rule("PX1121", nameof(Resources.PX1121Title).GetLocalized(), Category.Acuminator, DiagnosticSeverity.Warning,
+ DiagnosticsShortName.PX1121);
}
}
diff --git a/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardAnalyzer.cs b/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardAnalyzer.cs
new file mode 100644
index 000000000..0c45dd63e
--- /dev/null
+++ b/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardAnalyzer.cs
@@ -0,0 +1,310 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+
+using Acuminator.Utilities;
+using Acuminator.Utilities.DiagnosticSuppression;
+using Acuminator.Utilities.Roslyn.Semantic;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Acuminator.Analyzers.StaticAnalysis.MissingSchemaMutationGuard;
+
+///
+/// PX1121: Detects calls to PXDatabase.Execute with schema-mutation SQL
+/// (ALTER TABLE ... ADD , CREATE TABLE , CREATE INDEX ) that lack
+/// an existence guard preceding that specific mutation.
+///
+/// Acumatica customization plugins re-run UpdateDatabase on every publish;
+/// an unguarded schema mutation will fail with "column already exists" / "table
+/// already exists" on the second publish, breaking the deploy.
+///
+/// Guard-scope detection is per-mutation: for each mutation match in the SQL, we
+/// look at the preceding text (scoped to the current semicolon chunk) and check
+/// whether the *latest* match is a guard pattern (IF NOT EXISTS ,
+/// IF COL_LENGTH , IF OBJECT_ID , IF INDEXPROPERTY ,
+/// FROM sys.columns/tables/indexes ) or another action statement keyword
+/// (PRINT , EXEC , INSERT , etc.). A guard occluded by a later
+/// action-statement keyword no longer counts for that mutation, so the canonical
+/// false-negative pattern IF EXISTS(...) PRINT 'x' ALTER TABLE ... ADD ...
+/// (where the IF guards the PRINT and the ALTER is unguarded) is flagged correctly
+/// even without semicolons between statements.
+///
+/// Comments and string literals inside the SQL are stripped before matching so
+/// their contents do not produce false positives or false negatives.
+///
+/// This rule is not gated to ISVs because customer-written customization plugins
+/// are equally affected by the double-publish failure mode.
+///
+/// Known limitations (heuristic-based; full T-SQL parse would resolve them):
+/// - Nested block comments and bracketed-identifier escape sequences
+/// ([a]]b] ) are not modeled.
+/// - For interpolated strings with holes, literal segments are scanned;
+/// a hole that stands in for a DDL keyword/modifier (e.g.
+/// $"CREATE {kind} INDEX ..." ) is not modeled.
+/// - Runtime-computed SQL (StringBuilder -concatenated, non-constant)
+/// cannot be analyzed statically and is silently skipped.
+/// - SELECT is excluded from action keywords so it doesn't break
+/// guards like IF EXISTS (SELECT 1 FROM sys.X) ; consequence is that
+/// IF cond SELECT 1 ALTER TABLE ... ADD is treated as guarded
+/// (false negative on a rare pattern).
+/// - ELSE branches: code like IF cond PRINT 'x' ELSE ALTER TABLE ...
+/// ADD is flagged because PRINT occludes the guard for the
+/// ELSE -branch ALTER . Suppress via comment for this pattern.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class MissingSchemaMutationGuardAnalyzer : PXDiagnosticAnalyzer
+{
+ private const string ExecuteMethodName = "Execute";
+
+ private const string InterpolationHolePlaceholder = " __pxsb_hole__ ";
+
+ // Schema-mutation patterns. [^;] keeps each match inside one statement.
+ private static readonly Regex AlterTableAddPattern = new(
+ @"\bALTER\s+TABLE\b[^;]*?\bADD\b",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static readonly Regex CreateTablePattern = new(
+ @"\bCREATE\s+TABLE\b",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static readonly Regex CreateIndexPattern = new(
+ @"\bCREATE\s+(UNIQUE\s+)?(NONCLUSTERED\s+|CLUSTERED\s+)?INDEX\b",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ // Existence guards. Optional parens + whitespace handle styles like
+ // IF (COL_LENGTH(...) IS NULL) and IF NOT EXISTS(SELECT ...).
+ private static readonly Regex GuardPattern = new(
+ @"\bIF\s*\(?\s*(NOT\s+)?EXISTS\b"
+ + @"|\bIF\s*\(?\s*COL_LENGTH\b"
+ + @"|\bIF\s*\(?\s*OBJECT_ID\b"
+ + @"|\bIF\s*\(?\s*INDEXPROPERTY\b"
+ + @"|\bFROM\s+SYS\.(COLUMNS|TABLES|INDEXES)\b",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ // Action-statement keywords that BREAK a guard's scope when they appear
+ // between the guard and the mutation. Intentionally excludes:
+ // - SELECT (appears inside IF EXISTS guard conditions)
+ // - IF, THEN, ELSE, BEGIN, END (control flow that preserves guard scope)
+ // Includes ALTER/CREATE because a second mutation breaks the guard for the
+ // first one — each mutation needs its own guard.
+ private static readonly Regex ActionStatementKeywords = new(
+ @"\b(ALTER|CREATE|DROP|TRUNCATE|INSERT|UPDATE|DELETE|MERGE|EXEC|EXECUTE|PRINT|RAISERROR|WAITFOR)\b",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ public override ImmutableArray SupportedDiagnostics =>
+ ImmutableArray.Create(Descriptors.PX1121_MissingSchemaMutationGuard);
+
+ public MissingSchemaMutationGuardAnalyzer() : base() { }
+
+ public MissingSchemaMutationGuardAnalyzer(CodeAnalysisSettings codeAnalysisSettings) : base(codeAnalysisSettings) { }
+
+ protected override void AnalyzeCompilation(CompilationStartAnalysisContext compilationStartContext, PXContext pxContext)
+ {
+ compilationStartContext.RegisterSyntaxNodeAction(
+ syntaxContext => AnalyzeInvocation(syntaxContext, pxContext),
+ SyntaxKind.InvocationExpression);
+ }
+
+ private void AnalyzeInvocation(SyntaxNodeAnalysisContext syntaxContext, PXContext pxContext)
+ {
+ syntaxContext.CancellationToken.ThrowIfCancellationRequested();
+
+ var invocation = (InvocationExpressionSyntax)syntaxContext.Node;
+
+ if (!IsPxDatabaseExecuteCall(invocation, syntaxContext.SemanticModel, pxContext, syntaxContext.CancellationToken))
+ return;
+
+ var sqlArgument = invocation.ArgumentList.Arguments.FirstOrDefault();
+ if (sqlArgument is null)
+ return;
+
+ string? sqlText = TryGetStringValue(sqlArgument.Expression, syntaxContext.SemanticModel, syntaxContext.CancellationToken);
+ if (sqlText is null)
+ return;
+
+ if (!ContainsUnguardedSchemaMutation(sqlText))
+ return;
+
+ syntaxContext.ReportDiagnosticWithSuppressionCheck(
+ Diagnostic.Create(Descriptors.PX1121_MissingSchemaMutationGuard, invocation.GetLocation()),
+ pxContext.CodeAnalysisSettings);
+ }
+
+ private static bool IsPxDatabaseExecuteCall(
+ InvocationExpressionSyntax invocation,
+ SemanticModel semanticModel,
+ PXContext pxContext,
+ System.Threading.CancellationToken cancellationToken)
+ {
+ var symbolInfo = semanticModel.GetSymbolInfo(invocation, cancellationToken);
+ if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
+ return false;
+
+ if (methodSymbol.Name != ExecuteMethodName)
+ return false;
+
+ var pxDatabaseType = pxContext.PXDatabase.Type;
+ if (pxDatabaseType is null)
+ return false;
+
+ return SymbolEqualityComparer.Default.Equals(methodSymbol.ContainingType, pxDatabaseType);
+ }
+
+ private static string? TryGetStringValue(ExpressionSyntax expression, SemanticModel semanticModel, System.Threading.CancellationToken cancellationToken)
+ {
+ var constant = semanticModel.GetConstantValue(expression, cancellationToken);
+ if (constant.HasValue && constant.Value is string constStr)
+ return constStr;
+
+ if (expression is InterpolatedStringExpressionSyntax interp)
+ {
+ var sb = new StringBuilder();
+ bool any = false;
+ foreach (var content in interp.Contents)
+ {
+ if (content is InterpolatedStringTextSyntax text)
+ {
+ sb.Append(text.TextToken.ValueText);
+ any = true;
+ }
+ else
+ {
+ sb.Append(InterpolationHolePlaceholder);
+ any = true;
+ }
+ }
+ return any ? sb.ToString() : null;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Per-mutation guard detection. For each schema mutation in the sanitized SQL,
+ /// scan the preceding text and find the latest match of either a guard pattern
+ /// or an action-statement keyword. A guard occluded by a later action keyword
+ /// (e.g. an unrelated PRINT ) no longer counts.
+ ///
+ private static bool ContainsUnguardedSchemaMutation(string sql)
+ {
+ string sanitized = StripCommentsAndStringLiterals(sql);
+
+ var mutations = new List();
+ foreach (Match m in AlterTableAddPattern.Matches(sanitized)) mutations.Add(m);
+ foreach (Match m in CreateTablePattern.Matches(sanitized)) mutations.Add(m);
+ foreach (Match m in CreateIndexPattern.Matches(sanitized)) mutations.Add(m);
+
+ foreach (var mutation in mutations)
+ {
+ if (!IsGuardedAt(sanitized, mutation.Index))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsGuardedAt(string sanitized, int mutationIndex)
+ {
+ // Scope the search to the current ;-chunk (defensive boundary).
+ int chunkStart = mutationIndex > 0
+ ? sanitized.LastIndexOf(';', mutationIndex - 1) + 1
+ : 0;
+ if (chunkStart < 0) chunkStart = 0;
+ if (chunkStart >= mutationIndex) return false;
+
+ string chunk = sanitized.Substring(chunkStart, mutationIndex - chunkStart);
+
+ int latestGuardIdx = -1;
+ foreach (Match m in GuardPattern.Matches(chunk))
+ {
+ if (m.Index > latestGuardIdx) latestGuardIdx = m.Index;
+ }
+
+ if (latestGuardIdx == -1) return false;
+
+ int latestActionIdx = -1;
+ foreach (Match m in ActionStatementKeywords.Matches(chunk))
+ {
+ if (m.Index > latestActionIdx) latestActionIdx = m.Index;
+ }
+
+ // Guard applies only if it appears AFTER the last action keyword.
+ return latestGuardIdx > latestActionIdx;
+ }
+
+ ///
+ /// Strip SQL comments (-- line, /* block */) and string literals ('...')
+ /// so keyword matching does not match keywords appearing inside them.
+ /// Replaces stripped characters with whitespace (preserving newlines).
+ ///
+ private static string StripCommentsAndStringLiterals(string sql)
+ {
+ var sb = new StringBuilder(sql.Length);
+ int i = 0;
+ while (i < sql.Length)
+ {
+ char c = sql[i];
+
+ if (c == '-' && i + 1 < sql.Length && sql[i + 1] == '-')
+ {
+ while (i < sql.Length && sql[i] != '\n')
+ {
+ sb.Append(' ');
+ i++;
+ }
+ continue;
+ }
+
+ if (c == '/' && i + 1 < sql.Length && sql[i + 1] == '*')
+ {
+ sb.Append(" ");
+ i += 2;
+ while (i + 1 < sql.Length && !(sql[i] == '*' && sql[i + 1] == '/'))
+ {
+ sb.Append(sql[i] == '\n' ? '\n' : ' ');
+ i++;
+ }
+ if (i + 1 < sql.Length)
+ {
+ sb.Append(" ");
+ i += 2;
+ }
+ continue;
+ }
+
+ if (c == '\'')
+ {
+ sb.Append(' ');
+ i++;
+ while (i < sql.Length)
+ {
+ if (sql[i] == '\'')
+ {
+ if (i + 1 < sql.Length && sql[i + 1] == '\'')
+ {
+ sb.Append(" ");
+ i += 2;
+ continue;
+ }
+ sb.Append(' ');
+ i++;
+ break;
+ }
+ sb.Append(sql[i] == '\n' ? '\n' : ' ');
+ i++;
+ }
+ continue;
+ }
+
+ sb.Append(c);
+ i++;
+ }
+ return sb.ToString();
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardTests.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardTests.cs
new file mode 100644
index 000000000..74ef8e47f
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardTests.cs
@@ -0,0 +1,108 @@
+using System.Threading.Tasks;
+
+using Acuminator.Analyzers.StaticAnalysis;
+using Acuminator.Analyzers.StaticAnalysis.MissingSchemaMutationGuard;
+using Acuminator.Tests.Helpers;
+using Acuminator.Tests.Verification;
+using Acuminator.Utilities;
+
+using Microsoft.CodeAnalysis.Diagnostics;
+
+using Xunit;
+
+namespace Acuminator.Tests.Tests.StaticAnalysis.MissingSchemaMutationGuard
+{
+ public class MissingSchemaMutationGuardTests : DiagnosticVerifier
+ {
+ protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() =>
+ new MissingSchemaMutationGuardAnalyzer(
+ CodeAnalysisSettings.Default
+ .WithStaticAnalysisEnabled()
+ .WithSuppressionMechanismDisabled());
+
+ #region Positive cases — diagnostic expected
+
+ [Theory]
+ [EmbeddedFileData("Diagnostic_BareAlterTableAdd.cs")]
+ public Task BareAlterTableAdd_ShouldReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source,
+ Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4));
+
+ [Theory]
+ [EmbeddedFileData("Diagnostic_MultipleUnguardedMutations.cs")]
+ public Task MultipleUnguardedMutations_ShouldReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source,
+ Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4));
+
+ [Theory]
+ [EmbeddedFileData("Diagnostic_UnrelatedGuardDoesNotSatisfyMutation.cs")]
+ public Task UnrelatedGuardDoesNotSatisfyMutation_ShouldReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source,
+ Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4));
+
+ [Theory]
+ [EmbeddedFileData("Diagnostic_ConstantStringConcatenation.cs")]
+ public Task ConstantStringConcatenation_ShouldReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source,
+ Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4));
+
+ [Theory]
+ [EmbeddedFileData("Diagnostic_InterpolatedIdentifiers.cs")]
+ public Task InterpolatedIdentifiers_ShouldReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source,
+ Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(11, 4));
+
+ [Theory]
+ [EmbeddedFileData("Diagnostic_BareCreateTable.cs")]
+ public Task BareCreateTable_ShouldReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source,
+ Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4));
+
+ [Theory]
+ [EmbeddedFileData("Diagnostic_BareCreateIndex.cs")]
+ public Task BareCreateIndex_ShouldReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source,
+ Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4));
+
+ #endregion
+
+ #region Negative cases — no diagnostic expected
+
+ [Theory]
+ [EmbeddedFileData("NoDiagnostic_ColLengthGuard.cs")]
+ public Task ColLengthGuard_ShouldNotReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source);
+
+ [Theory]
+ [EmbeddedFileData("NoDiagnostic_IfNotExistsSysColumns.cs")]
+ public Task IfNotExistsSysColumns_ShouldNotReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source);
+
+ [Theory]
+ [EmbeddedFileData("NoDiagnostic_ObjectIdGuardCreateTable.cs")]
+ public Task ObjectIdGuardCreateTable_ShouldNotReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source);
+
+ [Theory]
+ [EmbeddedFileData("NoDiagnostic_IndexPropertyGuardCreateIndex.cs")]
+ public Task IndexPropertyGuardCreateIndex_ShouldNotReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source);
+
+ [Theory]
+ [EmbeddedFileData("NoDiagnostic_BeginEndBlockUnderGuard.cs")]
+ public Task BeginEndBlockUnderGuard_ShouldNotReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source);
+
+ [Theory]
+ [EmbeddedFileData("NoDiagnostic_NonSchemaMutationPlugin.cs")]
+ public Task NonSchemaMutation_ShouldNotReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source);
+
+ [Theory]
+ [EmbeddedFileData("NoDiagnostic_AlterInCommentIsIgnored.cs")]
+ public Task AlterInCommentIsIgnored_ShouldNotReport(string source) =>
+ VerifyCSharpDiagnosticAsync(source);
+
+ #endregion
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareAlterTableAdd.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareAlterTableAdd.cs
new file mode 100644
index 000000000..c7656ffaf
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareAlterTableAdd.cs
@@ -0,0 +1,14 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_BareAlterTableAdd
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ ALTER TABLE SOOrder ADD UsrPriority int NULL
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateIndex.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateIndex.cs
new file mode 100644
index 000000000..f2c3cf53f
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateIndex.cs
@@ -0,0 +1,14 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_BareCreateIndex
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ CREATE INDEX IX_SOOrder_UsrPriority ON SOOrder(UsrPriority)
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateTable.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateTable.cs
new file mode 100644
index 000000000..badde6124
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateTable.cs
@@ -0,0 +1,17 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_BareCreateTable
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ CREATE TABLE UsrCustomLookup (
+ Id int IDENTITY(1,1) PRIMARY KEY,
+ Code nvarchar(32) NOT NULL
+ )
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_ConstantStringConcatenation.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_ConstantStringConcatenation.cs
new file mode 100644
index 000000000..a10af60d5
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_ConstantStringConcatenation.cs
@@ -0,0 +1,12 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_ConstantStringConcatenation
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute("ALTER TABLE SOOrder " + "ADD UsrPriority int NULL");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_InterpolatedIdentifiers.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_InterpolatedIdentifiers.cs
new file mode 100644
index 000000000..7e22984e2
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_InterpolatedIdentifiers.cs
@@ -0,0 +1,14 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_InterpolatedIdentifiers
+ {
+ public void UpdateDatabase()
+ {
+ string tableName = "SOOrder";
+ string columnName = "UsrPriority";
+ PXDatabase.Execute($"ALTER TABLE {tableName} ADD {columnName} int NULL");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_MultipleUnguardedMutations.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_MultipleUnguardedMutations.cs
new file mode 100644
index 000000000..0fde65a70
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_MultipleUnguardedMutations.cs
@@ -0,0 +1,15 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_MultipleUnguardedMutations
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ ALTER TABLE SOOrder ADD UsrPriority int NULL;
+ ALTER TABLE POOrder ADD UsrPriority int NULL;
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_UnrelatedGuardDoesNotSatisfyMutation.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_UnrelatedGuardDoesNotSatisfyMutation.cs
new file mode 100644
index 000000000..1197c48f5
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_UnrelatedGuardDoesNotSatisfyMutation.cs
@@ -0,0 +1,16 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_UnrelatedGuard
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'OtherTable')
+ PRINT 'logging note';
+ ALTER TABLE SOOrder ADD UsrPriority int NULL
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_AlterInCommentIsIgnored.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_AlterInCommentIsIgnored.cs
new file mode 100644
index 000000000..783fd7a6a
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_AlterInCommentIsIgnored.cs
@@ -0,0 +1,18 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ // The word ALTER appears only inside a SQL comment and a string literal —
+ // neither should trigger the diagnostic.
+ public class MyPlugin_AlterInCommentIsIgnored
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ -- ALTER TABLE SOOrder ADD UsrPriority int NULL (commented out)
+ /* ALTER TABLE POOrder ADD UsrPriority int NULL (block comment) */
+ INSERT INTO UsrAuditLog (Message) VALUES ('ALTER TABLE is documented here')
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_BeginEndBlockUnderGuard.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_BeginEndBlockUnderGuard.cs
new file mode 100644
index 000000000..f6c28a1d3
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_BeginEndBlockUnderGuard.cs
@@ -0,0 +1,17 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_BeginEndBlockUnderGuard
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ IF COL_LENGTH('SOOrder', 'UsrPriority') IS NULL
+ BEGIN
+ ALTER TABLE SOOrder ADD UsrPriority int NULL;
+ END
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ColLengthGuard.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ColLengthGuard.cs
new file mode 100644
index 000000000..9d5788333
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ColLengthGuard.cs
@@ -0,0 +1,15 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_ColLengthGuard
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ IF COL_LENGTH('SOOrder', 'UsrPriority') IS NULL
+ ALTER TABLE SOOrder ADD UsrPriority int NULL
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IfNotExistsSysColumns.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IfNotExistsSysColumns.cs
new file mode 100644
index 000000000..db926038f
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IfNotExistsSysColumns.cs
@@ -0,0 +1,18 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_IfNotExistsSysColumns
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.columns
+ WHERE Name = 'UsrPriority' AND Object_ID = OBJECT_ID('SOOrder')
+ )
+ ALTER TABLE SOOrder ADD UsrPriority int NULL
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IndexPropertyGuardCreateIndex.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IndexPropertyGuardCreateIndex.cs
new file mode 100644
index 000000000..fa214ebb7
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IndexPropertyGuardCreateIndex.cs
@@ -0,0 +1,15 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_IndexPropertyGuardCreateIndex
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ IF INDEXPROPERTY(OBJECT_ID('SOOrder'), 'IX_SOOrder_UsrPriority', 'IndexID') IS NULL
+ CREATE INDEX IX_SOOrder_UsrPriority ON SOOrder(UsrPriority)
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_NonSchemaMutationPlugin.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_NonSchemaMutationPlugin.cs
new file mode 100644
index 000000000..cc5dad0c5
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_NonSchemaMutationPlugin.cs
@@ -0,0 +1,16 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ // Plugin that calls PXDatabase.Execute with non-DDL SQL — no schema mutation.
+ public class MyPlugin_NonSchemaMutation
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ INSERT INTO UsrAuditLog (EventTime, UserID, Action)
+ VALUES (GETDATE(), 'system', 'plugin-init')
+ ");
+ }
+ }
+}
diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ObjectIdGuardCreateTable.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ObjectIdGuardCreateTable.cs
new file mode 100644
index 000000000..c13d3c056
--- /dev/null
+++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ObjectIdGuardCreateTable.cs
@@ -0,0 +1,19 @@
+using PX.Data;
+
+namespace Acuminator.Tests.Sources
+{
+ public class MyPlugin_ObjectIdGuardCreateTable
+ {
+ public void UpdateDatabase()
+ {
+ PXDatabase.Execute(@"
+ IF OBJECT_ID('UsrCustomLookup', 'U') IS NULL
+ CREATE TABLE UsrCustomLookup (
+ Id int IDENTITY(1,1) PRIMARY KEY,
+ Code nvarchar(32) NOT NULL,
+ Description nvarchar(256) NULL
+ )
+ ");
+ }
+ }
+}