diff --git a/.github/instructions/analyzer-development.instructions.md b/.github/instructions/analyzer-development.instructions.md index 280f95ec..56941c63 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 00000000..16ccc931 --- /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 6dcf1755..49256773 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 fc079d8d..d036929c 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) 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 00000000..bdd173ae --- /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 00000000..7b25518f --- /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 00000000..38e019b8 --- /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 00000000..54bc5354 --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/AnalyzerExceptionHarness/HasDiagnostic/SymbolAction.al @@ -0,0 +1,7 @@ +table 50100 [|SymThrow|] +{ + fields + { + field(1; MyField; Integer) { } + } +} diff --git a/src/ALCops.ApplicationCop/ALCops.ApplicationCopAnalyzers.resx b/src/ALCops.ApplicationCop/ALCops.ApplicationCopAnalyzers.resx index 92d1501e..767f8675 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/Analyzers/CaptionRequired.cs b/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs index 6339820b..9b2f06fb 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, diff --git a/src/ALCops.ApplicationCop/ApplicationCopAnalyzer.cs b/src/ALCops.ApplicationCop/ApplicationCopAnalyzer.cs new file mode 100644 index 00000000..ef02c539 --- /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 0d33b81b..1af377a8 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 a34938d1..a0b2eec0 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.Common/Diagnostics/ALCopsDiagnosticAnalyzer.cs b/src/ALCops.Common/Diagnostics/ALCopsDiagnosticAnalyzer.cs new file mode 100644 index 00000000..b39b8661 --- /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 00000000..c103f1d1 --- /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 00000000..f5f5f827 --- /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 00000000..b212dde2 --- /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); +} diff --git a/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx b/src/ALCops.DocumentationCop/ALCops.DocumentationCopAnalyzers.resx index 4dda25b4..1e7ec86f 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 514071da..4f5b7f68 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 1493e9dc..d996165b 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 00000000..313526d9 --- /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 aa75d3f1..3419ce06 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 98299651..b43baa87 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 3e2442f9..be49fc91 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 00000000..b97f1a30 --- /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 2eede905..b32bbf5d 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 63b2c957..d7700eb5 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 7581e763..72a77008 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 00000000..628ceb1b --- /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 46b0ba9b..0e815535 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 8a13f8d0..e83b8a4a 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 6882da0f..c7244abb 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 00000000..7fdfb758 --- /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 92b9f68a..841b6807 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 60d74c6e..27412b95 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 a5400cd1..941b50c4 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 00000000..f8b02283 --- /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; +}