Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/instructions/analyzer-development.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
106 changes: 106 additions & 0 deletions .github/instructions/analyzer-exception-harness.instructions.md
Original file line number Diff line number Diff line change
@@ -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<DiagnosticDescriptor> SupportedDiagnosticsCore { get; } = ...
// was: public override ImmutableArray<DiagnosticDescriptor> 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions .github/instructions/project-overview.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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
/// <c>new</c>-hiding path), and a CompilationStart-nested symbol action. Each
/// asserts AC0000 is reported at the analyzed object/line instead of AD0001.
/// </summary>
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<ThrowingSymbolAnalyzer>();
var code = await ReadCaseAsync(nameof(SymbolAction)).ConfigureAwait(false);
fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.AnalyzerException);
}

[Test]
public async Task OperationAction()
{
var fixture = RoslynFixtureFactory.Create<ThrowingOperationAnalyzer>();
var code = await ReadCaseAsync(nameof(OperationAction)).ConfigureAwait(false);
fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.AnalyzerException);
}

[Test]
public async Task CompilationStartAction()
{
var fixture = RoslynFixtureFactory.Create<ThrowingCompilationStartAnalyzer>();
var code = await ReadCaseAsync(nameof(CompilationStartAction)).ConfigureAwait(false);
fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.AnalyzerException);
}

private Task<string> ReadCaseAsync(string testCase) =>
File.ReadAllTextAsync(
Path.Combine(_testCasePath, "HasDiagnostic", $"{testCase}.al"));
}

[DiagnosticAnalyzer]
public sealed class ThrowingSymbolAnalyzer : ApplicationCopAnalyzer
{
protected override System.Collections.Immutable.ImmutableArray<DiagnosticDescriptor> SupportedDiagnosticsCore { get; } =
System.Collections.Immutable.ImmutableArray<DiagnosticDescriptor>.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<DiagnosticDescriptor> SupportedDiagnosticsCore { get; } =
System.Collections.Immutable.ImmutableArray<DiagnosticDescriptor>.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<DiagnosticDescriptor> SupportedDiagnosticsCore { get; } =
System.Collections.Immutable.ImmutableArray<DiagnosticDescriptor>.Empty;

protected override void InitializeAnalyzer(SafeAnalysisContext context) =>
context.RegisterCompilationStartAction(
start => start.RegisterSymbolAction(
_ => throw new InvalidOperationException("boom"),
EnumProvider.SymbolKind.Table));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
table 50101 [|CompStartThrow|]
{
fields
{
field(1; MyField; Integer) { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
codeunit 50102 OpThrow
{
procedure Caller()
begin
[|Callee()|];
end;

local procedure Callee()
begin
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
table 50100 [|SymThrow|]
{
fields
{
field(1; MyField; Integer) { }
}
}
9 changes: 9 additions & 0 deletions src/ALCops.ApplicationCop/ALCops.ApplicationCopAnalyzers.resx
Original file line number Diff line number Diff line change
Expand Up @@ -450,4 +450,13 @@
<data name="ZeroEnumValueReservedForEmptyDescription" xml:space="preserve">
<value>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.</value>
</data>
<data name="AnalyzerExceptionTitle" xml:space="preserve">
<value>Analyzer threw an unhandled exception</value>
</data>
<data name="AnalyzerExceptionMessageFormat" xml:space="preserve">
<value>Analyzer '{0}' threw an exception of type '{1}': {2}</value>
</data>
<data name="AnalyzerExceptionDescription" xml:space="preserve">
<value>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.</value>
</data>
</root>
7 changes: 4 additions & 3 deletions src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DiagnosticDescriptor> SupportedDiagnostics { get; } =
protected override ImmutableArray<DiagnosticDescriptor> SupportedDiagnosticsCore { get; } =
ImmutableArray.Create(
DiagnosticDescriptors.CaptionRequired);

private static readonly HashSet<string> _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,
Expand Down
15 changes: 15 additions & 0 deletions src/ALCops.ApplicationCop/ApplicationCopAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics;
using ALCops.Common.Diagnostics;

namespace ALCops.ApplicationCop;

/// <summary>
/// Base class for every ApplicationCop analyzer. Supplies the cop-specific
/// <see cref="DiagnosticDescriptors.AnalyzerException"/> (AC0000) reported when a
/// rule throws an unhandled exception. See <see cref="ALCopsDiagnosticAnalyzer"/>.
/// </summary>
public abstract class ApplicationCopAnalyzer : ALCopsDiagnosticAnalyzer
{
protected sealed override DiagnosticDescriptor AnalyzerExceptionDescriptor =>
DiagnosticDescriptors.AnalyzerException;
}
16 changes: 16 additions & 0 deletions src/ALCops.ApplicationCop/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -381,5 +391,11 @@ internal static class Category
/// Example: Avoid exposing internal APIs, hard-coded credentials, or missing permission checks.
/// </summary>
public const string Security = "Security";

/// <summary>
/// Internal issues: failures inside ALCops analyzers themselves
/// (for example an unhandled exception in a rule), not problems in user code.
/// </summary>
public const string Internal = "Internal";
}
}
1 change: 1 addition & 0 deletions src/ALCops.ApplicationCop/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading