From 9d5a95dd49ab17b99191fa4c256d9914416c2270 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Tue, 1 Sep 2026 15:43:27 +0200 Subject: [PATCH 1/6] feat(FC0002): check casing of generic type arguments and subtyped object references Walk GenericNamedDataTypeSyntax (List of [...], Dictionary of [...]) type arguments by pushing the node onto the existing explicit stack. Collect IdentifierNameSyntax nodes from SubtypedDataTypeSyntax object references and resolve via GetSymbolInfo in a new ResolveObjectReferences batch pass, grouped by (TypeName, ObjectName) to avoid cross-contamination with the identifiers list. The existing CasingMismatchCodeFix (CanonicalText + QuoteIdentifierIfNeeded) applies automatically to the new diagnostic locations. Closes #255 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ALCops.Common/Reflection/EnumProvider.cs | 3 ++ .../CasingMismatchDeclaration.cs | 25 ++++++++++ .../HasDiagnostic/GenericDataType.al | 13 ++++++ .../HasDiagnostic/SubtypedObjectReference.al | 42 +++++++++++++++++ .../HasFix/GenericTypeArgument/current.al | 5 ++ .../HasFix/GenericTypeArgument/expected.al | 5 ++ .../HasFix/QuotedObjectReference/current.al | 13 ++++++ .../HasFix/QuotedObjectReference/expected.al | 13 ++++++ .../NoDiagnostic/GenericDataType.al | 13 ++++++ .../NoDiagnostic/SubtypedObjectReference.al | 43 +++++++++++++++++ .../Analyzers/CasingMismatchIdentifier.cs | 46 ++++++++++++++++++- 11 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/GenericDataType.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/SubtypedObjectReference.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/current.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/expected.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/current.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/expected.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/GenericDataType.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/SubtypedObjectReference.al diff --git a/src/ALCops.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index 315a4486..0cc2ceea 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -1017,6 +1017,8 @@ public static class SyntaxKind new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.Field))); private static readonly Lazy _fieldGroup = new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.FieldGroup))); + private static readonly Lazy _genericDataType = + new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.GenericDataType))); private static readonly Lazy _globalVarSection = new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.GlobalVarSection))); private static readonly Lazy _identifierName = @@ -1307,6 +1309,7 @@ public static class SyntaxKind public static NavCodeAnalysis.SyntaxKind ForStatement => _forStatement.Value; public static NavCodeAnalysis.SyntaxKind Field => _field.Value; public static NavCodeAnalysis.SyntaxKind FieldGroup => _fieldGroup.Value; + public static NavCodeAnalysis.SyntaxKind GenericDataType => _genericDataType.Value; public static NavCodeAnalysis.SyntaxKind GlobalVarSection => _globalVarSection.Value; public static NavCodeAnalysis.SyntaxKind IdentifierName => _identifierName.Value; public static NavCodeAnalysis.SyntaxKind IdentifierEqualsLiteral => _identifierEqualsLiteral.Value; diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs index 4d4fc618..4cc4d7c8 100644 --- a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs @@ -1,3 +1,4 @@ +using ALCops.FormattingCop.CodeFixes; using RoslynTestKit; namespace ALCops.FormattingCop.Test @@ -40,6 +41,8 @@ public void Setup() [TestCase("GlobalVarAndLocalVar")] [TestCase("XmlPortDataType")] [TestCase("XmlPortObjectAccess")] + [TestCase("GenericDataType")] + [TestCase("SubtypedObjectReference")] public async Task HasDiagnostic(string testCase) { SkipTestIfVersionIsTooLow( @@ -92,6 +95,8 @@ public async Task HasDiagnostic(string testCase) [TestCase("GlobalVarAndLocalVar")] [TestCase("XmlPortDataType")] [TestCase("XmlPortObjectAccess")] + [TestCase("GenericDataType")] + [TestCase("SubtypedObjectReference")] public async Task NoDiagnostic(string testCase) { SkipTestIfVersionIsTooLow( @@ -117,5 +122,25 @@ public async Task NoDiagnostic(string testCase) _fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.CasingMismatch); } + + [Test] + [TestCase("GenericTypeArgument")] + [TestCase("QuotedObjectReference")] + public async Task HasFix(string testCase) + { + var currentCode = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasFix), testCase, "current.al")) + .ConfigureAwait(false); + + var expectedCode = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasFix), testCase, "expected.al")) + .ConfigureAwait(false); + + var fixture = RoslynFixtureFactory.Create( + new CodeFixTestFixtureConfig + { + AdditionalAnalyzers = [_analyzer] + }); + + fixture.TestCodeFix(currentCode, expectedCode, DiagnosticDescriptors.CasingMismatch); + } } } \ No newline at end of file diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/GenericDataType.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/GenericDataType.al new file mode 100644 index 00000000..e5f1a5d3 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/GenericDataType.al @@ -0,0 +1,13 @@ +codeunit 50100 MyCodeunit +{ + var + MyList: List of [[|TEXT|]]; + MyDict: Dictionary of [[|INTEGER|], [|TEXT|]]; + MyNestedList: List of [Dictionary of [Integer, [|TEXT|]]]; + MyInterfaceList: List of [[|INTERFACE|] "My Interface"]; + MyCodeList: List of [[|CODE|][20]]; + MyEnumList: List of [[|ENUM|] "My Enum"]; +} + +interface "My Interface" { } +enum 50100 "My Enum" { value(0; "My Value") { } } diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/SubtypedObjectReference.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/SubtypedObjectReference.al new file mode 100644 index 00000000..e1139354 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/SubtypedObjectReference.al @@ -0,0 +1,42 @@ +codeunit 50100 MyCodeunit +{ + var + MyTable: Record [|"MY CUSTOMER"|]; + MyInterface: Interface [|"IMYINTERFACE"|]; + MyCodeunit2: Codeunit [|"MYHELPER"|]; + MyPage: Page [|"MY CUSTOMER CARD"|]; + MyXmlPort: XmlPort [|"MY EXPORT"|]; + MyInterfaceList: List of [Interface [|"IMYINTERFACE"|]]; + + procedure MyProcedure(ParamTable: Record [|"MY CUSTOMER"|]) ReturnTable: Record [|"MY CUSTOMER"|] + var + LocalTable: Record [|"MY CUSTOMER"|]; + begin + end; +} + +table 50100 "My Customer" +{ + fields + { + field(1; "Primary Key"; Integer) { } + } +} + +interface IMyInterface { } +codeunit 50101 MyHelper { } + +page 50100 "My Customer Card" +{ + SourceTable = "My Customer"; +} + +xmlport 50100 "My Export" +{ + schema + { + textelement(Root) + { + } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/current.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/current.al new file mode 100644 index 00000000..e1b1d3da --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/current.al @@ -0,0 +1,5 @@ +codeunit 50100 MyCodeunit +{ + var + MyList: List of [[|TEXT|]]; +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/expected.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/expected.al new file mode 100644 index 00000000..943d0e9c --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/expected.al @@ -0,0 +1,5 @@ +codeunit 50100 MyCodeunit +{ + var + MyList: List of [Text]; +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/current.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/current.al new file mode 100644 index 00000000..5d9962bf --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/current.al @@ -0,0 +1,13 @@ +codeunit 50100 MyCodeunit +{ + var + MyTable: Record [|"MY CUSTOMER"|]; +} + +table 50100 "My Customer" +{ + fields + { + field(1; "Primary Key"; Integer) { } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/expected.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/expected.al new file mode 100644 index 00000000..b2795826 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/expected.al @@ -0,0 +1,13 @@ +codeunit 50100 MyCodeunit +{ + var + MyTable: Record "My Customer"; +} + +table 50100 "My Customer" +{ + fields + { + field(1; "Primary Key"; Integer) { } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/GenericDataType.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/GenericDataType.al new file mode 100644 index 00000000..c6baf991 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/GenericDataType.al @@ -0,0 +1,13 @@ +codeunit 50100 MyCodeunit +{ + var + MyList: List of [[|Text|]]; + MyDict: Dictionary of [[|Integer|], [|Text|]]; + MyNestedList: List of [Dictionary of [Integer, [|Text|]]]; + MyInterfaceList: List of [[|Interface|] "My Interface"]; + MyCodeList: List of [[|Code|][20]]; + MyEnumList: List of [[|Enum|] "My Enum"]; +} + +interface "My Interface" { } +enum 50100 "My Enum" { value(0; "My Value") { } } diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/SubtypedObjectReference.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/SubtypedObjectReference.al new file mode 100644 index 00000000..07dc70b9 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/SubtypedObjectReference.al @@ -0,0 +1,43 @@ +codeunit 50100 MyCodeunit +{ + var + MyTable: Record [|"My Customer"|]; + MyInterface: Interface [|IMyInterface|]; + MyCodeunit2: Codeunit [|MyHelper|]; + MyPage: Page [|"My Customer Card"|]; + MyXmlPort: XmlPort [|"My Export"|]; + MyInterfaceList: List of [Interface [|IMyInterface|]]; + MyTableById: Record [|50100|]; + + procedure MyProcedure(ParamTable: Record [|"My Customer"|]) ReturnTable: Record [|"My Customer"|] + var + LocalTable: Record [|"My Customer"|]; + begin + end; +} + +table 50100 "My Customer" +{ + fields + { + field(1; "Primary Key"; Integer) { } + } +} + +interface IMyInterface { } +codeunit 50101 MyHelper { } + +page 50100 "My Customer Card" +{ + SourceTable = "My Customer"; +} + +xmlport 50100 "My Export" +{ + schema + { + textelement(Root) + { + } + } +} diff --git a/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs b/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs index 3b3f666e..8d34f265 100644 --- a/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs +++ b/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs @@ -52,12 +52,14 @@ private void AnalyzeDeclarations(SymbolAnalysisContext ctx) var identifiers = new List<(IdentifierNameSyntax Node, SyntaxNode? Scope)>(); var qualifiedNames = new List<(QualifiedNameSyntax Node, SyntaxNode? Scope)>(); var triggers = new List(); + var objectReferences = new List(); - WalkNode(ctx, root, identifiers, qualifiedNames, triggers); + WalkNode(ctx, root, identifiers, qualifiedNames, triggers, objectReferences); ResolveIdentifiers(ctx, semanticModel, identifiers); ResolveQualifiedNames(ctx, semanticModel, qualifiedNames); ResolveTriggers(ctx, semanticModel, triggers); + ResolveObjectReferences(ctx, semanticModel, objectReferences); } #region Tree Walk @@ -73,6 +75,7 @@ private static void WalkNode( List<(IdentifierNameSyntax Node, SyntaxNode? Scope)> identifiers, List<(QualifiedNameSyntax Node, SyntaxNode? Scope)> qualifiedNames, List triggers, + List objectReferences, bool skipChildIdentifiers = false) { var stack = new Stack<(SyntaxNode node, bool skipIds, SyntaxNode? scope)>(); @@ -98,7 +101,11 @@ private static void WalkNode( if (child is SubtypedDataTypeSyntax subtyped) { if (subtyped.Subtype.Kind == EnumProvider.SyntaxKind.ObjectReference) + { CompareAgainstDictionary(ctx, subtyped.TypeName, _navTypeKindDictionary); + if (subtyped.Subtype.Identifier is IdentifierNameSyntax subtypeName) + objectReferences.Add(subtypeName); + } continue; } @@ -106,7 +113,8 @@ private static void WalkNode( { CompareAgainstDictionary(ctx, dataType.TypeName, _navTypeKindDictionary); if (kind == EnumProvider.SyntaxKind.EnumDataType || - kind == EnumProvider.SyntaxKind.LabelDataType) + kind == EnumProvider.SyntaxKind.LabelDataType || + kind == EnumProvider.SyntaxKind.GenericDataType) stack.Push((child, false, currentScope)); continue; } @@ -541,6 +549,40 @@ private static void ResolveTriggers( } } + private static void ResolveObjectReferences( + SymbolAnalysisContext ctx, + SemanticModel semanticModel, + List objectReferences) + { + var groups = objectReferences + .ToLookup(node => + { + var subtypedDataType = (SubtypedDataTypeSyntax)node.Parent!.Parent!; + return subtypedDataType.TypeName.ValueText + "\0" + node.Identifier.ValueText; + }, SemanticFacts.NameEqualityComparer); + + foreach (var group in groups) + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + IdentifierNameSyntax? representative = null; + foreach (var item in group) + { + if (representative is null || item.Position > representative.Position) + representative = item; + } + + if (representative is null) + continue; + + if (semanticModel.GetSymbolInfo(representative, ctx.CancellationToken).Symbol is not ISymbol symbol) + continue; + + foreach (var item in group) + CompareIdentifier(ctx, item.Identifier, symbol.Name); + } + } + #endregion #region Comparison and Reporting From 0e2705a446d6bda33c88d2bb55b1c35b6ae37f4a Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Tue, 1 Sep 2026 15:50:06 +0200 Subject: [PATCH 2/6] docs: document FC0002 generic type argument and object reference casing Co-Authored-By: Claude Fable 5 --- .claude/rules/diagnostics/fc0002-casing-mismatch.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.claude/rules/diagnostics/fc0002-casing-mismatch.md b/.claude/rules/diagnostics/fc0002-casing-mismatch.md index 3184611d..5072f95e 100644 --- a/.claude/rules/diagnostics/fc0002-casing-mismatch.md +++ b/.claude/rules/diagnostics/fc0002-casing-mismatch.md @@ -17,12 +17,16 @@ Reports when the casing of a keyword or identifier reference differs from its ca | `XmlPort → Xmlport` remap in `_symbolKindDictionary` | The `::` left side and static class bind to the SDK's `XmlportClassTypeSymbol`, literally named `"Xmlport"` (`XmlportClassTypeSymbol.cs`). | | `.Run`/`.Import`/`.Export` receiver (`Xmlport.Run`) is NOT analyzed | The `KeywordTexts` filter in `ResolveIdentifiers` skips identifiers named after keywords to avoid false positives on user symbols. Known false negative; kept intentionally. | | Identifiers grouped by (text, scope) before `GetSymbolInfo` | Performance: one semantic call per distinct spelling per method scope. | +| Generic type arguments (`List of [...]`, `Dictionary of [...]`) walked by pushing the `GenericNamedDataTypeSyntax` node onto the existing stack | `ChildNodes()` yields only the type-argument `DataTypeSyntax` nodes (TypeName/`of`/brackets are tokens, never revisited), so the outer type name is not double-reported and nested generics recurse for free. Issue #255. | +| Object references after subtyped data types (`Record MyTable`, `Interface "IMyInterface"`) resolved via `GetSymbolInfo` on the inner `IdentifierNameSyntax` | The SDK's `GetSemanticInfoSymbolInNonMemberContext` derives the `SymbolKind` from the enclosing `SubtypedDataTypeSyntax.TypeName`, so one call returns the referenced object's canonical `Name` — no member model needed for declaration nodes. | +| Object references batched in a dedicated `objectReferences` list keyed by `TypeName + "\0" + name`, NOT the `identifiers` list | The `identifiers` list groups by (text, method scope); `Record Customer` and a variable named `Customer` would share a group and cross-contaminate the canonical text (false positive + wrong fix). Kind must be in the key because `Record Foo` and `Codeunit Foo` resolve to different symbols. | +| `ObjectIdSyntax` (`Record 18`) and namespace-qualified `QualifiedNameSyntax` subtypes are not collected | IDs have no casing; a namespace-aware compare doesn't fit the batched resolution and is deferred. | ## Architecture - Two analyzers share the descriptor `DiagnosticDescriptors.CasingMismatch`: `CasingMismatchKeyword` (keyword tokens) and `CasingMismatchIdentifier` (identifiers, data types, properties, option/object access). - `CasingMismatchKeyword`: `RegisterSymbolAction` per object kind; walks descendant tokens, compares keyword tokens against `SyntaxFactory.Token(kind).ValueText`. Skips tokens whose parent is a `*DataType` node or `IdentifierName`. -- `CasingMismatchIdentifier`: single iterative tree walk per object symbol. Dictionary-resolvable nodes are handled inline (fast); identifiers, qualified names, and triggers are batched for semantic-model resolution, grouped by (text, scope) so `GetSymbolInfo` runs once per group. +- `CasingMismatchIdentifier`: single iterative tree walk per object symbol. Dictionary-resolvable nodes are handled inline (fast); identifiers, qualified names, triggers, and subtyped object references are batched for semantic-model resolution (`ResolveIdentifiers`/`ResolveQualifiedNames`/`ResolveTriggers`/`ResolveObjectReferences`), grouped so `GetSymbolInfo` runs once per group. `GenericDataType` is in the stack-push allow-list alongside `EnumDataType`/`LabelDataType` so type arguments are walked. Key dictionaries (all `OrdinalIgnoreCase` keyed, value = canonical text): @@ -48,7 +52,8 @@ Key dictionaries (all `OrdinalIgnoreCase` keyed, value = canonical text): - `Xmlport.Run` receiver with wrong casing (`XMLPORT.Run`) is not flagged (keyword-named identifier filter, see design decisions). - Option members of platform table fields (e.g. `"Object Type"::XMLport`) resolve via semantic model to the platform's own casing. +- Namespace-qualified object references (`Record Ns.MyTable`) are not casing-checked (deliberately skipped, see design decisions). ## CodeFix: CasingMismatchKeyword -`CodeFixes/CasingMismatchKeyword.cs` fixes keyword tokens only; identifier diagnostics carry `CanonicalText` in properties but have no CodeFix yet. +`CodeFixes/CasingMismatchKeyword.cs` (class `CasingMismatchCodeFix`) is registered for the whole FC0002 ID and fixes every diagnostic carrying `CanonicalText` in properties — keyword and identifier diagnostics alike. It replaces the diagnostic span with `CanonicalText.QuoteIdentifierIfNeededWithReflection()`, which re-quotes names that need quotes (`"MY TABLE"` → `"My Table"`) and drops unnecessary quotes (`"IMYINTERFACE"` → `IMyInterface`). From 71724f802f05b5d2aab2e4381750cac94aca4e48 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Tue, 1 Sep 2026 17:19:25 +0200 Subject: [PATCH 3/6] test(FC0002): skip fixtures using Interface generic type arguments below AL 14.0 Co-Authored-By: Claude Fable 5 --- .../CasingMismatchDeclaration.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs index 4cc4d7c8..ff3cc1d7 100644 --- a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs @@ -63,6 +63,12 @@ public async Task HasDiagnostic(string testCase) "14.0" ); + SkipTestIfVersionIsTooLow( + ["GenericDataType", "SubtypedObjectReference"], + testCase, + "14.0", + "No support for Interface as a generic type argument before version 14.0"); + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasDiagnostic), $"{testCase}.al")) .ConfigureAwait(false); @@ -117,6 +123,12 @@ public async Task NoDiagnostic(string testCase) "14.0" ); + SkipTestIfVersionIsTooLow( + ["GenericDataType", "SubtypedObjectReference"], + testCase, + "14.0", + "No support for Interface as a generic type argument before version 14.0"); + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) .ConfigureAwait(false); From 27ef8dbd9affaa6ff1d72fc553a40e7602fbaad9 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Tue, 1 Sep 2026 17:27:45 +0200 Subject: [PATCH 4/6] refactor(FC0002): capture object reference type name at collection site Replaces the parent-traversal cast and "\0"-separated composite group key in ResolveObjectReferences with a (TypeName, Node) tuple collected in WalkNode, grouped by the default tuple comparer like ResolveIdentifiers. Co-Authored-By: Claude Fable 5 --- .../diagnostics/fc0002-casing-mismatch.md | 2 +- .../Analyzers/CasingMismatchIdentifier.cs | 20 ++++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.claude/rules/diagnostics/fc0002-casing-mismatch.md b/.claude/rules/diagnostics/fc0002-casing-mismatch.md index 5072f95e..01626bcc 100644 --- a/.claude/rules/diagnostics/fc0002-casing-mismatch.md +++ b/.claude/rules/diagnostics/fc0002-casing-mismatch.md @@ -19,7 +19,7 @@ Reports when the casing of a keyword or identifier reference differs from its ca | Identifiers grouped by (text, scope) before `GetSymbolInfo` | Performance: one semantic call per distinct spelling per method scope. | | Generic type arguments (`List of [...]`, `Dictionary of [...]`) walked by pushing the `GenericNamedDataTypeSyntax` node onto the existing stack | `ChildNodes()` yields only the type-argument `DataTypeSyntax` nodes (TypeName/`of`/brackets are tokens, never revisited), so the outer type name is not double-reported and nested generics recurse for free. Issue #255. | | Object references after subtyped data types (`Record MyTable`, `Interface "IMyInterface"`) resolved via `GetSymbolInfo` on the inner `IdentifierNameSyntax` | The SDK's `GetSemanticInfoSymbolInNonMemberContext` derives the `SymbolKind` from the enclosing `SubtypedDataTypeSyntax.TypeName`, so one call returns the referenced object's canonical `Name` — no member model needed for declaration nodes. | -| Object references batched in a dedicated `objectReferences` list keyed by `TypeName + "\0" + name`, NOT the `identifiers` list | The `identifiers` list groups by (text, method scope); `Record Customer` and a variable named `Customer` would share a group and cross-contaminate the canonical text (false positive + wrong fix). Kind must be in the key because `Record Foo` and `Codeunit Foo` resolve to different symbols. | +| Object references batched in a dedicated `objectReferences` list keyed by the `(TypeName, name)` tuple, NOT the `identifiers` list | The `identifiers` list groups by (text, method scope); `Record Customer` and a variable named `Customer` would share a group and cross-contaminate the canonical text (false positive + wrong fix). Kind must be in the key because `Record Foo` and `Codeunit Foo` resolve to different symbols. The type name is captured at the collection site in `WalkNode` (not re-derived via parent traversal), and the default case-sensitive tuple comparer matches `ResolveIdentifiers` — differently-cased duplicates just cost one extra `GetSymbolInfo`. | | `ObjectIdSyntax` (`Record 18`) and namespace-qualified `QualifiedNameSyntax` subtypes are not collected | IDs have no casing; a namespace-aware compare doesn't fit the batched resolution and is deferred. | ## Architecture diff --git a/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs b/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs index 8d34f265..78858662 100644 --- a/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs +++ b/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs @@ -52,7 +52,7 @@ private void AnalyzeDeclarations(SymbolAnalysisContext ctx) var identifiers = new List<(IdentifierNameSyntax Node, SyntaxNode? Scope)>(); var qualifiedNames = new List<(QualifiedNameSyntax Node, SyntaxNode? Scope)>(); var triggers = new List(); - var objectReferences = new List(); + var objectReferences = new List<(string? TypeName, IdentifierNameSyntax Node)>(); WalkNode(ctx, root, identifiers, qualifiedNames, triggers, objectReferences); @@ -75,7 +75,7 @@ private static void WalkNode( List<(IdentifierNameSyntax Node, SyntaxNode? Scope)> identifiers, List<(QualifiedNameSyntax Node, SyntaxNode? Scope)> qualifiedNames, List triggers, - List objectReferences, + List<(string? TypeName, IdentifierNameSyntax Node)> objectReferences, bool skipChildIdentifiers = false) { var stack = new Stack<(SyntaxNode node, bool skipIds, SyntaxNode? scope)>(); @@ -104,7 +104,7 @@ private static void WalkNode( { CompareAgainstDictionary(ctx, subtyped.TypeName, _navTypeKindDictionary); if (subtyped.Subtype.Identifier is IdentifierNameSyntax subtypeName) - objectReferences.Add(subtypeName); + objectReferences.Add((subtyped.TypeName.ValueText, subtypeName)); } continue; } @@ -552,14 +552,10 @@ private static void ResolveTriggers( private static void ResolveObjectReferences( SymbolAnalysisContext ctx, SemanticModel semanticModel, - List objectReferences) + List<(string? TypeName, IdentifierNameSyntax Node)> objectReferences) { var groups = objectReferences - .ToLookup(node => - { - var subtypedDataType = (SubtypedDataTypeSyntax)node.Parent!.Parent!; - return subtypedDataType.TypeName.ValueText + "\0" + node.Identifier.ValueText; - }, SemanticFacts.NameEqualityComparer); + .ToLookup(item => (item.TypeName, item.Node.Identifier.ValueText)); foreach (var group in groups) { @@ -568,8 +564,8 @@ private static void ResolveObjectReferences( IdentifierNameSyntax? representative = null; foreach (var item in group) { - if (representative is null || item.Position > representative.Position) - representative = item; + if (representative is null || item.Node.Position > representative.Position) + representative = item.Node; } if (representative is null) @@ -579,7 +575,7 @@ private static void ResolveObjectReferences( continue; foreach (var item in group) - CompareIdentifier(ctx, item.Identifier, symbol.Name); + CompareIdentifier(ctx, item.Node.Identifier, symbol.Name); } } From 2af184450ffcb1404c501e6670a2c0b1923a7d63 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Tue, 1 Sep 2026 17:57:51 +0200 Subject: [PATCH 5/6] feat(FC0002): check casing of namespace-qualified object references Collect QualifiedNameSyntax subtypes into a dedicated batched pass. GetSymbolInfo on the qualified name resolves via the SDK's GetSymbolFromObjectReference QualifiedName case; the object name is compared against symbol.Name and namespace parts right-aligned against GetContainingNamespaceQualifiedNameWithReflection (all-TFM safe). Co-Authored-By: Claude Fable 5 --- .../diagnostics/fc0002-casing-mismatch.md | 6 +- .../CasingMismatchDeclaration.cs | 3 + .../NamespacedObjectReference.al | 24 +++++++ .../QualifiedObjectReference/current.al | 15 ++++ .../QualifiedObjectReference/expected.al | 15 ++++ .../NoDiagnostic/NamespacedObjectReference.al | 24 +++++++ .../Analyzers/CasingMismatchIdentifier.cs | 69 ++++++++++++++++++- 7 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/NamespacedObjectReference.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/current.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/expected.al create mode 100644 src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/NamespacedObjectReference.al diff --git a/.claude/rules/diagnostics/fc0002-casing-mismatch.md b/.claude/rules/diagnostics/fc0002-casing-mismatch.md index 01626bcc..5d25e500 100644 --- a/.claude/rules/diagnostics/fc0002-casing-mismatch.md +++ b/.claude/rules/diagnostics/fc0002-casing-mismatch.md @@ -20,13 +20,14 @@ Reports when the casing of a keyword or identifier reference differs from its ca | Generic type arguments (`List of [...]`, `Dictionary of [...]`) walked by pushing the `GenericNamedDataTypeSyntax` node onto the existing stack | `ChildNodes()` yields only the type-argument `DataTypeSyntax` nodes (TypeName/`of`/brackets are tokens, never revisited), so the outer type name is not double-reported and nested generics recurse for free. Issue #255. | | Object references after subtyped data types (`Record MyTable`, `Interface "IMyInterface"`) resolved via `GetSymbolInfo` on the inner `IdentifierNameSyntax` | The SDK's `GetSemanticInfoSymbolInNonMemberContext` derives the `SymbolKind` from the enclosing `SubtypedDataTypeSyntax.TypeName`, so one call returns the referenced object's canonical `Name` — no member model needed for declaration nodes. | | Object references batched in a dedicated `objectReferences` list keyed by the `(TypeName, name)` tuple, NOT the `identifiers` list | The `identifiers` list groups by (text, method scope); `Record Customer` and a variable named `Customer` would share a group and cross-contaminate the canonical text (false positive + wrong fix). Kind must be in the key because `Record Foo` and `Codeunit Foo` resolve to different symbols. The type name is captured at the collection site in `WalkNode` (not re-derived via parent traversal), and the default case-sensitive tuple comparer matches `ResolveIdentifiers` — differently-cased duplicates just cost one extra `GetSymbolInfo`. | -| `ObjectIdSyntax` (`Record 18`) and namespace-qualified `QualifiedNameSyntax` subtypes are not collected | IDs have no casing; a namespace-aware compare doesn't fit the batched resolution and is deferred. | +| `ObjectIdSyntax` (`Record 18`) subtypes are not collected | IDs have no casing. | +| Namespace-qualified subtypes (`Record Ns.Path.MyTable`) resolved in a separate `ResolveQualifiedObjectReferences` pass | `GetSymbolInfo` on the `QualifiedNameSyntax` routes through the SDK's `GetSymbolFromObjectReference`, which has an explicit `QualifiedName` case (`LookupObjectTypeSymbol`). Namespace-part casing is compared right-aligned against `GetContainingNamespaceQualifiedNameWithReflection()` split on `.` (the reflection helper works on all TFMs; a null result skips namespace parts but still checks the object name). Not fed into `ResolveQualifiedNames` — its `Left.Kind == IdentifierName` branch assumes a field-in-object shape and early-returns. | ## Architecture - Two analyzers share the descriptor `DiagnosticDescriptors.CasingMismatch`: `CasingMismatchKeyword` (keyword tokens) and `CasingMismatchIdentifier` (identifiers, data types, properties, option/object access). - `CasingMismatchKeyword`: `RegisterSymbolAction` per object kind; walks descendant tokens, compares keyword tokens against `SyntaxFactory.Token(kind).ValueText`. Skips tokens whose parent is a `*DataType` node or `IdentifierName`. -- `CasingMismatchIdentifier`: single iterative tree walk per object symbol. Dictionary-resolvable nodes are handled inline (fast); identifiers, qualified names, triggers, and subtyped object references are batched for semantic-model resolution (`ResolveIdentifiers`/`ResolveQualifiedNames`/`ResolveTriggers`/`ResolveObjectReferences`), grouped so `GetSymbolInfo` runs once per group. `GenericDataType` is in the stack-push allow-list alongside `EnumDataType`/`LabelDataType` so type arguments are walked. +- `CasingMismatchIdentifier`: single iterative tree walk per object symbol. Dictionary-resolvable nodes are handled inline (fast); identifiers, qualified names, triggers, and subtyped object references (simple and namespace-qualified) are batched for semantic-model resolution (`ResolveIdentifiers`/`ResolveQualifiedNames`/`ResolveTriggers`/`ResolveObjectReferences`/`ResolveQualifiedObjectReferences`), grouped so `GetSymbolInfo` runs once per group. `GenericDataType` is in the stack-push allow-list alongside `EnumDataType`/`LabelDataType` so type arguments are walked. Key dictionaries (all `OrdinalIgnoreCase` keyed, value = canonical text): @@ -52,7 +53,6 @@ Key dictionaries (all `OrdinalIgnoreCase` keyed, value = canonical text): - `Xmlport.Run` receiver with wrong casing (`XMLPORT.Run`) is not flagged (keyword-named identifier filter, see design decisions). - Option members of platform table fields (e.g. `"Object Type"::XMLport`) resolve via semantic model to the platform's own casing. -- Namespace-qualified object references (`Record Ns.MyTable`) are not casing-checked (deliberately skipped, see design decisions). ## CodeFix: CasingMismatchKeyword diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs index ff3cc1d7..278a36e2 100644 --- a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs @@ -43,6 +43,7 @@ public void Setup() [TestCase("XmlPortObjectAccess")] [TestCase("GenericDataType")] [TestCase("SubtypedObjectReference")] + [TestCase("NamespacedObjectReference")] public async Task HasDiagnostic(string testCase) { SkipTestIfVersionIsTooLow( @@ -103,6 +104,7 @@ public async Task HasDiagnostic(string testCase) [TestCase("XmlPortObjectAccess")] [TestCase("GenericDataType")] [TestCase("SubtypedObjectReference")] + [TestCase("NamespacedObjectReference")] public async Task NoDiagnostic(string testCase) { SkipTestIfVersionIsTooLow( @@ -138,6 +140,7 @@ public async Task NoDiagnostic(string testCase) [Test] [TestCase("GenericTypeArgument")] [TestCase("QuotedObjectReference")] + [TestCase("QualifiedObjectReference")] public async Task HasFix(string testCase) { var currentCode = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasFix), testCase, "current.al")) diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/NamespacedObjectReference.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/NamespacedObjectReference.al new file mode 100644 index 00000000..9138a326 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/NamespacedObjectReference.al @@ -0,0 +1,24 @@ +namespace MyPublisher.MyExtension.MyAppDomain; + +codeunit 50000 MyCodeunit +{ + var + FullyQualified: Record [|MYPUBLISHER|].[|MYEXTENSION|].[|MYAPPDOMAIN|].[|MYTABLE|]; + Unqualified: Record [|MYTABLE|]; + + procedure Foo(p: Record [|MYPUBLISHER|].[|MYEXTENSION|].[|MYAPPDOMAIN|].[|MYTABLE|]) + var + LocalQualified: Codeunit [|MYPUBLISHER|].[|MYEXTENSION|].[|MYAPPDOMAIN|].[|MYHELPER|]; + begin + end; +} + +table 50000 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +codeunit 50001 MyHelper { } diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/current.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/current.al new file mode 100644 index 00000000..e392ee15 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/current.al @@ -0,0 +1,15 @@ +namespace MyPublisher.MyExtension.MyAppDomain; + +codeunit 50000 MyCodeunit +{ + var + FullyQualified: Record MyPublisher.MyExtension.MyAppDomain.[|MYTABLE|]; +} + +table 50000 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/expected.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/expected.al new file mode 100644 index 00000000..eba6b808 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/expected.al @@ -0,0 +1,15 @@ +namespace MyPublisher.MyExtension.MyAppDomain; + +codeunit 50000 MyCodeunit +{ + var + FullyQualified: Record MyPublisher.MyExtension.MyAppDomain.MyTable; +} + +table 50000 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/NamespacedObjectReference.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/NamespacedObjectReference.al new file mode 100644 index 00000000..846cdb8a --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/NamespacedObjectReference.al @@ -0,0 +1,24 @@ +namespace MyPublisher.MyExtension.MyAppDomain; + +codeunit 50000 MyCodeunit +{ + var + FullyQualified: Record [|MyPublisher|].[|MyExtension|].[|MyAppDomain|].[|MyTable|]; + Unqualified: Record [|MyTable|]; + + procedure Foo(p: Record [|MyPublisher|].[|MyExtension|].[|MyAppDomain|].[|MyTable|]) + var + LocalQualified: Codeunit [|MyPublisher|].[|MyExtension|].[|MyAppDomain|].[|MyHelper|]; + begin + end; +} + +table 50000 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +codeunit 50001 MyHelper { } diff --git a/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs b/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs index 78858662..52ada31b 100644 --- a/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs +++ b/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs @@ -53,13 +53,15 @@ private void AnalyzeDeclarations(SymbolAnalysisContext ctx) var qualifiedNames = new List<(QualifiedNameSyntax Node, SyntaxNode? Scope)>(); var triggers = new List(); var objectReferences = new List<(string? TypeName, IdentifierNameSyntax Node)>(); + var qualifiedObjectReferences = new List<(string? TypeName, QualifiedNameSyntax Node)>(); - WalkNode(ctx, root, identifiers, qualifiedNames, triggers, objectReferences); + WalkNode(ctx, root, identifiers, qualifiedNames, triggers, objectReferences, qualifiedObjectReferences); ResolveIdentifiers(ctx, semanticModel, identifiers); ResolveQualifiedNames(ctx, semanticModel, qualifiedNames); ResolveTriggers(ctx, semanticModel, triggers); ResolveObjectReferences(ctx, semanticModel, objectReferences); + ResolveQualifiedObjectReferences(ctx, semanticModel, qualifiedObjectReferences); } #region Tree Walk @@ -76,6 +78,7 @@ private static void WalkNode( List<(QualifiedNameSyntax Node, SyntaxNode? Scope)> qualifiedNames, List triggers, List<(string? TypeName, IdentifierNameSyntax Node)> objectReferences, + List<(string? TypeName, QualifiedNameSyntax Node)> qualifiedObjectReferences, bool skipChildIdentifiers = false) { var stack = new Stack<(SyntaxNode node, bool skipIds, SyntaxNode? scope)>(); @@ -105,6 +108,8 @@ private static void WalkNode( CompareAgainstDictionary(ctx, subtyped.TypeName, _navTypeKindDictionary); if (subtyped.Subtype.Identifier is IdentifierNameSyntax subtypeName) objectReferences.Add((subtyped.TypeName.ValueText, subtypeName)); + else if (subtyped.Subtype.Identifier is QualifiedNameSyntax qualifiedSubtypeName) + qualifiedObjectReferences.Add((subtyped.TypeName.ValueText, qualifiedSubtypeName)); } continue; } @@ -579,6 +584,68 @@ private static void ResolveObjectReferences( } } + private static void ResolveQualifiedObjectReferences( + SymbolAnalysisContext ctx, + SemanticModel semanticModel, + List<(string? TypeName, QualifiedNameSyntax Node)> qualifiedObjectReferences) + { + var groups = qualifiedObjectReferences + .ToLookup(item => (item.TypeName, item.Node.ToString())); + + foreach (var group in groups) + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + QualifiedNameSyntax? representative = null; + foreach (var item in group) + { + if (representative is null || item.Node.Position > representative.Position) + representative = item.Node; + } + + if (representative is null) + continue; + + if (semanticModel.GetSymbolInfo(representative, ctx.CancellationToken).Symbol is not ISymbol symbol) + continue; + + var namespaceParts = symbol.GetContainingNamespaceQualifiedNameWithReflection()?.Split('.'); + + foreach (var item in group) + { + CompareIdentifier(ctx, item.Node.Right.Identifier, symbol.Name); + CompareNamespaceParts(ctx, item.Node.Left, namespaceParts); + } + } + } + + // Right-aligned: the qualifier's parts are matched against the tail of the declared + // namespace, so a shorter qualifier never walks past its own leftmost part. + private static void CompareNamespaceParts(SymbolAnalysisContext ctx, SyntaxNode left, string[]? namespaceParts) + { + if (namespaceParts is null) + return; + + var index = namespaceParts.Length - 1; + var current = left; + + while (index >= 0) + { + if (current is QualifiedNameSyntax qualified) + { + CompareIdentifier(ctx, qualified.Right.Identifier, namespaceParts[index]); + current = qualified.Left; + index--; + continue; + } + + if (current is IdentifierNameSyntax identifier) + CompareIdentifier(ctx, identifier.Identifier, namespaceParts[index]); + + break; + } + } + #endregion #region Comparison and Reporting From 3d759ee128aa9dddcf501fce3861972a1488842a Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Tue, 1 Sep 2026 17:57:51 +0200 Subject: [PATCH 6/6] docs: require namespace fixture variants in the testing guide Co-Authored-By: Claude Fable 5 --- .claude/rules/testing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 584e9ac0..933280c8 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -289,6 +289,7 @@ Rules for .al fixtures: 3. Keep fixtures minimal: only include code relevant to the rule being tested. 4. Place `[|...|]` markers precisely around the syntax node the analyzer targets. 5. Every `HasDiagnostic` fixture must have at least one marker. Every `NoDiagnostic` fixture must also have markers (on the same kind of syntax node, but in a valid scenario). +6. When adding or creating tests, consider both a fixture **without** namespaces and one **with** a `namespace` declaration (if applicable to the analyzed syntax) — analyzers must support both. Use a generic multi-part namespace such as `MyPublisher.MyExtension.MyAppDomain`, and where relevant include fully-qualified object references (`MyPublisher.MyExtension.MyAppDomain.MyTable`). See `Rules/CasingMismatchDeclaration/HasDiagnostic/NamespacedObjectReference.al` in FormattingCop.Test for an example. Typical fixture structure: