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
29 changes: 29 additions & 0 deletions .github/instructions/analyzer-development.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,35 @@ This is the same mechanism AC0031 (`RequiredPermissionDetector.TryGetFromInvocat
`invocation.Instance.Type`. Keep the variable-map fast path first so `GetOperation` (the ~0.3ms call)
only runs for the rare non-identifier receivers.

### Detecting `this`/self at the operation level (`OperationKind.ThisReference`)

When you already hold the bound `IOperation` (e.g. `IFieldAccess.Instance` inside a
`RegisterOperationAction`) rather than syntax, detect a `this`/self reference via the
**`OperationKind` enum**, not the `IInstanceReferenceOperation` type:

```csharp
// Works on every TFM. Never names IInstanceReferenceOperation.
var thisReferenceKind = EnumProvider.OperationKind.ThisReference;
if (thisReferenceKind != default && instance.Kind == thisReferenceKind)
return true; // `this`/self
```

`IInstanceReferenceOperation`, `ThisExpressionSyntax`, and `SyntaxKind.ThisExpression` are all
absent from the netstandard2.1 compile floor (AL 12.0.13). Referencing the interface forces an
`#if !NETSTANDARD2_1` guard that silently drops `this.` detection on the netstandard2.1 binary that
serves AL 14.0–15.2. `EnumProvider.OperationKind.ThisReference` resolves to `default` (the enum's
`None`) on SDKs without the member — where no `this` code can exist anyway — so the `!= default`
guard makes it a no-op there and exact elsewhere. Add the member to `EnumProvider` with the
string-literal `ParseEnum<…>("ThisReference")` form (like `CompoundAssignmentStatement`), since
`nameof(OperationKind.ThisReference)` will not compile at the floor.

Note that `this` yields a record-typed symbol (`BoundThisReference.ExpressionSymbol => Type`), so
`GetSymbolSafe().Name` is the table name, **not** `"Rec"`. To distinguish the current record from
the `xRec` before-image (both share the same record type), compare the global's name to the
reserved keyword `"Rec"` — the `IsThis`/`HasImplicitWith` flags that the compiler uses live on the
internal `SynthesizedGlobalVariableSymbol` and are not publicly reachable. See PC0037
`UseValidateForFieldAssignment.IsCurrentRecordInstance`.

## EnumProvider (Critical Pattern)
Never reference `Microsoft.Dynamics.Nav.CodeAnalysis` enum values directly. Always use `EnumProvider` from `ALCops.Common.Reflection`. This provides backward compatibility across SDK versions via reflection-based caching.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Some `Microsoft.Dynamics.Nav.CodeAnalysis` APIs only exist in newer SDK versions
|---|---|---|
| `IFieldSymbol.Type` | net8.0 only | `fieldSymbol.OriginalDefinition.GetTypeSymbol()` (requires `using Microsoft.Dynamics.Nav.CodeAnalysis.Symbols`) |
| `ThisExpressionSyntax` (AL `this` keyword) | net8.0+ only (do not reference) | The public type, `SyntaxKind.ThisExpression`, and `IInstanceReferenceOperation` are absent from the netstandard2.1 compile floor (AL 12.0.13, predating the Fall 2024 `this` feature), so there is no type-name or `EnumProvider` workaround for the syntax type. Instead, **do not reference it at all**: resolve the receiver via the operation tree (`GetOperation(receiver)?.Type`, or `IInvocationExpression.Instance.Type`), which *is* available at the floor and binds a table's `this` to its record. This avoids the `#if` entirely and works on AL 14.0-15.2 (which run the netstandard2.1 binary). See AC0032 `TableDataAccessUnusedPermissions` and AC0031 `RequiredPermissionDetector`. |
| `IInstanceReferenceOperation` (`this`/self when you hold the `IOperation`) | net8.0+ only (do not reference) | When you already have the bound instance operation, detect self via `instance.Kind == EnumProvider.OperationKind.ThisReference` (guarded `!= default`) instead of `instance is IInstanceReferenceOperation`. The `OperationKind` enum member is reachable through `EnumProvider` (string-literal `ParseEnum<…>("ThisReference")`) and resolves to `default` on SDKs without it, so no `#if` is needed and it works on every TFM. See PC0037 `UseValidateForFieldAssignment.IsCurrentRecordInstance` and the this/self note in `analyzer-development.instructions.md`. |

### Pattern for `IFieldSymbol.Type`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ Detects direct field assignments on non-temporary record variables and recommend
| Flag all fields (including PK, system fields) | Community consensus: no exceptions, use pragma for justified cases |
| Exclude only `temporary` record variables | `IRecordTypeSymbol.Temporary == true` — temporary records don't persist and don't need subscriber execution |
| Do NOT exclude non-Normal table types | Christian Hovenbitzer: inherently temporary tables should still validate |
| Do NOT exclude assignments inside OnValidate triggers | Even cascading field assignments should use Validate to trigger subscribers on the target field |
| Suppress same-field assignment to the current record inside its own validate trigger | Assigning a field to `Rec`/self inside that field's own `OnValidate`/`OnBeforeValidate`/`OnAfterValidate` is by design (default values, value transformation such as rounding); calling `Validate()` there is impossible/recursive. Issue #357 |
| Suppression is narrow: same field only, current record only | Cross-field cascades, `xRec`, and other same-table variables still fire (they are different records / different fields, so subscribers must run) |
| Do NOT exclude assignments after Init() | The Init+assign+Insert pattern should still use Validate where possible |
| No ChangeCompany handling | Use pragma for legacy ChangeCompany+write patterns |
| Register for CompoundAssignmentStatement | Guard with `!= default` for netstandard2.1 where the OperationKind doesn't exist |
Expand All @@ -36,19 +37,26 @@ Detects direct field assignments on non-temporary record variables and recommend

- **Analyzer**: Registers for `OperationKind.AssignmentStatement` and `OperationKind.CompoundAssignmentStatement`
- **Detection**: Checks if `IAssignmentStatement.Target` is `IFieldAccess` with a non-temporary `IRecordTypeSymbol` instance
- **Own-validate suppression** (`IsAssignmentToOwnValidateField`): walks to the nearest `TriggerDeclarationSyntax`, requires its name to be `OnValidate`/`OnBeforeValidate`/`OnAfterValidate`, requires the assigned instance to be the current record, then compares the assigned field name (`IsSameName`) to the trigger's owner field
- **Current-record detection** (`IsCurrentRecordInstance`): true when the instance is a `this`/self reference — detected via `instance.Kind == EnumProvider.OperationKind.ThisReference` (guarded `!= default`), **not** the `IInstanceReferenceOperation` type — or when its symbol is named `Rec` (covers explicit `Rec.` and a page's implicit-with bare reference). `Rec`/`xRec` are reserved AL keywords, so the name is the only public discriminator between the current record and the `xRec` before-image. See the this/self note in `analyzer-development.instructions.md`.
- **Owner field resolution** (`ResolveTriggerOwnerField`): the trigger symbol's `ContainingSymbol` is the owner — an `IFieldSymbol` (table field), an `IControlSymbol` (page control, resolved via `RelatedFieldSymbol`), or a change-modify symbol for `modify(...)` extensions whose modified base field/control is read via the internal `Target` property (`PropertyAccessor.GetPropertyIfExists`), then resolved recursively
- **Location**: Reports on `fieldAccess.Syntax.GetIdentifierNameSyntax()` (the field identifier token)
- **CodeFix**: Navigates from diagnostic span to parent `AssignmentStatementSyntax`, rewrites to `ExpressionStatement(InvocationExpression(MemberAccess(Rec, "Validate"), ArgumentList(FieldName, Value)))`

## Test coverage

**HasDiagnostic (5 cases):** SimpleAssignment, CompoundAssignment, InsideOnValidateTrigger, AfterInit, PrimaryKeyField.
**NoDiagnostic (3 cases):** TemporaryVariable, ValidateCall, NonRecordVariable.
**HasDiagnostic (7 cases):** SimpleAssignment, CompoundAssignment, AfterInit, PrimaryKeyField, OnValidateDifferentFieldOnRec, OnValidateXRecSameField, OnValidateOtherRecordSameField.
**NoDiagnostic (12 cases):** TemporaryVariable, ValidateCall, NonRecordVariable, InsideOnValidateTrigger, TableFieldOnValidateSameField, TableExtensionFieldOnBeforeValidateSameField, TableExtensionFieldOnAfterValidateSameField, PageControlOnValidateSameField, PageExtensionControlOnBeforeValidateSameField, PageExtensionControlOnAfterValidateSameField, OnValidateSameFieldThisReference, PageControlOnValidateSameFieldBareReference.
**HasFix (1 case):** SimpleAssignment.

## Known issues

- CompoundAssignmentStatement OperationKind does not exist in netstandard2.1 SDK. Guarded with `!= default` check.
- The CodeFix does not handle compound assignments (`+=`, `-=`) — only simple `:=` is auto-fixable.
- `this`/self detection uses the `OperationKind.ThisReference` enum (via `EnumProvider`, guarded `!= default`), **not** the `IInstanceReferenceOperation` type. That type is absent from the netstandard2.1 compile floor (AL 12.0.13), and referencing it would force an `#if !NETSTANDARD2_1` guard that silently drops `this.` suppression on the netstandard2.1 binary serving AL 14.0–15.2. The enum approach works on every TFM with no guard. See AC0032 / PR #353.
- `OnBeforeValidate`/`OnAfterValidate` are only valid on **modified** fields/controls (`modify(...)`), not on newly-added extension fields (AL0162). The table/page extension fixtures therefore use `modify("Unit Price")` on a base field.
- A bare table self field reference (`"Field" := ...`, no `Rec.`) binds to the table object type (not `IRecordTypeSymbol`), so it never fires today and needs no suppression. Page bare references use implicit-with → `Rec`, so they fire and are suppressed.
- `xRec` and other same-table record variables keep firing — they are different records.

## Related

Expand Down
14 changes: 10 additions & 4 deletions src/ALCops.Common/Reflection/EnumProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,8 @@ public static class OperationKind
new(() => ParseEnum<NavCodeAnalysis.OperationKind>(nameof(NavCodeAnalysis.OperationKind.AssignmentStatement)));
private static readonly Lazy<NavCodeAnalysis.OperationKind> _binaryOperatorExpression =
new(() => ParseEnum<NavCodeAnalysis.OperationKind>(nameof(NavCodeAnalysis.OperationKind.BinaryOperatorExpression)));
private static readonly Lazy<NavCodeAnalysis.OperationKind> _compoundAssignmentStatement =
new(() => ParseEnum<NavCodeAnalysis.OperationKind>("CompoundAssignmentStatement"));
private static readonly Lazy<NavCodeAnalysis.OperationKind> _conversionExpression =
new(() => ParseEnum<NavCodeAnalysis.OperationKind>(nameof(NavCodeAnalysis.OperationKind.ConversionExpression)));
private static readonly Lazy<NavCodeAnalysis.OperationKind> _emptyStatement =
Expand All @@ -506,26 +508,27 @@ public static class OperationKind
new(() => ParseEnum<NavCodeAnalysis.OperationKind>(nameof(NavCodeAnalysis.OperationKind.ParameterReferenceExpression)));
private static readonly Lazy<NavCodeAnalysis.OperationKind> _returnValueReferenceExpression =
new(() => ParseEnum<NavCodeAnalysis.OperationKind>(nameof(NavCodeAnalysis.OperationKind.ReturnValueReferenceExpression)));
private static readonly Lazy<NavCodeAnalysis.OperationKind> _thisReference =
new(() => ParseEnum<NavCodeAnalysis.OperationKind>("ThisReference"));
private static readonly Lazy<NavCodeAnalysis.OperationKind> _xmlPortDataItemAccess =
new(() => ParseEnum<NavCodeAnalysis.OperationKind>(nameof(NavCodeAnalysis.OperationKind.XmlPortDataItemAccess)));
private static readonly Lazy<NavCodeAnalysis.OperationKind> _compoundAssignmentStatement =
new(() => ParseEnum<NavCodeAnalysis.OperationKind>("CompoundAssignmentStatement"));

public static NavCodeAnalysis.OperationKind AssignmentStatement => _assignmentStatement.Value;
public static NavCodeAnalysis.OperationKind BinaryOperatorExpression => _binaryOperatorExpression.Value;
public static NavCodeAnalysis.OperationKind CompoundAssignmentStatement => _compoundAssignmentStatement.Value;
public static NavCodeAnalysis.OperationKind ConversionExpression => _conversionExpression.Value;
public static NavCodeAnalysis.OperationKind EmptyStatement => _emptyStatement.Value;
public static NavCodeAnalysis.OperationKind ExpressionStatement => _expressionStatement.Value;
public static NavCodeAnalysis.OperationKind ExitStatement => _exitStatement.Value;
public static NavCodeAnalysis.OperationKind ExpressionStatement => _expressionStatement.Value;
public static NavCodeAnalysis.OperationKind FieldAccess => _fieldAccess.Value;
public static NavCodeAnalysis.OperationKind GlobalReferenceExpression => _globalReferenceExpression.Value;
public static NavCodeAnalysis.OperationKind InvocationExpression => _invocationExpression.Value;
public static NavCodeAnalysis.OperationKind LiteralExpression => _literalExpression.Value;
public static NavCodeAnalysis.OperationKind LocalReferenceExpression => _localReferenceExpression.Value;
public static NavCodeAnalysis.OperationKind ParameterReferenceExpression => _parameterReferenceExpression.Value;
public static NavCodeAnalysis.OperationKind ReturnValueReferenceExpression => _returnValueReferenceExpression.Value;
public static NavCodeAnalysis.OperationKind ThisReference => _thisReference.Value;
public static NavCodeAnalysis.OperationKind XmlPortDataItemAccess => _xmlPortDataItemAccess.Value;
public static NavCodeAnalysis.OperationKind CompoundAssignmentStatement => _compoundAssignmentStatement.Value;
}

/// <summary>
Expand Down Expand Up @@ -790,6 +793,8 @@ public static class SymbolKind
{
private static readonly Lazy<NavCodeAnalysis.SymbolKind> _action =
new(() => ParseEnum<NavCodeAnalysis.SymbolKind>(nameof(NavCodeAnalysis.SymbolKind.Action)));
private static readonly Lazy<NavCodeAnalysis.SymbolKind> _change =
new(() => ParseEnum<NavCodeAnalysis.SymbolKind>(nameof(NavCodeAnalysis.SymbolKind.Change)));
private static readonly Lazy<NavCodeAnalysis.SymbolKind> _class =
new(() => ParseEnum<NavCodeAnalysis.SymbolKind>(nameof(NavCodeAnalysis.SymbolKind.Class)));
private static readonly Lazy<NavCodeAnalysis.SymbolKind> _codeunit =
Expand Down Expand Up @@ -864,6 +869,7 @@ public static class SymbolKind
#endif

public static NavCodeAnalysis.SymbolKind Action => _action.Value;
public static NavCodeAnalysis.SymbolKind Change => _change.Value;
public static NavCodeAnalysis.SymbolKind Class => _class.Value;
public static NavCodeAnalysis.SymbolKind Codeunit => _codeunit.Value;
public static NavCodeAnalysis.SymbolKind ControlAddIn => _controlAddIn.Value;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal)
{
trigger OnValidate()
begin
Rec.[|"Line Amount"|] := Rec."Unit Price" * Rec.Quantity;
end;
}
field(2; Quantity; Decimal) { }
field(3; "Line Amount"; Decimal) { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal)
{
trigger OnValidate()
var
OtherSalesLine: Record "Sales Line";
begin
OtherSalesLine.[|"Unit Price"|] := 100;
end;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal)
{
trigger OnValidate()
begin
xRec.[|"Unit Price"|] := 100;
end;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal)
{
trigger OnValidate()
begin
this.[|"Unit Price"|] := 100;
end;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal) { }
}
}

page 50100 "Sales Line Card"
{
PageType = Card;
SourceTable = "Sales Line";

layout
{
area(Content)
{
field(UnitPrice; Rec."Unit Price")
{
trigger OnValidate()
begin
Rec.[|"Unit Price"|] := 100;
end;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal) { }
}
}

page 50100 "Sales Line Card"
{
PageType = Card;
SourceTable = "Sales Line";

layout
{
area(Content)
{
field(UnitPrice; Rec."Unit Price")
{
trigger OnValidate()
begin
[|"Unit Price"|] := 100;
end;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal) { }
}
}

page 50100 "Sales Line Card"
{
PageType = Card;
SourceTable = "Sales Line";

layout
{
area(Content)
{
field(UnitPrice; Rec."Unit Price") { }
}
}
}

pageextension 50101 "Sales Line Card Ext" extends "Sales Line Card"
{
layout
{
modify(UnitPrice)
{
trigger OnAfterValidate()
begin
Rec.[|"Unit Price"|] := 100;
end;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal) { }
}
}

page 50100 "Sales Line Card"
{
PageType = Card;
SourceTable = "Sales Line";

layout
{
area(Content)
{
field(UnitPrice; Rec."Unit Price") { }
}
}
}

pageextension 50101 "Sales Line Card Ext" extends "Sales Line Card"
{
layout
{
modify(UnitPrice)
{
trigger OnBeforeValidate()
begin
Rec.[|"Unit Price"|] := 100;
end;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal) { }
}
}

tableextension 50101 "Sales Line Ext" extends "Sales Line"
{
fields
{
modify("Unit Price")
{
trigger OnAfterValidate()
begin
Rec.[|"Unit Price"|] := 100;
end;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal) { }
}
}

tableextension 50101 "Sales Line Ext" extends "Sales Line"
{
fields
{
modify("Unit Price")
{
trigger OnBeforeValidate()
begin
Rec.[|"Unit Price"|] := 100;
end;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
table 50100 "Sales Line"
{
fields
{
field(1; "Unit Price"; Decimal)
{
trigger OnValidate()
begin
Rec.[|"Unit Price"|] := Round(Rec."Unit Price", 0.01);
end;
}
field(2; Quantity; Decimal) { }
}
}
Loading
Loading