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
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ Registers `RegisterOperationAction` on `InvocationExpression` and `RegisterSymbo
| Decision | Rationale |
|---|---|
| Two analysis paths: the invocation path compares the tables of each `TransferFields` call, the relation path compares extension-added fields of curated table pairs (`TransferFieldsRelations.TableRelations`, e.g. Customer → Contact) | The relation path catches extension fields that collide on well-known BaseApp transfers even when the call site is not in the analyzed module |
| Invocation path reports field-level diagnostics only for pairs outside the curated list, plus one summary diagnostic at the invocation site | Curated pairs are already reported field-by-field on the extension fields themselves by the relation path |
| Invocation path reports field-level diagnostics only when the call's (source, target) pair is not a curated relation in that direction (source = argument record, target = receiver), plus one summary diagnostic at the invocation site | Curated pairs are already reported field-by-field on the extension fields themselves by the relation path. The lookup is by pair, not by either table alone: a table that appears somewhere in the list says nothing about an unrelated call |
| On a curated pair whose two tables are both Microsoft's, mismatches where both fields are Microsoft-owned are dropped before reporting (no summary, no field-level diagnostic). A field declared in the analyzed module is never Microsoft-owned; a dependency tableextension field is Microsoft-owned only when that extension is Microsoft's | Microsoft ships and supports these transfers with fields that differ by name or type (Purchase Header ↔ Purchase Header Archive field 151, Tracking Specification ↔ Reservation Entry fields 31 and 900; the 5043 difference is between FlowFields, which the rule ignores); the developer cannot rename Base App fields, and a pragma on the call would also hide collisions on their own extension fields ([#418](https://github.com/ALCops/Analyzers/issues/418)). Both sides must be Microsoft-owned: once one side was added by the developer or a third party the collision is new and outside Microsoft's support. This deliberately differs from the field pragma, which is an explicit user suppression and works from either side |
| Ownership test is `ISymbol.IsMicrosoftObject()` (Common): namespace root `Microsoft` or `System`, else module publisher `Microsoft` | Both namespace roots are reserved for Microsoft (AS0008/PTE0021) and the namespace is already read for the relation lookup; the publisher fallback covers pre-namespace Base App versions. Tables that only share a curated name in a non-Microsoft module are not the Base App tables and stay reported |
| Skip via `IsRemoved()` (Removed/Moved), not `IsObsolete()` | `ObsoleteState = Pending` tables and fields still participate at runtime and must keep firing |
| Removed fields, calls where either table is removed, and removed relation-path extensions (or extensions of a removed base table) are excluded | Removed fields do not participate in `TransferFields` at runtime ([#148](https://github.com/ALCops/Analyzers/issues/148)); upgrade code transfers from removed tables and a removed target makes the call dead code ([#435](https://github.com/ALCops/Analyzers/issues/435)) |
| Field-level `#pragma warning disable` on either side suppresses the pair | Checked against both fields' syntax directives, so silencing one side is enough |
Expand All @@ -35,11 +37,14 @@ Registers `RegisterOperationAction` on `InvocationExpression` and `RegisterSymbo
- Type pairs the platform converts implicitly (Enum→Integer, Code→Text, Integer→BigInteger/Decimal).
- PK fields when `InitPrimaryKeyFields` is `false`; any pair when `SkipFieldsNotMatchingType` is a constant `true`.
- PC0021: names that differ only by a mandatory affix on a same-module extension field.
- Microsoft-owned field pairs on a curated pair of Microsoft tables. Not suppressed: a pair that is curated only in the reverse direction, and non-Microsoft tables that carry a curated name.

## Known issues

- Platform-parity affix matching can over-strip coincidental substrings (`Customer` with affix `MER` → `Custo`); when the paired same-ID field's core genuinely collides, PC0021 stays silent. Accepted as a narrow false negative for SDK parity ([#436](https://github.com/ALCops/Analyzers/issues/436)).
- Relation-path coverage only applies to the curated `TransferFieldsRelations.TableRelations` list, which carries BC version ranges (`MinVersion`/`MaxVersion`).
- Relation-path coverage only applies to the curated `TransferFieldsRelations.TableRelations` list, which carries BC version ranges (`MinVersion`/`MaxVersion`). Those ranges are stored but never read: a relation counts as curated on every BC version.
- The suppression of a Microsoft dependency tableextension's field on a curated pair cannot be covered by a fixture (it needs a second module); it is verified by reasoning only.
- A third-party dependency's tableextension field that collides on a curated Microsoft pair is still reported in every app that calls `TransferFields` on that pair, although the consuming app cannot change it.

## SDK facts

Expand All @@ -53,3 +58,4 @@ Registers `RegisterOperationAction` on `InvocationExpression` and `RegisterSymbo
- Invocation-path fixtures for removed tables must be upgrade codeunits (see SDK facts).
- Affix fixtures (`Affix_*`) inject an `AppSourceCop.json` via `MemoryFileSystem`; this requires `Microsoft.Dynamics.Nav.Analyzers.Common.dll` as a `Private=True` reference in the test csproj (ALCops.Common references it with `Private=False`).
- Tableextension fixtures are gated on runtime 13.0 and `this` receiver fixtures on 14.0.
- Microsoft ownership is exercised two ways: a `namespace Microsoft.*` fixture for BC24+ tables, and namespace-less tables under a fixture whose `ProjectInfoCustomizer` sets the module publisher to `Microsoft` (`WithProjectDefinition`); the harness default publisher is `Default Publisher`. One AL file is one module with one namespace, so a fixture cannot mix a Microsoft table with a non-Microsoft extension; own-module extension fields are keyed on the declaring module instead.
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ Recurring causes of false positives/negatives, mined from `fix(...)` commits. Wh
| **Table shape: implicit primary key** | A table without a `keys` section has a synthesized primary key over its first valid field. `ITableTypeSymbol.Keys` never lists it (also for referenced `.app` tables); only `PrimaryKey` does. Key-membership logic must read `PrimaryKey`, and every rule reading keys needs a fixture without a `keys` section. | PC0029 #544 |
| **One `SymbolKind` covers several AL constructs** | `SymbolKind.Action` spans `area`, `group`, `action`, `separator`, `actionref`, `customaction`, `systemaction` and `fileuploadaction`; `SymbolKind.Control` spans `area`, `group`, `field`, `part` and the rest. Read `IActionSymbol.ActionKind` / `IControlSymbol.ControlKind` before treating a name, caption or property as developer-chosen. | LC0092 #537, AC0011 |
| **Version-scoped syntax** (`this`, newer keywords) | Fixtures need `SkipTestIfVersionIsTooLow("14.0")` or `RequireMinimumVersion(...)`; a rule may need a `VersionProvider` gate rather than a code change. | PC0035 fixtures, PC0029 |
| **Base App differences the developer cannot change** | A comparison between two Microsoft objects can flag differences Microsoft ships and supports (renamed or retyped fields on a curated pair). Suppress only where both sides are Microsoft-owned (`ISymbol.IsMicrosoftObject()`: namespace root `Microsoft`/`System`, else publisher `Microsoft`) and keep checking anything the analyzed module or a third party added. | PC0020/PC0021 #418 |
| **Null `Instance` means bare self *or* static built-in class** | `BoundCall.Instance` is null for any `IsStatic` target (IsolatedStorage Get/Delete/Contains, NumberSequence Insert/Delete/Next, File Rename/Copy, bare Error/Format), not just bare self. `GetReceiverTableType` takes the invocation or field access itself (never its `Instance`) and returns null for static targets. | PC0013 #550 |
19 changes: 19 additions & 0 deletions src/ALCops.Common/Extensions/SymbolInterfaceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ public static string GetFullyQualifiedObjectName(this ISymbol symbol, bool quote
return $"{containingNamespace}.{symbolName}";
}

/// <summary>
/// True when the object is Microsoft-owned: its root namespace is "Microsoft" or "System"
/// (both reserved for Microsoft, see AS0008/PTE0021), or, when it has no namespace
/// (pre-namespace BC or a bare declaration), its module's publisher is "Microsoft".
/// </summary>
public static bool IsMicrosoftObject(this ISymbol symbol)
{
var ns = symbol.GetContainingNamespaceQualifiedNameWithReflection();
if (!string.IsNullOrEmpty(ns))
{
var dot = ns.IndexOf('.');
var root = dot < 0 ? ns : ns.Substring(0, dot);
return SemanticFacts.IsSameName(root, "Microsoft") || SemanticFacts.IsSameName(root, "System");
}

var publisher = symbol.ContainingModule?.Publisher;
return publisher is not null && StringComparer.OrdinalIgnoreCase.Equals(publisher, "Microsoft");
}

#region Obsolete Extension Methods
// COMPAT(netstandard2.1, net8.0): IsObsoletePendingMove and IsObsoleteMoved may not exist in older SDK versions.
// TODO: When netstandard2.1 and net8.0 are dropped, check if these properties are on ISymbol directly.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Same curated pair as CuratedPair_MicrosoftPublisher, but the module is not Microsoft's
// ("Default Publisher", no namespace): tables that merely share a curated name are not the
// Base App tables, so the mismatch is reported at the call. Field-level reporting stays off
// for curated pairs.
codeunit 50100 MyCodeunit
{
procedure MyProcedure()
var
PurchaseHeader: Record "Purchase Header";
PurchaseHeaderArchive: Record "Purchase Header Archive";
begin
[|PurchaseHeader.TransferFields(PurchaseHeaderArchive)|];
end;
}

table 38 "Purchase Header"
{
fields
{
field(1; "No."; Code[20]) { }
field(151; "Quote No."; Code[20]) { }
}
}

table 5109 "Purchase Header Archive"
{
fields
{
field(1; "No."; Code[20]) { }
field(151; "Purchase Quote No."; Code[20]) { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Curated Microsoft pair: the Microsoft-owned differences on fields 31 and 900 are not
// reported, but fields added by tableextensions of the analyzed module are always checked,
// so the collision on field 50100 is still reported at the call.
namespace Microsoft.Inventory.Tracking;

codeunit 50100 MyCodeunit
{
procedure MyProcedure()
var
TrackingSpecification: Record "Tracking Specification";
ReservationEntry: Record "Reservation Entry";
begin
[|ReservationEntry.TransferFields(TrackingSpecification)|];
end;
}

table 336 "Tracking Specification"
{
fields
{
field(1; "Entry No."; Integer) { }
field(31; "Qty. Rounding Precision (Base)"; Decimal) { }
field(900; "Prohibit Cancellation"; Boolean) { }
}
}

table 337 "Reservation Entry"
{
fields
{
field(1; "Entry No."; Integer) { }
field(31; "Action Message Adjustment"; Decimal) { }
field(900; "Disallow Cancellation"; Boolean) { }
}
}

tableextension 50100 MyTrackingSpecExt extends "Tracking Specification"
{
fields
{
field(50100; "My Field A"; Integer) { }
}
}

tableextension 50101 MyReservationEntryExt extends "Reservation Entry"
{
fields
{
field(50100; "My Field B"; Integer) { } // Same ID (50100) as in MyTrackingSpecExt, different name
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Both tables are Microsoft's, but Purchase Header Archive -> Customer is not a curated
// TransferFields relation: every mismatch is reported, at the call and on both fields.
codeunit 50100 MyCodeunit
{
procedure MyProcedure()
var
Customer: Record Customer;
PurchaseHeaderArchive: Record "Purchase Header Archive";
begin
[|Customer.TransferFields(PurchaseHeaderArchive)|];
end;
}

table 18 Customer
{
fields
{
field(1; "No."; Code[20]) { }
[|field(21; "Currency Code"; Code[10]) { }|]
}
}

table 5109 "Purchase Header Archive"
{
fields
{
field(1; "No."; Code[20]) { }
[|field(21; "Posting Date"; Date) { }|]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Bank Deposit Header -> Posted Bank Deposit Header is curated in that direction only. This call
// runs the other way (source = Posted Bank Deposit Header, target = Bank Deposit Header), so it is
// not a supported pair: the mismatch is reported at the call and on both fields, although both
// tables are Microsoft's.
namespace Microsoft.Bank.Deposit;

codeunit 50100 MyCodeunit
{
procedure MyProcedure()
var
BankDepositHeader: Record "Bank Deposit Header";
PostedBankDepositHeader: Record "Posted Bank Deposit Header";
begin
[|BankDepositHeader.TransferFields(PostedBankDepositHeader)|];
end;
}

table 1690 "Bank Deposit Header"
{
fields
{
field(1; "No."; Code[20]) { }
[|field(2; "Bank Account No."; Code[20]) { }|]
}
}

table 1695 "Posted Bank Deposit Header"
{
fields
{
field(1; "No."; Code[20]) { }
[|field(2; "Posted Bank Account No."; Code[20]) { }|] // Same ID (2) as in Bank Deposit Header, different name
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Tracking Specification -> Reservation Entry is a curated TransferFields relation and both
// tables live in the Microsoft namespace. Fields 31 and 900 differ by name in the Base App;
// the developer cannot rename them, so neither the call nor the fields are reported.
namespace Microsoft.Inventory.Tracking;

codeunit 50100 MyCodeunit
{
procedure MyProcedure()
var
TrackingSpecification: Record "Tracking Specification";
ReservationEntry: Record "Reservation Entry";
begin
[|ReservationEntry.TransferFields(TrackingSpecification)|];
end;
}

table 336 "Tracking Specification"
{
fields
{
field(1; "Entry No."; Integer) { }
[|field(31; "Qty. Rounding Precision (Base)"; Decimal) { }|]
[|field(900; "Prohibit Cancellation"; Boolean) { }|]
}
}

table 337 "Reservation Entry"
{
fields
{
field(1; "Entry No."; Integer) { }
[|field(31; "Action Message Adjustment"; Decimal) { }|]
[|field(900; "Disallow Cancellation"; Boolean) { }|]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Purchase Header Archive -> Purchase Header is a curated TransferFields relation. The tables
// have no namespace (pre-namespace Base App), so ownership falls back to the module publisher,
// which the test fixture sets to "Microsoft". Field 151 differs by name in the Base App.
codeunit 50100 MyCodeunit
{
procedure MyProcedure()
var
PurchaseHeader: Record "Purchase Header";
PurchaseHeaderArchive: Record "Purchase Header Archive";
begin
[|PurchaseHeader.TransferFields(PurchaseHeaderArchive)|];
end;
}

table 38 "Purchase Header"
{
fields
{
field(1; "No."; Code[20]) { }
[|field(151; "Quote No."; Code[20]) { }|]
}
}

table 5109 "Purchase Header Archive"
{
fields
{
field(1; "No."; Code[20]) { }
[|field(151; "Purchase Quote No."; Code[20]) { }|]
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Dynamics.Nav.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces;
using RoslynTestKit;

namespace ALCops.PlatformCop.Test
Expand Down Expand Up @@ -52,6 +53,21 @@ private static AnalyzerTestFixture CreateFixtureWithCoincidentalAffix()
});
}

private static AnalyzerTestFixture CreateFixtureWithMicrosoftPublisher()
{
return RoslynFixtureFactory.Create<Analyzers.TransferFieldsSchemaCompatibility>(
new AnalyzerTestFixtureConfig
{
ProjectInfoCustomizer = info => info.WithProjectDefinition(new ProjectDefinition
{
Publisher = "Microsoft",
Name = "TestApp",
Version = new Version(1, 0, 0, 0),
AppId = Guid.NewGuid()
})
});
}

[SetUp]
public void Setup()
{
Expand All @@ -64,6 +80,8 @@ public void Setup()
}

[Test]
[TestCase("CuratedPair_DefaultPublisher")]
[TestCase("CuratedPair_OwnExtensionField")]
[TestCase("InvocationRecWithCodeunit")]
[TestCase("InvocationRecWithPage")]
[TestCase("InvocationRecWithTable")]
Expand All @@ -82,10 +100,11 @@ public void Setup()
[TestCase("TableExt_NamespaceCasingMismatch")]
[TestCase("InvocationBareSelfInTableExtension")]
[TestCase("InvocationThisSelfInTable")]
[TestCase("ReverseOnlyPair_MicrosoftNamespace")]
public async Task HasDiagnostic(string testCase)
{
SkipTestIfVersionIsTooLow(
["InvocationWithTableExtension", "InvocationBareSelfInTableExtension", "TableExt_Multiple_SameBase", "TableExtension", "TableExtensionTypeWithLength", "TableExt_NamespaceCasingMismatch"],
["CuratedPair_OwnExtensionField", "InvocationWithTableExtension", "InvocationBareSelfInTableExtension", "TableExt_Multiple_SameBase", "TableExtension", "TableExtensionTypeWithLength", "TableExt_NamespaceCasingMismatch"],
testCase,
"13.0",
"No support for tableextensions when target itself is already declared in the same module");
Expand All @@ -104,6 +123,7 @@ public async Task HasDiagnostic(string testCase)

[Test]
[TestCase("BuiltInInvocation")]
[TestCase("CuratedPair_MicrosoftNamespace")]
[TestCase("Invocation_ObsoleteStateRemoved")]
[TestCase("Invocation_Pragma")]
[TestCase("Invocation_SourceTableObsoleteStateRemoved")]
Expand Down Expand Up @@ -131,6 +151,26 @@ public async Task NoDiagnostic(string testCase)
_fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.TransferFieldsNameMismatch);
}

[Test]
[TestCase("NonCuratedPair_MicrosoftTables")]
public async Task HasDiagnosticWithMicrosoftPublisher(string testCase)
{
var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasDiagnostic), $"{testCase}.al"))
.ConfigureAwait(false);

CreateFixtureWithMicrosoftPublisher().HasDiagnosticAtAllMarkers(code, DiagnosticIds.TransferFieldsNameMismatch);
}

[Test]
[TestCase("CuratedPair_MicrosoftPublisher")]
public async Task NoDiagnosticWithMicrosoftPublisher(string testCase)
{
var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al"))
.ConfigureAwait(false);

CreateFixtureWithMicrosoftPublisher().NoDiagnosticAtAllMarkers(code, DiagnosticIds.TransferFieldsNameMismatch);
}

[Test]
[TestCase("Affix_Invocation_CoreNameDiffers")]
[TestCase("Affix_Invocation_OwnTableFieldsNotStripped")]
Expand Down
Loading
Loading