From bc4d2dd6743d8c18fffcf18d4e15a36c70e9966e Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 24 Jun 2026 13:58:53 +0200 Subject: [PATCH 1/5] feat: add analyzer exception harness in ALCops.Common Add ALCopsDiagnosticAnalyzer base class plus SafeAnalysisContext and SafeCompilationStartContext decorators that wrap every registered analysis callback in try/catch. An unhandled exception is converted into a located XX0000 diagnostic at the symbol/node being analyzed instead of surfacing as the SDK's AD0001 on app.json line 1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Diagnostics/ALCopsDiagnosticAnalyzer.cs | 54 +++++++ .../Diagnostics/AnalyzerExceptionReporter.cs | 25 ++++ .../Diagnostics/SafeAnalysisContext.cs | 137 ++++++++++++++++++ .../SafeCompilationStartContext.cs | 112 ++++++++++++++ 4 files changed, 328 insertions(+) create mode 100644 src/ALCops.Common/Diagnostics/ALCopsDiagnosticAnalyzer.cs create mode 100644 src/ALCops.Common/Diagnostics/AnalyzerExceptionReporter.cs create mode 100644 src/ALCops.Common/Diagnostics/SafeAnalysisContext.cs create mode 100644 src/ALCops.Common/Diagnostics/SafeCompilationStartContext.cs diff --git a/src/ALCops.Common/Diagnostics/ALCopsDiagnosticAnalyzer.cs b/src/ALCops.Common/Diagnostics/ALCopsDiagnosticAnalyzer.cs new file mode 100644 index 0000000..b39b866 --- /dev/null +++ b/src/ALCops.Common/Diagnostics/ALCopsDiagnosticAnalyzer.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; + +namespace ALCops.Common.Diagnostics; + +/// +/// Base class for every ALCops analyzer. It transparently wraps the analysis +/// callbacks so that an unhandled exception thrown by a rule is reported as a +/// located XX0000 diagnostic (see ) +/// instead of surfacing as the SDK's AD0001 on app.json line 1. +/// +/// Analyzers derive from the per-cop bridge (for example +/// ApplicationCopAnalyzer) rather than from this type directly, override +/// instead of +/// , and override +/// instead of +/// . +/// +public abstract class ALCopsDiagnosticAnalyzer : DiagnosticAnalyzer +{ + /// + /// The cop-specific XX0000 descriptor reported when a rule in this cop + /// throws an unhandled exception. Supplied by the per-cop bridge class. + /// + protected abstract DiagnosticDescriptor AnalyzerExceptionDescriptor { get; } + + /// + /// The descriptors produced by the concrete analyzer. The + /// is appended automatically, so + /// derived analyzers must not list it themselves. + /// + protected abstract ImmutableArray SupportedDiagnosticsCore { get; } + + public sealed override ImmutableArray SupportedDiagnostics + { + get + { + ImmutableArray core = SupportedDiagnosticsCore; + return core.Contains(AnalyzerExceptionDescriptor) + ? core + : core.Add(AnalyzerExceptionDescriptor); + } + } + + public sealed override void Initialize(AnalysisContext context) => + InitializeAnalyzer(new SafeAnalysisContext(context, AnalyzerExceptionDescriptor, GetType().Name)); + + /// + /// Registers the analyzer's actions. The supplied + /// wraps every registered callback so unhandled exceptions become located + /// XX0000 diagnostics. + /// + protected abstract void InitializeAnalyzer(SafeAnalysisContext context); +} diff --git a/src/ALCops.Common/Diagnostics/AnalyzerExceptionReporter.cs b/src/ALCops.Common/Diagnostics/AnalyzerExceptionReporter.cs new file mode 100644 index 0000000..c103f1d --- /dev/null +++ b/src/ALCops.Common/Diagnostics/AnalyzerExceptionReporter.cs @@ -0,0 +1,25 @@ +using Microsoft.Dynamics.Nav.CodeAnalysis; +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using Microsoft.Dynamics.Nav.CodeAnalysis.Text; + +namespace ALCops.Common.Diagnostics; + +/// +/// Builds the XX0000 diagnostic reported when an analyzer callback throws +/// an unhandled exception. The message mirrors the useful part of the SDK's +/// AD0001 text but is attached to the real location. +/// +internal static class AnalyzerExceptionReporter +{ + public static Diagnostic CreateDiagnostic( + DiagnosticDescriptor descriptor, + Location? location, + string analyzerName, + Exception exception) => + Diagnostic.Create( + descriptor, + location ?? Location.None, + analyzerName, + exception.GetType().ToString(), + exception.Message); +} diff --git a/src/ALCops.Common/Diagnostics/SafeAnalysisContext.cs b/src/ALCops.Common/Diagnostics/SafeAnalysisContext.cs new file mode 100644 index 0000000..f5f5f82 --- /dev/null +++ b/src/ALCops.Common/Diagnostics/SafeAnalysisContext.cs @@ -0,0 +1,137 @@ +using System.Collections.Immutable; +using Microsoft.Dynamics.Nav.CodeAnalysis; +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using Microsoft.Dynamics.Nav.CodeAnalysis.Text; + +namespace ALCops.Common.Diagnostics; + +/// +/// An decorator that forwards every registration to +/// the real context but wraps each callback in a try/catch. When a callback throws +/// an unhandled exception (other than cancellation), it is reported as a located +/// XX0000 diagnostic instead of bubbling up to the SDK as AD0001. +/// +/// Note on the SDK coupling: this type subclasses the SDK's abstract +/// . If a future SDK version adds a new public-abstract +/// Register* method, this class will fail to compile until an override is +/// added here. That compile-time break is intentional - it forces wrapping of any +/// new registration surface. +/// +public sealed class SafeAnalysisContext : AnalysisContext +{ + private readonly AnalysisContext _inner; + private readonly DiagnosticDescriptor _descriptor; + private readonly string _analyzerName; + + internal SafeAnalysisContext(AnalysisContext inner, DiagnosticDescriptor descriptor, string analyzerName) + { + _inner = inner; + _descriptor = descriptor; + _analyzerName = analyzerName; + } + + public override void RegisterSymbolAction(Action action, ImmutableArray symbolKinds) => + _inner.RegisterSymbolAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, ctx.Symbol.GetLocation(), _analyzerName, ex)); + } + }, + symbolKinds); + + public override void RegisterSyntaxNodeAction(Action action, ImmutableArray syntaxKinds) => + _inner.RegisterSyntaxNodeAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, ctx.Node.GetLocation(), _analyzerName, ex)); + } + }, + syntaxKinds); + + public override void RegisterCodeBlockAction(Action action) => + _inner.RegisterCodeBlockAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, ctx.CodeBlock.GetLocation(), _analyzerName, ex)); + } + }); + + public override void RegisterCompilationAction(Action action) => + _inner.RegisterCompilationAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, Location.None, _analyzerName, ex)); + } + }); + + public override void RegisterSemanticModelAction(Action action) => + _inner.RegisterSemanticModelAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, Location.None, _analyzerName, ex)); + } + }); + + public override void RegisterSyntaxTreeAction(Action action) => + _inner.RegisterSyntaxTreeAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, Location.None, _analyzerName, ex)); + } + }); + + public override void RegisterCompilationStartAction(Action action) => + _inner.RegisterCompilationStartAction( + startContext => action(new SafeCompilationStartContext(startContext, _descriptor, _analyzerName))); + + // CodeBlockStart exposes its own start context with nested registrations. No + // ALCops analyzer uses it today; forward unwrapped so it stays functional. + public override void RegisterCodeBlockStartAction(Action action) => + _inner.RegisterCodeBlockStartAction(action); + + // Operation registration cannot be intercepted through the public abstract + // surface (the params overload routes through internal members we cannot + // override). These 'new' overloads win because analyzers reference the + // SafeAnalysisContext type, and they forward to the inner context's public + // params overload, which the real context implements. + public new void RegisterOperationAction(Action action, params OperationKind[] operationKinds) => + _inner.RegisterOperationAction(WrapOperation(action), operationKinds); + + public new void RegisterOperationAction(Action action, ImmutableArray operationKinds) => + _inner.RegisterOperationAction(WrapOperation(action), operationKinds.ToArray()); + + private Action WrapOperation(Action action) => + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, ctx.Operation.Syntax.GetLocation(), _analyzerName, ex)); + } + }; +} diff --git a/src/ALCops.Common/Diagnostics/SafeCompilationStartContext.cs b/src/ALCops.Common/Diagnostics/SafeCompilationStartContext.cs new file mode 100644 index 0000000..b212dde --- /dev/null +++ b/src/ALCops.Common/Diagnostics/SafeCompilationStartContext.cs @@ -0,0 +1,112 @@ +using System.Collections.Immutable; +using Microsoft.Dynamics.Nav.CodeAnalysis; +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using Microsoft.Dynamics.Nav.CodeAnalysis.Text; + +namespace ALCops.Common.Diagnostics; + +/// +/// A decorator that wraps the +/// nested registrations made inside a RegisterCompilationStartAction +/// callback. Because every registration method on +/// is public-abstract, these +/// overrides intercept the nested callbacks via virtual dispatch even when the +/// analyzer's callback variable is typed as the SDK base type - so no call-site +/// changes are needed in analyzers that use RegisterCompilationStartAction. +/// +public sealed class SafeCompilationStartContext : CompilationStartAnalysisContext +{ + private readonly CompilationStartAnalysisContext _inner; + private readonly DiagnosticDescriptor _descriptor; + private readonly string _analyzerName; + + internal SafeCompilationStartContext( + CompilationStartAnalysisContext inner, + DiagnosticDescriptor descriptor, + string analyzerName) + : base(inner.Compilation, inner.Options, inner.CancellationToken) + { + _inner = inner; + _descriptor = descriptor; + _analyzerName = analyzerName; + } + + public override void RegisterSymbolAction(Action action, ImmutableArray symbolKinds) => + _inner.RegisterSymbolAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, ctx.Symbol.GetLocation(), _analyzerName, ex)); + } + }, + symbolKinds); + + public override void RegisterSyntaxNodeAction(Action action, ImmutableArray syntaxKinds) => + _inner.RegisterSyntaxNodeAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, ctx.Node.GetLocation(), _analyzerName, ex)); + } + }, + syntaxKinds); + + public override void RegisterCodeBlockAction(Action action) => + _inner.RegisterCodeBlockAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, ctx.CodeBlock.GetLocation(), _analyzerName, ex)); + } + }); + + public override void RegisterCompilationEndAction(Action action) => + _inner.RegisterCompilationEndAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, Location.None, _analyzerName, ex)); + } + }); + + public override void RegisterSemanticModelAction(Action action) => + _inner.RegisterSemanticModelAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, Location.None, _analyzerName, ex)); + } + }); + + public override void RegisterSyntaxTreeAction(Action action) => + _inner.RegisterSyntaxTreeAction( + ctx => + { + try { action(ctx); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ctx.ReportDiagnostic(AnalyzerExceptionReporter.CreateDiagnostic( + _descriptor, Location.None, _analyzerName, ex)); + } + }); + + // CodeBlockStart exposes its own start context with nested registrations. No + // ALCops analyzer uses it today; forward unwrapped so it stays functional. + public override void RegisterCodeBlockStartAction(Action action) => + _inner.RegisterCodeBlockStartAction(action); +} From 3192fc51df12f62f92c7a7f8e843d5180abe0f34 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 24 Jun 2026 13:59:21 +0200 Subject: [PATCH 2/5] feat: wire XX0000 AnalyzerException descriptor into all 6 cops Add a per-cop XX0000 AnalyzerException diagnostic (Info, enabled), a dedicated Internal category, resx strings, and a thin {Cop}Analyzer bridge that supplies the descriptor to the shared ALCopsDiagnosticAnalyzer base. No production analyzer is converted yet; the scaffolding lets analyzers adopt the harness via a uniform 3-line change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ALCops.ApplicationCopAnalyzers.resx | 9 +++++++++ .../ApplicationCopAnalyzer.cs | 15 +++++++++++++++ .../DiagnosticDescriptors.cs | 16 ++++++++++++++++ src/ALCops.ApplicationCop/DiagnosticIds.cs | 1 + .../ALCops.DocumentationCopAnalyzers.resx | 9 +++++++++ .../DiagnosticDescriptors.cs | 16 ++++++++++++++++ src/ALCops.DocumentationCop/DiagnosticIds.cs | 1 + .../DocumentationCopAnalyzer.cs | 15 +++++++++++++++ .../ALCops.FormattingCopAnalyzers.resx | 9 +++++++++ .../DiagnosticDescriptors.cs | 16 ++++++++++++++++ src/ALCops.FormattingCop/DiagnosticIds.cs | 1 + .../FormattingCopAnalyzer.cs | 15 +++++++++++++++ .../ALCops.LinterCopAnalyzers.resx | 9 +++++++++ src/ALCops.LinterCop/DiagnosticDescriptors.cs | 16 ++++++++++++++++ src/ALCops.LinterCop/DiagnosticIds.cs | 1 + src/ALCops.LinterCop/LinterCopAnalyzer.cs | 15 +++++++++++++++ .../ALCops.PlatformCopAnalyzers.resx | 9 +++++++++ src/ALCops.PlatformCop/DiagnosticDescriptors.cs | 16 ++++++++++++++++ src/ALCops.PlatformCop/DiagnosticIds.cs | 1 + src/ALCops.PlatformCop/PlatformCopAnalyzer.cs | 15 +++++++++++++++ .../ALCops.TestAutomationCopAnalyzers.resx | 9 +++++++++ .../DiagnosticDescriptors.cs | 16 ++++++++++++++++ src/ALCops.TestAutomationCop/DiagnosticIds.cs | 1 + .../TestAutomationCopAnalyzer.cs | 15 +++++++++++++++ 24 files changed, 246 insertions(+) create mode 100644 src/ALCops.ApplicationCop/ApplicationCopAnalyzer.cs create mode 100644 src/ALCops.DocumentationCop/DocumentationCopAnalyzer.cs create mode 100644 src/ALCops.FormattingCop/FormattingCopAnalyzer.cs create mode 100644 src/ALCops.LinterCop/LinterCopAnalyzer.cs create mode 100644 src/ALCops.PlatformCop/PlatformCopAnalyzer.cs create mode 100644 src/ALCops.TestAutomationCop/TestAutomationCopAnalyzer.cs diff --git a/src/ALCops.ApplicationCop/ALCops.ApplicationCopAnalyzers.resx b/src/ALCops.ApplicationCop/ALCops.ApplicationCopAnalyzers.resx index 92d1501..767f867 100644 --- a/src/ALCops.ApplicationCop/ALCops.ApplicationCopAnalyzers.resx +++ b/src/ALCops.ApplicationCop/ALCops.ApplicationCopAnalyzers.resx @@ -450,4 +450,13 @@ Zero (0) should be reserved as the empty Enum value. Business Central stores Enums as integers and does not support null; new records (and existing records after adding a field via table extension) default to 0, which makes non-empty meaning at 0 ambiguous. + + Analyzer threw an unhandled exception + + + Analyzer '{0}' threw an exception of type '{1}': {2} + + + An ALCops analyzer threw an unhandled exception while analyzing this object. This is a defect in the analyzer, not in the analyzed code. The diagnostic is reported at the object or line being analyzed (instead of the generic AD0001 on app.json) so the culprit can be located. Please report it to the ALCops maintainers with the object name and exception details. + diff --git a/src/ALCops.ApplicationCop/ApplicationCopAnalyzer.cs b/src/ALCops.ApplicationCop/ApplicationCopAnalyzer.cs new file mode 100644 index 0000000..ef02c53 --- /dev/null +++ b/src/ALCops.ApplicationCop/ApplicationCopAnalyzer.cs @@ -0,0 +1,15 @@ +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using ALCops.Common.Diagnostics; + +namespace ALCops.ApplicationCop; + +/// +/// Base class for every ApplicationCop analyzer. Supplies the cop-specific +/// (AC0000) reported when a +/// rule throws an unhandled exception. See . +/// +public abstract class ApplicationCopAnalyzer : ALCopsDiagnosticAnalyzer +{ + protected sealed override DiagnosticDescriptor AnalyzerExceptionDescriptor => + DiagnosticDescriptors.AnalyzerException; +} diff --git a/src/ALCops.ApplicationCop/DiagnosticDescriptors.cs b/src/ALCops.ApplicationCop/DiagnosticDescriptors.cs index 0d33b81..1af377a 100644 --- a/src/ALCops.ApplicationCop/DiagnosticDescriptors.cs +++ b/src/ALCops.ApplicationCop/DiagnosticDescriptors.cs @@ -335,6 +335,16 @@ public static class DiagnosticDescriptors description: ApplicationCopAnalyzers.ZeroEnumValueReservedForEmptyDescription, helpLinkUri: GetHelpUri(DiagnosticIds.ZeroEnumValueReservedForEmpty)); + public static readonly DiagnosticDescriptor AnalyzerException = new( + id: DiagnosticIds.AnalyzerException, + title: ApplicationCopAnalyzers.AnalyzerExceptionTitle, + messageFormat: ApplicationCopAnalyzers.AnalyzerExceptionMessageFormat, + category: Category.Internal, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: ApplicationCopAnalyzers.AnalyzerExceptionDescription, + helpLinkUri: GetHelpUri(DiagnosticIds.AnalyzerException)); + public static string GetHelpUri(string identifier) { return string.Format(CultureInfo.InvariantCulture, "https://alcops.dev/docs/analyzers/applicationcop/{0}/", identifier.ToLower()); @@ -381,5 +391,11 @@ internal static class Category /// Example: Avoid exposing internal APIs, hard-coded credentials, or missing permission checks. /// public const string Security = "Security"; + + /// + /// Internal issues: failures inside ALCops analyzers themselves + /// (for example an unhandled exception in a rule), not problems in user code. + /// + public const string Internal = "Internal"; } } \ No newline at end of file diff --git a/src/ALCops.ApplicationCop/DiagnosticIds.cs b/src/ALCops.ApplicationCop/DiagnosticIds.cs index a34938d..a0b2eec 100644 --- a/src/ALCops.ApplicationCop/DiagnosticIds.cs +++ b/src/ALCops.ApplicationCop/DiagnosticIds.cs @@ -2,6 +2,7 @@ namespace ALCops.ApplicationCop; public static class DiagnosticIds { + public static readonly string AnalyzerException = "AC0000"; public static readonly string LookupPageIdAndDrillDownPageId = "AC0001"; public static readonly string NotBlankRequiredOnPrimaryKeyField = "AC0002"; public static readonly string NotBlankNotAllowedOnPrimaryKeyField = "AC0003"; diff --git a/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx b/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx index 4dda25b..1e7ec86 100644 --- a/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx +++ b/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx @@ -207,4 +207,13 @@ Internal events can be part of the exposed API (internalsVisibleTo) and should be explicitly documented to justify their availability. XML documentation comments communicate intent, usage, and guarantees to consumers of the extension. + + Analyzer threw an unhandled exception + + + Analyzer '{0}' threw an exception of type '{1}': {2} + + + An ALCops analyzer threw an unhandled exception while analyzing this object. This is a defect in the analyzer, not in the analyzed code. The diagnostic is reported at the object or line being analyzed (instead of the generic AD0001 on app.json) so the culprit can be located. Please report it to the ALCops maintainers with the object name and exception details. + \ No newline at end of file diff --git a/src/ALCops.DocumentationCop/DiagnosticDescriptors.cs b/src/ALCops.DocumentationCop/DiagnosticDescriptors.cs index 514071d..4f5b7f6 100644 --- a/src/ALCops.DocumentationCop/DiagnosticDescriptors.cs +++ b/src/ALCops.DocumentationCop/DiagnosticDescriptors.cs @@ -105,6 +105,16 @@ public static class DiagnosticDescriptors description: DocumentationCopAnalyzers.InternalEventRequiresDocumentationDescription, helpLinkUri: GetHelpUri(DiagnosticIds.InternalEventRequiresDocumentation)); + public static readonly DiagnosticDescriptor AnalyzerException = new( + id: DiagnosticIds.AnalyzerException, + title: DocumentationCopAnalyzers.AnalyzerExceptionTitle, + messageFormat: DocumentationCopAnalyzers.AnalyzerExceptionMessageFormat, + category: Category.Internal, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: DocumentationCopAnalyzers.AnalyzerExceptionDescription, + helpLinkUri: GetHelpUri(DiagnosticIds.AnalyzerException)); + public static string GetHelpUri(string identifier) { return string.Format(CultureInfo.InvariantCulture, "https://alcops.dev/docs/analyzers/documentationcop/{0}/", identifier.ToLower()); @@ -151,5 +161,11 @@ internal static class Category /// Example: Avoid exposing internal APIs, hard-coded credentials, or missing permission checks. /// public const string Security = "Security"; + + /// + /// Internal issues: failures inside ALCops analyzers themselves + /// (for example an unhandled exception in a rule), not problems in user code. + /// + public const string Internal = "Internal"; } } \ No newline at end of file diff --git a/src/ALCops.DocumentationCop/DiagnosticIds.cs b/src/ALCops.DocumentationCop/DiagnosticIds.cs index 1493e9d..d996165 100644 --- a/src/ALCops.DocumentationCop/DiagnosticIds.cs +++ b/src/ALCops.DocumentationCop/DiagnosticIds.cs @@ -2,6 +2,7 @@ namespace ALCops.DocumentationCop; public static class DiagnosticIds { + public static readonly string AnalyzerException = "DC0000"; public static readonly string CommitRequiresComment = "DC0001"; public static readonly string WriteToFlowFieldRequiresComment = "DC0002"; public static readonly string EmptyStatementRequiresComment = "DC0003"; diff --git a/src/ALCops.DocumentationCop/DocumentationCopAnalyzer.cs b/src/ALCops.DocumentationCop/DocumentationCopAnalyzer.cs new file mode 100644 index 0000000..313526d --- /dev/null +++ b/src/ALCops.DocumentationCop/DocumentationCopAnalyzer.cs @@ -0,0 +1,15 @@ +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using ALCops.Common.Diagnostics; + +namespace ALCops.DocumentationCop; + +/// +/// Base class for every DocumentationCop analyzer. Supplies the cop-specific +/// (DC0000) reported when a +/// rule throws an unhandled exception. See . +/// +public abstract class DocumentationCopAnalyzer : ALCopsDiagnosticAnalyzer +{ + protected sealed override DiagnosticDescriptor AnalyzerExceptionDescriptor => + DiagnosticDescriptors.AnalyzerException; +} diff --git a/src/ALCops.FormattingCop/ALCops.FormattingCopAnalyzers.resx b/src/ALCops.FormattingCop/ALCops.FormattingCopAnalyzers.resx index aa75d3f..3419ce0 100644 --- a/src/ALCops.FormattingCop/ALCops.FormattingCopAnalyzers.resx +++ b/src/ALCops.FormattingCop/ALCops.FormattingCopAnalyzers.resx @@ -162,4 +162,13 @@ ALCops: Sort permission declarations alphabetically + + Analyzer threw an unhandled exception + + + Analyzer '{0}' threw an exception of type '{1}': {2} + + + An ALCops analyzer threw an unhandled exception while analyzing this object. This is a defect in the analyzer, not in the analyzed code. The diagnostic is reported at the object or line being analyzed (instead of the generic AD0001 on app.json) so the culprit can be located. Please report it to the ALCops maintainers with the object name and exception details. + \ No newline at end of file diff --git a/src/ALCops.FormattingCop/DiagnosticDescriptors.cs b/src/ALCops.FormattingCop/DiagnosticDescriptors.cs index 9829965..b43baa8 100644 --- a/src/ALCops.FormattingCop/DiagnosticDescriptors.cs +++ b/src/ALCops.FormattingCop/DiagnosticDescriptors.cs @@ -47,6 +47,16 @@ public static class DiagnosticDescriptors helpLinkUri: GetHelpUri(DiagnosticIds.PermissionDeclarationOrder)); + public static readonly DiagnosticDescriptor AnalyzerException = new( + id: DiagnosticIds.AnalyzerException, + title: FormattingCopAnalyzers.AnalyzerExceptionTitle, + messageFormat: FormattingCopAnalyzers.AnalyzerExceptionMessageFormat, + category: Category.Internal, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: FormattingCopAnalyzers.AnalyzerExceptionDescription, + helpLinkUri: GetHelpUri(DiagnosticIds.AnalyzerException)); + public static string GetHelpUri(string identifier) { return string.Format(CultureInfo.InvariantCulture, "https://alcops.dev/docs/analyzers/formattingcop/{0}/", identifier.ToLower()); @@ -93,5 +103,11 @@ internal static class Category /// Example: Avoid exposing internal APIs, hard-coded credentials, or missing permission checks. /// public const string Security = "Security"; + + /// + /// Internal issues: failures inside ALCops analyzers themselves + /// (for example an unhandled exception in a rule), not problems in user code. + /// + public const string Internal = "Internal"; } } \ No newline at end of file diff --git a/src/ALCops.FormattingCop/DiagnosticIds.cs b/src/ALCops.FormattingCop/DiagnosticIds.cs index 3e2442f..be49fc9 100644 --- a/src/ALCops.FormattingCop/DiagnosticIds.cs +++ b/src/ALCops.FormattingCop/DiagnosticIds.cs @@ -2,6 +2,7 @@ namespace ALCops.FormattingCop; public static class DiagnosticIds { + public static readonly string AnalyzerException = "FC0000"; public static readonly string SemicolonAfterMethodOrTriggerDeclaration = "FC0001"; public static readonly string CasingMismatch = "FC0002"; public static readonly string UseParenthesisForFunctionCall = "FC0003"; diff --git a/src/ALCops.FormattingCop/FormattingCopAnalyzer.cs b/src/ALCops.FormattingCop/FormattingCopAnalyzer.cs new file mode 100644 index 0000000..b97f1a3 --- /dev/null +++ b/src/ALCops.FormattingCop/FormattingCopAnalyzer.cs @@ -0,0 +1,15 @@ +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using ALCops.Common.Diagnostics; + +namespace ALCops.FormattingCop; + +/// +/// Base class for every FormattingCop analyzer. Supplies the cop-specific +/// (FC0000) reported when a +/// rule throws an unhandled exception. See . +/// +public abstract class FormattingCopAnalyzer : ALCopsDiagnosticAnalyzer +{ + protected sealed override DiagnosticDescriptor AnalyzerExceptionDescriptor => + DiagnosticDescriptors.AnalyzerException; +} diff --git a/src/ALCops.LinterCop/ALCops.LinterCopAnalyzers.resx b/src/ALCops.LinterCop/ALCops.LinterCopAnalyzers.resx index 2eede90..b32bbf5 100644 --- a/src/ALCops.LinterCop/ALCops.LinterCopAnalyzers.resx +++ b/src/ALCops.LinterCop/ALCops.LinterCopAnalyzers.resx @@ -411,4 +411,13 @@ ALCops: Remove unreferenced parameter + + Analyzer threw an unhandled exception + + + Analyzer '{0}' threw an exception of type '{1}': {2} + + + An ALCops analyzer threw an unhandled exception while analyzing this object. This is a defect in the analyzer, not in the analyzed code. The diagnostic is reported at the object or line being analyzed (instead of the generic AD0001 on app.json) so the culprit can be located. Please report it to the ALCops maintainers with the object name and exception details. + \ No newline at end of file diff --git a/src/ALCops.LinterCop/DiagnosticDescriptors.cs b/src/ALCops.LinterCop/DiagnosticDescriptors.cs index 63b2c95..d7700eb 100644 --- a/src/ALCops.LinterCop/DiagnosticDescriptors.cs +++ b/src/ALCops.LinterCop/DiagnosticDescriptors.cs @@ -314,6 +314,16 @@ public static class DiagnosticDescriptors description: LinterCopAnalyzers.UseSecretTextForSensitiveTextDescription, helpLinkUri: GetHelpUri(DiagnosticIds.UseSecretTextForSensitiveText)); + public static readonly DiagnosticDescriptor AnalyzerException = new( + id: DiagnosticIds.AnalyzerException, + title: LinterCopAnalyzers.AnalyzerExceptionTitle, + messageFormat: LinterCopAnalyzers.AnalyzerExceptionMessageFormat, + category: Category.Internal, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: LinterCopAnalyzers.AnalyzerExceptionDescription, + helpLinkUri: GetHelpUri(DiagnosticIds.AnalyzerException)); + public static string GetHelpUri(string identifier) { return string.Format(CultureInfo.InvariantCulture, "https://alcops.dev/docs/analyzers/lintercop/{0}/", identifier.ToLower()); @@ -360,5 +370,11 @@ internal static class Category /// Example: Avoid exposing internal APIs, hard-coded credentials, or missing permission checks. /// public const string Security = "Security"; + + /// + /// Internal issues: failures inside ALCops analyzers themselves + /// (for example an unhandled exception in a rule), not problems in user code. + /// + public const string Internal = "Internal"; } } \ No newline at end of file diff --git a/src/ALCops.LinterCop/DiagnosticIds.cs b/src/ALCops.LinterCop/DiagnosticIds.cs index 7581e76..72a7700 100644 --- a/src/ALCops.LinterCop/DiagnosticIds.cs +++ b/src/ALCops.LinterCop/DiagnosticIds.cs @@ -2,6 +2,7 @@ namespace ALCops.LinterCop; public static class DiagnosticIds { + public static readonly string AnalyzerException = "LC0000"; public static readonly string ObjectIdInDeclaration = "LC0003"; public static readonly string RecordInstanceIsolationLevel = "LC0031"; public static readonly string MaintainabilityIndexMetric = "LC0007"; diff --git a/src/ALCops.LinterCop/LinterCopAnalyzer.cs b/src/ALCops.LinterCop/LinterCopAnalyzer.cs new file mode 100644 index 0000000..628ceb1 --- /dev/null +++ b/src/ALCops.LinterCop/LinterCopAnalyzer.cs @@ -0,0 +1,15 @@ +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using ALCops.Common.Diagnostics; + +namespace ALCops.LinterCop; + +/// +/// Base class for every LinterCop analyzer. Supplies the cop-specific +/// (LC0000) reported when a +/// rule throws an unhandled exception. See . +/// +public abstract class LinterCopAnalyzer : ALCopsDiagnosticAnalyzer +{ + protected sealed override DiagnosticDescriptor AnalyzerExceptionDescriptor => + DiagnosticDescriptors.AnalyzerException; +} diff --git a/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.resx b/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.resx index 46b0ba9..0e81553 100644 --- a/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.resx +++ b/src/ALCops.PlatformCop/ALCops.PlatformCopAnalyzers.resx @@ -495,4 +495,13 @@ ALCops: Use Validate() for field assignment + + Analyzer threw an unhandled exception + + + Analyzer '{0}' threw an exception of type '{1}': {2} + + + An ALCops analyzer threw an unhandled exception while analyzing this object. This is a defect in the analyzer, not in the analyzed code. The diagnostic is reported at the object or line being analyzed (instead of the generic AD0001 on app.json) so the culprit can be located. Please report it to the ALCops maintainers with the object name and exception details. + \ No newline at end of file diff --git a/src/ALCops.PlatformCop/DiagnosticDescriptors.cs b/src/ALCops.PlatformCop/DiagnosticDescriptors.cs index 8a13f8d..e83b8a4 100644 --- a/src/ALCops.PlatformCop/DiagnosticDescriptors.cs +++ b/src/ALCops.PlatformCop/DiagnosticDescriptors.cs @@ -365,6 +365,16 @@ public static class DiagnosticDescriptors description: PlatformCopAnalyzers.UseValidateForFieldAssignmentDescription, helpLinkUri: GetHelpUri(DiagnosticIds.UseValidateForFieldAssignment)); + public static readonly DiagnosticDescriptor AnalyzerException = new( + id: DiagnosticIds.AnalyzerException, + title: PlatformCopAnalyzers.AnalyzerExceptionTitle, + messageFormat: PlatformCopAnalyzers.AnalyzerExceptionMessageFormat, + category: Category.Internal, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: PlatformCopAnalyzers.AnalyzerExceptionDescription, + helpLinkUri: GetHelpUri(DiagnosticIds.AnalyzerException)); + public static string GetHelpUri(string identifier) { return string.Format(CultureInfo.InvariantCulture, "https://alcops.dev/docs/analyzers/platformcop/{0}/", identifier.ToLower()); @@ -411,5 +421,11 @@ internal static class Category /// Example: Avoid exposing internal APIs, hard-coded credentials, or missing permission checks. /// public const string Security = "Security"; + + /// + /// Internal issues: failures inside ALCops analyzers themselves + /// (for example an unhandled exception in a rule), not problems in user code. + /// + public const string Internal = "Internal"; } } \ No newline at end of file diff --git a/src/ALCops.PlatformCop/DiagnosticIds.cs b/src/ALCops.PlatformCop/DiagnosticIds.cs index 6882da0..c7244ab 100644 --- a/src/ALCops.PlatformCop/DiagnosticIds.cs +++ b/src/ALCops.PlatformCop/DiagnosticIds.cs @@ -2,6 +2,7 @@ namespace ALCops.PlatformCop; public static class DiagnosticIds { + public static readonly string AnalyzerException = "PC0000"; public static readonly string EditableFlowField = "PC0001"; public static readonly string AutoIncrementInTemporaryTable = "PC0002"; public static readonly string SetRangeWithFilterOperators = "PC0003"; diff --git a/src/ALCops.PlatformCop/PlatformCopAnalyzer.cs b/src/ALCops.PlatformCop/PlatformCopAnalyzer.cs new file mode 100644 index 0000000..7fdfb75 --- /dev/null +++ b/src/ALCops.PlatformCop/PlatformCopAnalyzer.cs @@ -0,0 +1,15 @@ +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using ALCops.Common.Diagnostics; + +namespace ALCops.PlatformCop; + +/// +/// Base class for every PlatformCop analyzer. Supplies the cop-specific +/// (PC0000) reported when a +/// rule throws an unhandled exception. See . +/// +public abstract class PlatformCopAnalyzer : ALCopsDiagnosticAnalyzer +{ + protected sealed override DiagnosticDescriptor AnalyzerExceptionDescriptor => + DiagnosticDescriptors.AnalyzerException; +} diff --git a/src/ALCops.TestAutomationCop/ALCops.TestAutomationCopAnalyzers.resx b/src/ALCops.TestAutomationCop/ALCops.TestAutomationCopAnalyzers.resx index 92b9f68..841b680 100644 --- a/src/ALCops.TestAutomationCop/ALCops.TestAutomationCopAnalyzers.resx +++ b/src/ALCops.TestAutomationCop/ALCops.TestAutomationCopAnalyzers.resx @@ -129,4 +129,13 @@ Codeunits with Subtype = Test define the executable surface for automated tests and must contain only explicit test entry points. Any global procedure declared in a test codeunit is implicitly exposed as callable logic and therefore must represent an actual test, explicitly marked with the [Test] attribute. A global procedure without this attribute indicates either an incomplete test or misplaced reusable logic. + + Analyzer threw an unhandled exception + + + Analyzer '{0}' threw an exception of type '{1}': {2} + + + An ALCops analyzer threw an unhandled exception while analyzing this object. This is a defect in the analyzer, not in the analyzed code. The diagnostic is reported at the object or line being analyzed (instead of the generic AD0001 on app.json) so the culprit can be located. Please report it to the ALCops maintainers with the object name and exception details. + \ No newline at end of file diff --git a/src/ALCops.TestAutomationCop/DiagnosticDescriptors.cs b/src/ALCops.TestAutomationCop/DiagnosticDescriptors.cs index 60d74c6..27412b9 100644 --- a/src/ALCops.TestAutomationCop/DiagnosticDescriptors.cs +++ b/src/ALCops.TestAutomationCop/DiagnosticDescriptors.cs @@ -15,6 +15,16 @@ public static class DiagnosticDescriptors description: TestAutomationCopAnalyzers.GlobalMethodRequiresTestAttributeDescription, helpLinkUri: GetHelpUri(DiagnosticIds.GlobalMethodRequiresTestAttribute)); + public static readonly DiagnosticDescriptor AnalyzerException = new( + id: DiagnosticIds.AnalyzerException, + title: TestAutomationCopAnalyzers.AnalyzerExceptionTitle, + messageFormat: TestAutomationCopAnalyzers.AnalyzerExceptionMessageFormat, + category: Category.Internal, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: TestAutomationCopAnalyzers.AnalyzerExceptionDescription, + helpLinkUri: GetHelpUri(DiagnosticIds.AnalyzerException)); + public static string GetHelpUri(string identifier) { return string.Format(CultureInfo.InvariantCulture, "https://alcops.dev/docs/analyzers/testautomationCop/{0}/", identifier.ToLower()); @@ -61,5 +71,11 @@ internal static class Category /// Example: Avoid exposing internal APIs, hard-coded credentials, or missing permission checks. /// public const string Security = "Security"; + + /// + /// Internal issues: failures inside ALCops analyzers themselves + /// (for example an unhandled exception in a rule), not problems in user code. + /// + public const string Internal = "Internal"; } } \ No newline at end of file diff --git a/src/ALCops.TestAutomationCop/DiagnosticIds.cs b/src/ALCops.TestAutomationCop/DiagnosticIds.cs index a5400cd..941b50c 100644 --- a/src/ALCops.TestAutomationCop/DiagnosticIds.cs +++ b/src/ALCops.TestAutomationCop/DiagnosticIds.cs @@ -2,5 +2,6 @@ namespace ALCops.TestAutomationCop; public static class DiagnosticIds { + public static readonly string AnalyzerException = "TA0000"; public static readonly string GlobalMethodRequiresTestAttribute = "TA0001"; } \ No newline at end of file diff --git a/src/ALCops.TestAutomationCop/TestAutomationCopAnalyzer.cs b/src/ALCops.TestAutomationCop/TestAutomationCopAnalyzer.cs new file mode 100644 index 0000000..f8b0228 --- /dev/null +++ b/src/ALCops.TestAutomationCop/TestAutomationCopAnalyzer.cs @@ -0,0 +1,15 @@ +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using ALCops.Common.Diagnostics; + +namespace ALCops.TestAutomationCop; + +/// +/// Base class for every TestAutomationCop analyzer. Supplies the cop-specific +/// (TA0000) reported when a +/// rule throws an unhandled exception. See . +/// +public abstract class TestAutomationCopAnalyzer : ALCopsDiagnosticAnalyzer +{ + protected sealed override DiagnosticDescriptor AnalyzerExceptionDescriptor => + DiagnosticDescriptors.AnalyzerException; +} From 8ef63880a4d36b6299cb52fd1f74d469f38eec25 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 24 Jun 2026 13:59:36 +0200 Subject: [PATCH 3/5] feat: adopt analyzer exception harness in CaptionRequired (AC0011) Convert CaptionRequired to derive from ApplicationCopAnalyzer, overriding SupportedDiagnosticsCore and InitializeAnalyzer. No Register* call changes. An unhandled exception now surfaces as AC0000 at the analyzed object instead of AD0001 on app.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs b/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs index 6339820..9b2f06f 100644 --- a/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs +++ b/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs @@ -4,20 +4,21 @@ using Microsoft.Dynamics.Nav.CodeAnalysis; using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; using Microsoft.Dynamics.Nav.CodeAnalysis.Symbols; +using ALCops.Common.Diagnostics; namespace ALCops.ApplicationCop.Analyzers; [DiagnosticAnalyzer] -public sealed class CaptionRequired : DiagnosticAnalyzer +public sealed class CaptionRequired : ApplicationCopAnalyzer { - public override ImmutableArray SupportedDiagnostics { get; } = + protected override ImmutableArray SupportedDiagnosticsCore { get; } = ImmutableArray.Create( DiagnosticDescriptors.CaptionRequired); private static readonly HashSet _predefinedActionCategoryNames = SyntaxFacts.PredefinedActionCategoryNames.Select(x => x.Key.ToLowerInvariant()).ToHashSet(); - public override void Initialize(AnalysisContext context) => + protected override void InitializeAnalyzer(SafeAnalysisContext context) => context.RegisterSymbolAction( CheckForMissingCaptions, EnumProvider.SymbolKind.Page, From f60b94ca8d873f0d5ff1de0c5a288b6bd798f551 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 24 Jun 2026 14:09:15 +0200 Subject: [PATCH 4/5] test: cover analyzer exception harness paths (AC0000) Add test-only throwing analyzers exercising the symbol, operation (new-hiding), and CompilationStart-nested registration surfaces. Each asserts AC0000 is reported at the analyzed object/line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AnalyzerExceptionHarness.cs | 95 +++++++++++++++++++ .../HasDiagnostic/CompilationStartAction.al | 7 ++ .../HasDiagnostic/OperationAction.al | 11 +++ .../HasDiagnostic/SymbolAction.al | 7 ++ 4 files changed, 120 insertions(+) create mode 100644 src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/AnalyzerExceptionHarness.cs create mode 100644 src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/CompilationStartAction.al create mode 100644 src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/OperationAction.al create mode 100644 src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/SymbolAction.al diff --git a/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/AnalyzerExceptionHarness.cs b/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/AnalyzerExceptionHarness.cs new file mode 100644 index 0000000..bdd173a --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/AnalyzerExceptionHarness.cs @@ -0,0 +1,95 @@ +using ALCops.Common.Diagnostics; +using ALCops.Common.Reflection; +using ALCops.ApplicationCop; +using Microsoft.Dynamics.Nav.CodeAnalysis; +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using RoslynTestKit; + +namespace ALCops.ApplicationCop.Test +{ + /// + /// Exercises the shared analyzer-exception harness (XX0000) via test-only + /// analyzers that deliberately throw from each registration surface the harness + /// wraps: a symbol action (matches CaptionRequired), an operation action (the + /// new-hiding path), and a CompilationStart-nested symbol action. Each + /// asserts AC0000 is reported at the analyzed object/line instead of AD0001. + /// + public class AnalyzerExceptionHarness : NavCodeAnalysisBase + { + private string _testCasePath; + + [SetUp] + public void Setup() + { + _testCasePath = Path.Combine( + Directory.GetParent( + Environment.CurrentDirectory)!.Parent!.Parent!.FullName, + Path.Combine("Rules", nameof(AnalyzerExceptionHarness))); + } + + [Test] + public async Task SymbolAction() + { + var fixture = RoslynFixtureFactory.Create(); + var code = await ReadCaseAsync(nameof(SymbolAction)).ConfigureAwait(false); + fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.AnalyzerException); + } + + [Test] + public async Task OperationAction() + { + var fixture = RoslynFixtureFactory.Create(); + var code = await ReadCaseAsync(nameof(OperationAction)).ConfigureAwait(false); + fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.AnalyzerException); + } + + [Test] + public async Task CompilationStartAction() + { + var fixture = RoslynFixtureFactory.Create(); + var code = await ReadCaseAsync(nameof(CompilationStartAction)).ConfigureAwait(false); + fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.AnalyzerException); + } + + private Task ReadCaseAsync(string testCase) => + File.ReadAllTextAsync( + Path.Combine(_testCasePath, "HasDiagnostic", $"{testCase}.al")); + } + + [DiagnosticAnalyzer] + public sealed class ThrowingSymbolAnalyzer : ApplicationCopAnalyzer + { + protected override System.Collections.Immutable.ImmutableArray SupportedDiagnosticsCore { get; } = + System.Collections.Immutable.ImmutableArray.Empty; + + protected override void InitializeAnalyzer(SafeAnalysisContext context) => + context.RegisterSymbolAction( + _ => throw new InvalidOperationException("boom"), + EnumProvider.SymbolKind.Table); + } + + [DiagnosticAnalyzer] + public sealed class ThrowingOperationAnalyzer : ApplicationCopAnalyzer + { + protected override System.Collections.Immutable.ImmutableArray SupportedDiagnosticsCore { get; } = + System.Collections.Immutable.ImmutableArray.Empty; + + protected override void InitializeAnalyzer(SafeAnalysisContext context) => + context.RegisterOperationAction( + _ => throw new InvalidOperationException("boom"), + EnumProvider.OperationKind.InvocationExpression); + } + + [DiagnosticAnalyzer] + public sealed class ThrowingCompilationStartAnalyzer : ApplicationCopAnalyzer + { + protected override System.Collections.Immutable.ImmutableArray SupportedDiagnosticsCore { get; } = + System.Collections.Immutable.ImmutableArray.Empty; + + protected override void InitializeAnalyzer(SafeAnalysisContext context) => + context.RegisterCompilationStartAction( + start => start.RegisterSymbolAction( + _ => throw new InvalidOperationException("boom"), + EnumProvider.SymbolKind.Table)); + } +} diff --git a/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/CompilationStartAction.al b/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/CompilationStartAction.al new file mode 100644 index 0000000..7b25518 --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/CompilationStartAction.al @@ -0,0 +1,7 @@ +table 50101 [|CompStartThrow|] +{ + fields + { + field(1; MyField; Integer) { } + } +} diff --git a/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/OperationAction.al b/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/OperationAction.al new file mode 100644 index 0000000..38e019b --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/OperationAction.al @@ -0,0 +1,11 @@ +codeunit 50102 OpThrow +{ + procedure Caller() + begin + [|Callee()|]; + end; + + local procedure Callee() + begin + end; +} diff --git a/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/SymbolAction.al b/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/SymbolAction.al new file mode 100644 index 0000000..54bc535 --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/SymbolAction.al @@ -0,0 +1,7 @@ +table 50100 [|SymThrow|] +{ + fields + { + field(1; MyField; Integer) { } + } +} From a5b63c3abb44ee6a10196231dc4d27b4c0130d52 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 24 Jun 2026 14:12:55 +0200 Subject: [PATCH 5/5] docs: document analyzer exception harness in instruction files Add analyzer-exception-harness.instructions.md (design, adoption recipe, SDK-coupling note, fallback) and reference it from analyzer-development, project-overview, and the instruction-maintenance index. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyzer-development.instructions.md | 12 +- ...analyzer-exception-harness.instructions.md | 106 ++++++++++++++++++ .../instruction-maintenance.instructions.md | 1 + .../project-overview.instructions.md | 1 + 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .github/instructions/analyzer-exception-harness.instructions.md diff --git a/.github/instructions/analyzer-development.instructions.md b/.github/instructions/analyzer-development.instructions.md index 280f95e..56941c6 100644 --- a/.github/instructions/analyzer-development.instructions.md +++ b/.github/instructions/analyzer-development.instructions.md @@ -160,7 +160,17 @@ For parameterized messages, use standard .NET format strings: ## Analyzer Class Pattern -All analyzers inherit from `DiagnosticAnalyzer` and are decorated with `[DiagnosticAnalyzer]`. +All analyzers are decorated with `[DiagnosticAnalyzer]`. + +> **Exception harness (XX0000).** Analyzers may instead derive from the per-cop +> bridge `{Cop}Analyzer` (for example `ApplicationCopAnalyzer`) so that an +> unhandled exception becomes a located `XX0000` diagnostic instead of `AD0001` +> on `app.json`. Adoption is a 3-line change: base type `: DiagnosticAnalyzer` → +> `: {Cop}Analyzer`, `SupportedDiagnostics` → `SupportedDiagnosticsCore`, and +> `Initialize(AnalysisContext)` → `InitializeAnalyzer(SafeAnalysisContext)` (no +> `Register*` changes). Currently only `CaptionRequired` is converted. See +> `analyzer-exception-harness.instructions.md`. The template below shows the +> still-supported plain `DiagnosticAnalyzer` form. ### Minimal Template (Symbol-based) diff --git a/.github/instructions/analyzer-exception-harness.instructions.md b/.github/instructions/analyzer-exception-harness.instructions.md new file mode 100644 index 0000000..16ccc93 --- /dev/null +++ b/.github/instructions/analyzer-exception-harness.instructions.md @@ -0,0 +1,106 @@ +--- +applyTo: 'src/ALCops.Common/Diagnostics/**' +--- + +# Analyzer Exception Harness (XX0000) + +The harness converts an unhandled exception thrown by any ALCops analyzer into a +**located `XX0000` diagnostic** (`AC0000`, `DC0000`, `FC0000`, `LC0000`, +`PC0000`, `TA0000`) at the object/line being analyzed, instead of the SDK's +generic `AD0001` on `app.json` line 1. This makes analyzer defects diagnosable: +the message names the failing analyzer and the exception, and the location points +at the input that triggered it. + +## Why it exists + +The NAV SDK's `AnalyzerExecutor` catches every analyzer exception and emits +`AD0001` at `Location.None`. There is **no global `onAnalyzerException` hook** an +analyzer can register from inside `Initialize`. The only interposition point is +the delegate passed to each `Register*Action`. The harness wraps those delegates +transparently so adopting analyzers need no per-callback try/catch (the manual +`Rule0000ErrorInRule` pattern used in BusinessCentral.LinterCop). + +## Components (in `ALCops.Common/Diagnostics/`) + +| Type | Role | +|---|---| +| `ALCopsDiagnosticAnalyzer` | Abstract base. Seals `Initialize`/`SupportedDiagnostics`; exposes `InitializeAnalyzer(SafeAnalysisContext)` and `SupportedDiagnosticsCore`. Auto-appends the cop's `AnalyzerExceptionDescriptor` to `SupportedDiagnostics` and captures `GetType().Name` for the message. | +| `SafeAnalysisContext` | `AnalysisContext` decorator. Overrides every public-abstract `Register*` method to forward to the inner context with the callback wrapped in try/catch. | +| `SafeCompilationStartContext` | Same decoration for registrations nested inside `RegisterCompilationStartAction`. | +| `AnalyzerExceptionReporter` | Builds the `XX0000` diagnostic (`Diagnostic.Create(descriptor, location ?? Location.None, analyzerName, exceptionType, exceptionMessage)`). | +| `{Cop}Analyzer` (per cop) | Thin bridge supplying `AnalyzerExceptionDescriptor => DiagnosticDescriptors.AnalyzerException`. Common cannot reference per-cop descriptors, so this lives in each cop project. | + +## Adoption recipe (3 mechanical edits, no `Register*` changes) + +```csharp +// 1. base type +public sealed class MyRule : ApplicationCopAnalyzer // was : DiagnosticAnalyzer + +// 2. supported diagnostics +protected override ImmutableArray SupportedDiagnosticsCore { get; } = ... +// was: public override ImmutableArray SupportedDiagnostics + +// 3. initialize +protected override void InitializeAnalyzer(SafeAnalysisContext context) => ... +// was: public override void Initialize(AnalysisContext context) +``` + +Keep the `[DiagnosticAnalyzer]` attribute. Do **not** list the cop's +`AnalyzerException` descriptor in `SupportedDiagnosticsCore`; the base appends it. + +As of this change only `CaptionRequired` (ApplicationCop) is converted. The other +analyzers adopt the harness incrementally via this same recipe. + +## Location strategy per context + +| Context | Location | +|---|---| +| `SymbolAnalysisContext` | `Symbol.GetLocation()` | +| `SyntaxNodeAnalysisContext` | `Node.GetLocation()` | +| `CodeBlockAnalysisContext` | `CodeBlock.GetLocation()` | +| `OperationAnalysisContext` | `Operation.Syntax.GetLocation()` | +| Compilation / SemanticModel / SyntaxTree | `Location.None` (message still names the cop + rule) | + +Use `symbol.GetLocation()` (SDK `SymbolExtensions`), **not** `Symbol.Locations` +— `ISymbol` has no `Locations` property in this SDK. + +## Design notes / known constraints + +- **Operation actions are special.** The public `params` + `AnalysisContext.RegisterOperationAction` routes through *internal* members we + cannot override. `SafeAnalysisContext` therefore **`new`-hides** both operation + overloads and forwards to `inner.RegisterOperationAction(wrapped, kinds)`. This + only wins because adopting analyzers type their `InitializeAnalyzer` parameter + as `SafeAnalysisContext`. Converting an operation-based analyzer requires no + call-site change beyond the 3-line recipe. +- **`CompilationStartAnalysisContext`** exposes only public-abstract methods and + no operation registration, so `SafeCompilationStartContext` intercepts nested + registrations via virtual dispatch even when the callback variable is typed as + the SDK base type — no call-site changes in CompilationStart analyzers. +- **Cancellation propagates.** All wrappers use + `catch (Exception ex) when (ex is not OperationCanceledException)`. +- **SDK coupling (accepted, documented).** `SafeAnalysisContext` subclasses the + SDK's abstract `AnalysisContext`. If a future SDK adds a new **public-abstract** + `Register*` method, this type fails to **compile** until an override is added. + That compile-time break is intentional — it forces wrapping of any new surface. + Forwarding uses only the public registration API every analyzer already calls. +- **netstandard2.1.** The harness uses only APIs present on all TFMs + (`netstandard2.1;net8.0;net10.0`); no `#if` guards. Verified to build on all + three. +- **Performance.** try/catch with no throw is effectively free; one delegate + indirection per callback, built once at registration. Negligible against + ~100k method bodies. + +## Fallback (not built) + +If the decorator ever proves troublesome, the contingency is extension helpers +(`Register*ActionSafe`) that wrap callbacks at the call site. They are robust and +simple but require renaming every `Register*` call and a per-file +`SupportedDiagnostics` edit. Documented here only; not implemented. + +## Test coverage + +`ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/` exercises the shipped +wrapping paths with test-only throwing analyzers. + +**HasDiagnostic (3 cases):** SymbolAction, OperationAction, CompilationStartAction. diff --git a/.github/instructions/instruction-maintenance.instructions.md b/.github/instructions/instruction-maintenance.instructions.md index 6dcf175..4925677 100644 --- a/.github/instructions/instruction-maintenance.instructions.md +++ b/.github/instructions/instruction-maintenance.instructions.md @@ -91,6 +91,7 @@ Include: project structure, templates, step-by-step guides, API reference, commo | `ac0031-table-data-access-requires-permissions` | rule-scoped | AC0031 rule | | `ac0032-table-data-access-unused-permissions` | rule-scoped | AC0032 rule | | `sdk-analyzer-infrastructure` | `'src/ALCops.*/Analyzers/**'` | NAV SDK internals: callback ordering, incremental compilation, GetOperation perf | +| `analyzer-exception-harness` | `'src/ALCops.Common/Diagnostics/**'` | XX0000 harness: base class + context decorators that convert analyzer exceptions into located diagnostics | | `fc0004-permission-declaration-order` | rule-scoped | FC0004 rule | | `lc0086-page-style-string-literal` | rule-scoped | LC0086 rule | | `lc0091-translatable-text-should-be-translated` | rule-scoped | LC0091 rule | diff --git a/.github/instructions/project-overview.instructions.md b/.github/instructions/project-overview.instructions.md index fc079d8..d036929 100644 --- a/.github/instructions/project-overview.instructions.md +++ b/.github/instructions/project-overview.instructions.md @@ -17,6 +17,7 @@ The solution (`ALCops.sln`) contains 13 projects in the `src/` directory. A 14th - `Extensions/` : Syntax node, symbol, compilation, and type extension methods - `Helpers/` : `ManifestHelper.cs`, `AppSourceCopConfigurationProvider.cs` - `Reflection/` : `CompilationHelper.cs`, `EnumProvider.cs`, `PropertyAccessor.cs`, `SymbolHelper.cs`, `VersionProvider.cs`, `StringHelper.cs` + - `Diagnostics/` : `ALCopsDiagnosticAnalyzer.cs` (exception-handling base class), `SafeAnalysisContext.cs` / `SafeCompilationStartContext.cs` (callback-wrapping decorators), `AnalyzerExceptionReporter.cs`. Convert analyzer exceptions into a located per-cop `XX0000` diagnostic instead of the SDK's `AD0001`. See `analyzer-exception-harness.instructions.md`. - `Constants.cs` : Shared constant values ### Analyzer projects (6)