From 4da307158f6cf08b00ce8da93cdac8d729137760 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Fri, 11 Sep 2026 16:26:42 +0200 Subject: [PATCH 1/6] fix(LC0092): skip platform-named action areas, system actions and layout areas Every node inside an actions block is one SymbolKind.Action symbol, and every node inside a layout block is one SymbolKind.Control symbol, so the rule checked area, group, actionref, separator and systemaction names against the Action target and layout areas against the Control target. Those names are picked by the platform, not by the developer: an action area name comes from ActionAreaKind, a system action name from SystemActionKind, a layout area name from the page layout, and a group named Category_Process binds the group to that promoted-category slot. AnalyzeAction and AnalyzeControl now read IActionSymbol.ActionKind and IControlSymbol.ControlKind and skip those kinds. Ordinary groups, separators, action references, custom and file-upload actions stay checked under Action, and every other control kind under Control, so no new naming target and no settings-schema change is needed. EnumProvider gains ActionKind.SystemAction, resolved through the string overload with an out-of-range sentinel because the member is absent from the oldest supported SDK and default(ActionKind) is Area. Co-Authored-By: Claude Fable 5.1 --- .../diagnostics/lc0092-naming-pattern.md | 13 ++++++ .../references/regression-catalog.md | 1 + src/ALCops.Common/Reflection/EnumProvider.cs | 9 +++++ .../HasDiagnostic/ActionGroupCustomPattern.al | 15 +++++++ .../Rules/NamingPattern/NamingPattern.cs | 40 +++++++++++++++++-- .../NoDiagnostic/ActionAreaCustomPattern.al | 12 ++++++ .../NoDiagnostic/ActionAreaLowerCase.al | 12 ++++++ .../NoDiagnostic/ControlAreaCustomPattern.al | 23 +++++++++++ .../NoDiagnostic/ControlAreaLowerCase.al | 23 +++++++++++ .../PromotedCategoryGroupCustomPattern.al | 21 ++++++++++ .../NoDiagnostic/SystemActionCustomPattern.al | 15 +++++++ .../Analyzers/NamingPattern.cs | 22 ++++++++-- 12 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 src/ALCops.LinterCop.Test/Rules/NamingPattern/HasDiagnostic/ActionGroupCustomPattern.al create mode 100644 src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ActionAreaCustomPattern.al create mode 100644 src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ActionAreaLowerCase.al create mode 100644 src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ControlAreaCustomPattern.al create mode 100644 src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ControlAreaLowerCase.al create mode 100644 src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/PromotedCategoryGroupCustomPattern.al create mode 100644 src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/SystemActionCustomPattern.al diff --git a/.claude/rules/diagnostics/lc0092-naming-pattern.md b/.claude/rules/diagnostics/lc0092-naming-pattern.md index 9a19f34e..a05adb1a 100644 --- a/.claude/rules/diagnostics/lc0092-naming-pattern.md +++ b/.claude/rules/diagnostics/lc0092-naming-pattern.md @@ -36,6 +36,7 @@ Registers `CompilationStartAction` (settings, AppSourceCop affixes, `NamingPatte | Regex safety: 2-second match timeout, `ArgumentException`/`RegexMatchTimeoutException` caught and the pattern disabled | Protects against ReDoS and invalid user patterns without failing the analysis. | | `GetAppSourceCopConfiguration` wrapped in try-catch at compilation start, continuing with null affixes | It may throw in minimal (test) runtime environments. | | Settings loaded through the compilation snapshot with the callback's cancellation token | Retains virtual-file lookup and MemoryFileSystem tests while sharing one configuration with CM0001 and the other cops. | +| Filter `SymbolKind.Action` by `ActionKind` and `SymbolKind.Control` by `ControlKind` instead of adding naming targets | Each of those two symbol kinds covers several AL constructs, only some of which carry a developer-chosen name; new targets (`ActionArea`, `SystemAction`, ...) would enlarge the settings schema for kinds nobody can rename. Ordinary groups stay under `Action` and every other control kind under `Control`. | | Independent of LC0098: a subscriber violating both rules receives two diagnostics | LC0092 constrains the character class of the first character, LC0098 the structural template; their settings are decoupled. Teams whose source objects start lowercase or non-letter (the LC0098 default emits a quoted identifier) should relax `NamingPatterns.EventSubscriber` to accept the leading quote. | ## Deliberate non-reports @@ -45,12 +46,24 @@ Registers `CompilationStartAction` (settings, AppSourceCop affixes, `NamingPatte - Event subscriber parameters: must match the publisher signature (AL0828), and platform trigger parameters (`xRec`, `BelowxRec`, `RunTrigger`, ...) cannot be renamed. - Controls on API pages/queries: AA0102 requires camelCase, which the default PascalCase pattern would always contradict. - Whitespace-only names such as `value(0; " ")`: a common "empty" enum value, not a naming issue. +- Action areas (`area(Processing)`, `area(Promoted)`, ...) and layout areas (`area(Content)`, `area(FactBoxes)`, ...): the name selects a platform area, so it is fixed by `ActionAreaKind` / the page layout rather than chosen. +- `systemaction(OK)` and friends: the name selects a `SystemActionKind` member. +- Action groups named after a predefined promoted category (`Category_New`, `Category_Process`, `Category_Report`, `Category_Category4` .. `Category_Category20`): the name binds the group to that platform category slot, the same skip AC0011 applies. +- Ordinary action groups, separators, action references, custom actions and file-upload actions stay checked under `Action`; every control kind other than the layout area stays checked under `Control`. - Enum values, unless a pattern is configured. - Obsolete symbols (standard ALCops convention). +## SDK facts + +- Every node inside an `actions { }` block is a single `SymbolKind.Action` symbol; `IActionSymbol.ActionKind` is the only thing distinguishing `area` from `group`, `action`, `separator`, `actionref`, `customaction`, `systemaction` and `fileuploadaction`. `SymbolKind.Control` is shaped the same way, with `IControlSymbol.ControlKind` telling the layout `area` apart from groups, fields and parts. +- Action and control names come from the source text (`syntax.Name.Unquoted()`), not from the canonical enum member, so `area(processing)` really is named `processing` and fails an uppercase-start pattern. +- `ActionKind.SystemAction` is absent from the oldest supported SDK (it arrives in 12.1), so `EnumProvider` resolves it through the string overload with an out-of-range sentinel: `default(ActionKind)` is `Area`, and falling back to it would make every action area read as a system action. +- The predefined promoted-category names come from `SyntaxFacts.PromotedCategoriesSynthesizedSymbolNames`, an `ImmutableHashSet` the SDK already builds over `PromotedCategoryKind` with `SemanticFacts.NameEqualityComparer`. It holds the same `Category_*` names as `SyntaxFacts.PredefinedActionCategoryNames` (which AC0011 uses) but is immutable and needs no local copy. + ## Test notes - Custom patterns are injected as `alcops.json` through a `MemoryFileSystem`; `NamingPatternSettings.cs` unit-tests the inheritance-chain resolution of `NamingPatternConfig` directly. +- The `systemaction` fixture is gated with `SkipTestIfVersionIsTooLow(..., "14.0", ...)`. `systemaction` itself arrives with `PageType = PromptDialog` in 12.1, but the fixture uses `ConfigurationDialog`, which the parser gates at 14.0; the gate follows the page type, not the action. The page also needs `Extensible = false` (AL0223) and a system-action name the page type supports (`Ok` or `Cancel`). ## Settings diff --git a/.claude/skills/fix-false-positive/references/regression-catalog.md b/.claude/skills/fix-false-positive/references/regression-catalog.md index 699ec3d3..a404f4fa 100644 --- a/.claude/skills/fix-false-positive/references/regression-catalog.md +++ b/.claude/skills/fix-false-positive/references/regression-catalog.md @@ -20,4 +20,5 @@ Recurring causes of false positives/negatives, mined from `fix(...)` commits. Wh | **Built-in method names are not identities** | `MethodKind.BuiltInMethod` plus a method name can match a future built-in on the wrong class. Anchor semantic classification to the exact containing built-in class and method pair; use receiver `NavTypeKind` only for invalid editor-time bindings. | PC0038 #468 | | **Flow-analysis operation wrappers and bypasses** | Parenthesized expressions must be unwrapped before applying short-circuit rules; `break` is a loop exit rather than body fallthrough; and enum exhaustiveness must use the compiler's complete enum-value helper because public enum value lists omit enum-extension values. Use `OperationKind` plus reflective operands for operation interfaces that differ by target framework. | PC0038 #471 | | **Record methods reach their receiver in four forms** | The receiver may be a named variable (`MyTable.M()`), the implicit `Rec` (`Rec.M()`), bare implicit self (`M()`), or `this` (`this.M()`). Instance-null gates skip bare self; name-keyed maps mis-key `this` (table name instead of "Rec") and bare (null). In tableextensions all self forms redirect to the target table. Resolve with `GetReceiverTableType`. | AC0032 #343, batch #348 | +| **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 | diff --git a/src/ALCops.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index 9240cf2c..6c8c719d 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -61,6 +61,10 @@ private static T ParseEnum(string value, T fallback = default) where T : stru /// public static class ActionKind { + // default(ActionKind) is Area, so a member missing from the loaded SDK must not fall back to it. + // An out-of-range value matches no real kind and keeps every comparison false. + private const NavCodeAnalysis.ActionKind Unresolved = (NavCodeAnalysis.ActionKind)int.MaxValue; + private static readonly Lazy _action = new(() => ParseEnum(nameof(NavCodeAnalysis.ActionKind.Action))); private static readonly Lazy _actionRef = @@ -69,11 +73,16 @@ public static class ActionKind new(() => ParseEnum(nameof(NavCodeAnalysis.ActionKind.Area))); private static readonly Lazy _group = new(() => ParseEnum(nameof(NavCodeAnalysis.ActionKind.Group))); + // String form: the member is absent from the oldest supported SDK, which also cannot compile a + // systemaction, so it resolves to the sentinel there. + private static readonly Lazy _systemAction = + new(() => ParseEnum("SystemAction", Unresolved)); public static NavCodeAnalysis.ActionKind Action => _action.Value; public static NavCodeAnalysis.ActionKind ActionRef => _actionRef.Value; public static NavCodeAnalysis.ActionKind Area => _area.Value; public static NavCodeAnalysis.ActionKind Group => _group.Value; + public static NavCodeAnalysis.ActionKind SystemAction => _systemAction.Value; } /// diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/HasDiagnostic/ActionGroupCustomPattern.al b/src/ALCops.LinterCop.Test/Rules/NamingPattern/HasDiagnostic/ActionGroupCustomPattern.al new file mode 100644 index 00000000..8f5bd05e --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/HasDiagnostic/ActionGroupCustomPattern.al @@ -0,0 +1,15 @@ +page 50100 MyPage +{ + actions + { + area(Processing) + { + group([|Line|]) + { + action([|Test|]) + { + } + } + } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs index 196a587a..d355fec3 100644 --- a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs @@ -8,8 +8,8 @@ public class NamingPattern : NavCodeAnalysisBase private AnalyzerTestFixture _fixture; private string _testCasePath; - private static readonly byte[] EnumValueNamingSettings = System.Text.Encoding.UTF8.GetBytes( - """{"NamingPatterns": {"EnumValue": {"AllowPattern": "^[A-Z]", "AllowDescription": "should start with an uppercase letter"}}}"""); + private static readonly byte[] CustomNamingSettings = System.Text.Encoding.UTF8.GetBytes( + """{"NamingPatterns": {"EnumValue": {"AllowPattern": "^[A-Z]", "AllowDescription": "should start with an uppercase letter"}, "Action": {"AllowPattern": "act[A-Za-z0-9]", "AllowDescription": "should begin with 'act'."}, "Control": {"AllowPattern": "ctl[A-Za-z0-9]", "AllowDescription": "should begin with 'ctl'."}}}"""); [SetUp] public void Setup() @@ -60,6 +60,8 @@ public async Task HasDiagnostic(string testCase) [TestCase("EnumValueBlankSpace")] [TestCase("EnumValueLowerCaseStart")] [TestCase("ParameterPascalCase")] + [TestCase("ActionAreaLowerCase")] + [TestCase("ControlAreaLowerCase")] public async Task NoDiagnostic(string testCase) { var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) @@ -70,6 +72,7 @@ public async Task NoDiagnostic(string testCase) [Test] [TestCase("EnumValueLowerCaseStartCustomSettings")] + [TestCase("ActionGroupCustomPattern")] public async Task HasDiagnosticWithCustomSettings(string testCase) { var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasDiagnostic), $"{testCase}.al")) @@ -77,7 +80,7 @@ public async Task HasDiagnosticWithCustomSettings(string testCase) var files = new Dictionary { - { "alcops.json", EnumValueNamingSettings } + { "alcops.json", CustomNamingSettings } }; var fileSystem = new MemoryFileSystem(files); @@ -89,5 +92,36 @@ public async Task HasDiagnosticWithCustomSettings(string testCase) fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.NamingPattern); } + + [Test] + [TestCase("ActionAreaCustomPattern")] + [TestCase("ControlAreaCustomPattern")] + [TestCase("PromotedCategoryGroupCustomPattern")] + [TestCase("SystemActionCustomPattern")] + public async Task NoDiagnosticWithCustomSettings(string testCase) + { + SkipTestIfVersionIsTooLow( + ["SystemActionCustomPattern"], + testCase, + "14.0", + "The fixture's 'ConfigurationDialog' page type requires runtime version 14.0."); + + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) + .ConfigureAwait(false); + + var files = new Dictionary + { + { "alcops.json", CustomNamingSettings } + }; + var fileSystem = new MemoryFileSystem(files); + + var fixture = RoslynFixtureFactory.Create( + new AnalyzerTestFixtureConfig + { + FileSystem = fileSystem + }); + + fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.NamingPattern); + } } } diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ActionAreaCustomPattern.al b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ActionAreaCustomPattern.al new file mode 100644 index 00000000..dae61e1e --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ActionAreaCustomPattern.al @@ -0,0 +1,12 @@ +page 50100 MyPage +{ + actions + { + area([|Processing|]) + { + action(actTest) + { + } + } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ActionAreaLowerCase.al b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ActionAreaLowerCase.al new file mode 100644 index 00000000..1598c532 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ActionAreaLowerCase.al @@ -0,0 +1,12 @@ +page 50100 MyPage +{ + actions + { + area([|processing|]) + { + action(MyAction) + { + } + } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ControlAreaCustomPattern.al b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ControlAreaCustomPattern.al new file mode 100644 index 00000000..fb6952c7 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ControlAreaCustomPattern.al @@ -0,0 +1,23 @@ +page 50100 MyPage +{ + SourceTable = MyTable; + + layout + { + area([|Content|]) + { + field(ctlName; Rec.MyField) + { + ApplicationArea = All; + } + } + } +} + +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ControlAreaLowerCase.al b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ControlAreaLowerCase.al new file mode 100644 index 00000000..290a68b3 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/ControlAreaLowerCase.al @@ -0,0 +1,23 @@ +page 50100 MyPage +{ + SourceTable = MyTable; + + layout + { + area([|content|]) + { + field(MyField; Rec.MyField) + { + ApplicationArea = All; + } + } + } +} + +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/PromotedCategoryGroupCustomPattern.al b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/PromotedCategoryGroupCustomPattern.al new file mode 100644 index 00000000..ba76d236 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/PromotedCategoryGroupCustomPattern.al @@ -0,0 +1,21 @@ +page 50100 MyPage +{ + actions + { + area([|Processing|]) + { + action(actTest) + { + } + } + area([|Promoted|]) + { + group([|Category_Process|]) + { + actionref(actRef; actTest) + { + } + } + } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/SystemActionCustomPattern.al b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/SystemActionCustomPattern.al new file mode 100644 index 00000000..1da5e0b6 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NoDiagnostic/SystemActionCustomPattern.al @@ -0,0 +1,15 @@ +page 50100 MyPage +{ + PageType = ConfigurationDialog; + Extensible = false; + + actions + { + area([|SystemActions|]) + { + systemaction([|OK|]) + { + } + } + } +} diff --git a/src/ALCops.LinterCop/Analyzers/NamingPattern.cs b/src/ALCops.LinterCop/Analyzers/NamingPattern.cs index 716f205b..8b72b2f3 100644 --- a/src/ALCops.LinterCop/Analyzers/NamingPattern.cs +++ b/src/ALCops.LinterCop/Analyzers/NamingPattern.cs @@ -156,15 +156,29 @@ private static void AnalyzeEnumValue(SymbolAnalysisContext ctx, NamingPatternCon private static void AnalyzeAction(SymbolAnalysisContext ctx, NamingPatternConfig config) { - if (ctx.IsObsolete()) + if (ctx.IsObsolete() || ctx.Symbol is not IActionSymbol action) + return; + + // Every node of an actions block is one Action symbol, so only the kinds whose name the + // developer picks are checked. Action areas take their name from ActionAreaKind and system + // actions from SystemActionKind; a group named after a predefined promoted category binds + // the group to that platform category slot. + if (action.ActionKind == EnumProvider.ActionKind.Area || + action.ActionKind == EnumProvider.ActionKind.SystemAction || + (action.ActionKind == EnumProvider.ActionKind.Group && + SyntaxFacts.PromotedCategoriesSynthesizedSymbolNames.Contains(action.Name))) return; - CheckName(ctx, ctx.Symbol.Name, NamingTarget.Action, config, "Action"); + CheckName(ctx, action.Name, NamingTarget.Action, config, "Action"); } private static void AnalyzeControl(SymbolAnalysisContext ctx, NamingPatternConfig config) { - if (ctx.IsObsolete()) + if (ctx.IsObsolete() || ctx.Symbol is not IControlSymbol control) + return; + + // Layout areas (Content, FactBoxes, RoleCenter, ...) take their name from the platform. + if (control.ControlKind == EnumProvider.ControlKind.Area) return; // Skip controls on API objects (pages with PageType=API, queries with QueryType=API). @@ -172,7 +186,7 @@ private static void AnalyzeControl(SymbolAnalysisContext ctx, NamingPatternConfi if (IsInApiObject(ctx.Symbol)) return; - CheckName(ctx, ctx.Symbol.Name, NamingTarget.Control, config, "Control"); + CheckName(ctx, control.Name, NamingTarget.Control, config, "Control"); } private static bool IsInApiObject(ISymbol symbol) From b4a46c89cdbf613f8aa955624ad56e867564f41c Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Fri, 11 Sep 2026 16:40:14 +0200 Subject: [PATCH 2/6] test(LC0092): gate the systemaction fixture on the SDK that compiles ConfigurationDialog SDKs 14.0 through 16.2.28 reject PageType = ConfigurationDialog as a feature under development (AL0574), so the fixture only compiles from 16.2.31, where the compiler downgrades that to a public-preview warning. Co-Authored-By: Claude Fable 5.1 --- .claude/rules/diagnostics/lc0092-naming-pattern.md | 2 +- .../Rules/NamingPattern/NamingPattern.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/rules/diagnostics/lc0092-naming-pattern.md b/.claude/rules/diagnostics/lc0092-naming-pattern.md index a05adb1a..02a9d10a 100644 --- a/.claude/rules/diagnostics/lc0092-naming-pattern.md +++ b/.claude/rules/diagnostics/lc0092-naming-pattern.md @@ -63,7 +63,7 @@ Registers `CompilationStartAction` (settings, AppSourceCop affixes, `NamingPatte ## Test notes - Custom patterns are injected as `alcops.json` through a `MemoryFileSystem`; `NamingPatternSettings.cs` unit-tests the inheritance-chain resolution of `NamingPatternConfig` directly. -- The `systemaction` fixture is gated with `SkipTestIfVersionIsTooLow(..., "14.0", ...)`. `systemaction` itself arrives with `PageType = PromptDialog` in 12.1, but the fixture uses `ConfigurationDialog`, which the parser gates at 14.0; the gate follows the page type, not the action. The page also needs `Extensible = false` (AL0223) and a system-action name the page type supports (`Ok` or `Cancel`). +- The `systemaction` fixture is gated with `SkipTestIfVersionIsTooLow(..., "16.2.31", ...)`. `systemaction` itself arrives with `PageType = PromptDialog` in 12.1, but the fixture uses `ConfigurationDialog`: the parser accepts it from 14.0, yet SDKs up to 16.2.28 still reject it as a feature under development (AL0574), and 16.2.31 downgrades that to a public-preview warning. The gate follows the first SDK that compiles the page type, not the parser or the action. The page also needs `Extensible = false` (AL0223) and a system-action name the page type supports (`Ok` or `Cancel`). ## Settings diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs index d355fec3..54980b42 100644 --- a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs @@ -103,8 +103,8 @@ public async Task NoDiagnosticWithCustomSettings(string testCase) SkipTestIfVersionIsTooLow( ["SystemActionCustomPattern"], testCase, - "14.0", - "The fixture's 'ConfigurationDialog' page type requires runtime version 14.0."); + "16.2.31", + "The fixture's 'ConfigurationDialog' page type is rejected as a feature under development (AL0574) before SDK 16.2.31."); var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) .ConfigureAwait(false); From 13039dbcdc0ea9fac9604938662595ba46b01787 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sat, 12 Sep 2026 10:50:32 +0200 Subject: [PATCH 3/6] fix(CM): resolve missing ActionKind and ControlKind members to a sentinel Both enums have a dispatchable zero member (Area), so a member absent from the loaded SDK fell back to it and made every comparison against an action or layout area true. Route every member of both nested classes through a Parse helper with an out-of-range Unresolved sentinel, the way SymbolKind already does. Co-Authored-By: Claude Fable 5.1 --- .claude/rules/analyzer-development.md | 2 +- .claude/rules/common-library.md | 4 +- src/ALCops.Common/Reflection/EnumProvider.cs | 40 +++++++++++++------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.claude/rules/analyzer-development.md b/.claude/rules/analyzer-development.md index 1b40cefc..0cb09280 100644 --- a/.claude/rules/analyzer-development.md +++ b/.claude/rules/analyzer-development.md @@ -19,7 +19,7 @@ The repo is version-controlled per AL release: `git -C ../nav-sdk-source tag` li - **Plain `DiagnosticAnalyzer`, `[DiagnosticAnalyzer]`, `sealed`.** Never derive from the `ALCopsDiagnosticAnalyzer` / `{Cop}Analyzer` harness (`analyzer-exception-harness.md`). - **`IsObsolete()` first** in every callback (available on all four analysis contexts). Reporting on obsolete code is noise. -- **`EnumProvider` for every SDK enum value** (`ALCops.Common.Reflection`). Direct `SymbolKind.X` / `PropertyKind.X` references break on other SDK versions. A member missing from the loaded SDK resolves to an inert fallback: `default(T)` for most enums, an out-of-range sentinel for `SymbolKind` (because `default(SymbolKind)` is `Module`, and the driver ignores kinds above the enum's maximum). Guard with `!= default` only for enums whose zero member is `None`; never for `SymbolKind`. +- **`EnumProvider` for every SDK enum value** (`ALCops.Common.Reflection`). Direct `SymbolKind.X` / `PropertyKind.X` references break on other SDK versions. A member missing from the loaded SDK resolves to an inert fallback: `default(T)` for most enums, an out-of-range sentinel for `SymbolKind`, `ActionKind` and `ControlKind`, whose zero member is a dispatchable value (`Module`, `Area`, `Area`) that an unresolved member would otherwise impersonate; the driver ignores kinds above the enum's maximum. Guard with `!= default` only for enums whose zero member is `None`; never for those three. - **Typed property access.** `GetEnumPropertyValue(EnumProvider.PropertyKind.X)`, `GetBooleanPropertyValue()`, `GetProperty()`. Never compare `ValueText` strings for property values. - **`GetSymbolSafe()`, never `GetSymbol()`**, on operations. `symbol-resolution.md` explains the SDK bug. - **Symbols, not text, identify things.** Resolve via the operation tree or `SemanticModel`, then compare symbols or `ISymbol.Name` (`symbol-resolution.md`). diff --git a/.claude/rules/common-library.md b/.claude/rules/common-library.md index 0f1abdf3..9ceaf202 100644 --- a/.claude/rules/common-library.md +++ b/.claude/rules/common-library.md @@ -28,7 +28,7 @@ Target frameworks, LangVersion, nullable enforcement and conditional package ref The `Microsoft.Dynamics.Nav.CodeAnalysis` SDK treats many types, properties, and enum values as internal or changes their signatures between Business Central releases. Direct references would break compilation against older (or newer) SDK versions. The reflection pattern used throughout Common: -1. **Enum values**: `EnumProvider` wraps every enum value in `Lazy` using `Enum.Parse`. A value missing from the loaded SDK resolves to a fallback, identical in Debug and Release: `default(T)` for most enums, but for `SymbolKind` an out-of-range sentinel (`int.MaxValue`), because `default(SymbolKind)` is `Module` (an unresolved kind passed to `RegisterSymbolAction` would fire for the module symbol) and `Undefined` (-1) crashes the SDK driver's per-kind bucketing; the driver skips kinds above the loaded enum's maximum. +1. **Enum values**: `EnumProvider` wraps every enum value in `Lazy` using `Enum.Parse`. A value missing from the loaded SDK resolves to a fallback, identical in Debug and Release: `default(T)` for most enums, but for `SymbolKind`, `ActionKind` and `ControlKind` an out-of-range sentinel (`int.MaxValue`), because their zero member is a real value an unresolved member would impersonate — `Module` for `SymbolKind` (an unresolved kind passed to `RegisterSymbolAction` would fire for the module symbol) and `Area` for the other two (an unresolved kind would read as an action or layout area). `Undefined` (-1) is no alternative: it crashes the SDK driver's per-kind bucketing, while the driver skips kinds above the loaded enum's maximum. 2. **Properties**: `PropertyAccessor`, `SymbolHelper` use `Lazy` with `GetProperty()` and cache results. 3. **Methods**: `StringHelper`, `ManifestHelper` use `Lazy` with `GetMethod()` and create typed delegates. `StringHelper` detects the SDK method signature at runtime (with/without bool parameter); `ManifestHelper` on netstandard2.1 tries two type paths for AL version compatibility. 4. **Static fields**: `VersionProvider` uses `GetField()` with a "never supported" fallback when a field does not exist in the loaded SDK version. @@ -118,7 +118,7 @@ Successful settings and deterministic configuration failures remain cached for t ### How to Add a New Enum Value to EnumProvider 1. Open `Reflection/EnumProvider.cs` and find the nested class for the enum type. -2. Add a new `private static readonly Lazy` field using `ParseEnum(nameof(...))` or a string literal for values that may not exist in all SDK versions. In the `SymbolKind` class use its `Parse(...)` helper so a missing member resolves to the out-of-range `Unresolved` sentinel, never `Module`. Before relying on `default(T)` for a new enum, check that its zero member is inert; if it is a real, dispatchable value, give that nested class its own fallback helper like `SymbolKind.Parse`. +2. Add a new `private static readonly Lazy` field using `ParseEnum(nameof(...))` or a string literal for values that may not exist in all SDK versions. In the `SymbolKind`, `ActionKind` and `ControlKind` classes use that class's `Parse(...)` helper so a missing member resolves to its out-of-range `Unresolved` sentinel, never the zero member. Before relying on `default(T)` for a new enum, check that its zero member is inert; if it is a real, dispatchable value, give that nested class its own fallback helper like `SymbolKind.Parse`. 3. Add a public static property that returns `_field.Value`. 4. If the enum value requires conditional compilation for different frameworks, use `#if` guards. diff --git a/src/ALCops.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index 6c8c719d..8b1d06d9 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -61,22 +61,26 @@ private static T ParseEnum(string value, T fallback = default) where T : stru /// public static class ActionKind { - // default(ActionKind) is Area, so a member missing from the loaded SDK must not fall back to it. - // An out-of-range value matches no real kind and keeps every comparison false. + // default(ActionKind) is Area, so no member of this class may fall back to it: an unresolved kind + // would then read as an action area and match every comparison against Area. Every member goes + // through Parse, whose out-of-range sentinel matches no real kind and keeps every comparison false. private const NavCodeAnalysis.ActionKind Unresolved = (NavCodeAnalysis.ActionKind)int.MaxValue; + private static NavCodeAnalysis.ActionKind Parse(string name) => + ParseEnum(name, Unresolved); + private static readonly Lazy _action = - new(() => ParseEnum(nameof(NavCodeAnalysis.ActionKind.Action))); + new(() => Parse(nameof(NavCodeAnalysis.ActionKind.Action))); private static readonly Lazy _actionRef = - new(() => ParseEnum(nameof(NavCodeAnalysis.ActionKind.ActionRef))); + new(() => Parse(nameof(NavCodeAnalysis.ActionKind.ActionRef))); private static readonly Lazy _area = - new(() => ParseEnum(nameof(NavCodeAnalysis.ActionKind.Area))); + new(() => Parse(nameof(NavCodeAnalysis.ActionKind.Area))); private static readonly Lazy _group = - new(() => ParseEnum(nameof(NavCodeAnalysis.ActionKind.Group))); + new(() => Parse(nameof(NavCodeAnalysis.ActionKind.Group))); // String form: the member is absent from the oldest supported SDK, which also cannot compile a // systemaction, so it resolves to the sentinel there. private static readonly Lazy _systemAction = - new(() => ParseEnum("SystemAction", Unresolved)); + new(() => Parse("SystemAction")); public static NavCodeAnalysis.ActionKind Action => _action.Value; public static NavCodeAnalysis.ActionKind ActionRef => _actionRef.Value; @@ -228,20 +232,28 @@ public static class CodeunitSubtypeKind /// public static class ControlKind { + // default(ControlKind) is Area, so no member of this class may fall back to it: an unresolved kind + // would then read as a layout area and match every comparison against Area. Every member goes + // through Parse, whose out-of-range sentinel matches no real kind and keeps every comparison false. + private const NavCodeAnalysis.ControlKind Unresolved = (NavCodeAnalysis.ControlKind)int.MaxValue; + + private static NavCodeAnalysis.ControlKind Parse(string name) => + ParseEnum(name, Unresolved); + private static readonly Lazy _area = - new(() => ParseEnum(nameof(NavCodeAnalysis.ControlKind.Area))); + new(() => Parse(nameof(NavCodeAnalysis.ControlKind.Area))); private static readonly Lazy _field = - new(() => ParseEnum(nameof(NavCodeAnalysis.ControlKind.Field))); + new(() => Parse(nameof(NavCodeAnalysis.ControlKind.Field))); private static readonly Lazy _grid = - new(() => ParseEnum(nameof(NavCodeAnalysis.ControlKind.Grid))); + new(() => Parse(nameof(NavCodeAnalysis.ControlKind.Grid))); private static readonly Lazy _part = - new(() => ParseEnum(nameof(NavCodeAnalysis.ControlKind.Part))); + new(() => Parse(nameof(NavCodeAnalysis.ControlKind.Part))); private static readonly Lazy _repeater = - new(() => ParseEnum(nameof(NavCodeAnalysis.ControlKind.Repeater))); + new(() => Parse(nameof(NavCodeAnalysis.ControlKind.Repeater))); private static readonly Lazy _systemPart = - new(() => ParseEnum(nameof(NavCodeAnalysis.ControlKind.SystemPart))); + new(() => Parse(nameof(NavCodeAnalysis.ControlKind.SystemPart))); private static readonly Lazy _userControl = - new(() => ParseEnum(nameof(NavCodeAnalysis.ControlKind.UserControl))); + new(() => Parse(nameof(NavCodeAnalysis.ControlKind.UserControl))); public static NavCodeAnalysis.ControlKind Area => _area.Value; public static NavCodeAnalysis.ControlKind Field => _field.Value; From 58cf5160c0a16d95834c9d7fc68eae436932da5e Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sat, 12 Sep 2026 10:50:40 +0200 Subject: [PATCH 4/6] refactor(CM): share the predefined promoted category group check AC0011 kept a hand-lowercased copy of SyntaxFacts.PredefinedActionCategoryNames and lowercased every name it tested, while LC0092 queried the SDK set directly. Both now call IActionSymbol.IsPredefinedPromotedCategoryGroup(), which wraps the SDK's own case-insensitive set, so the two cops cannot drift apart. Co-Authored-By: Claude Fable 5.1 --- .../diagnostics/ac0011-caption-required.md | 2 +- .../diagnostics/lc0092-naming-pattern.md | 4 ++-- .../Analyzers/CaptionRequired.cs | 5 +--- .../ActionSymbolInterfaceExtensions.cs | 23 +++++++++++++++++++ .../Analyzers/NamingPattern.cs | 3 +-- 5 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/ALCops.Common/Extensions/ActionSymbolInterfaceExtensions.cs diff --git a/.claude/rules/diagnostics/ac0011-caption-required.md b/.claude/rules/diagnostics/ac0011-caption-required.md index e142d193..13a8c9b7 100644 --- a/.claude/rules/diagnostics/ac0011-caption-required.md +++ b/.claude/rules/diagnostics/ac0011-caption-required.md @@ -27,7 +27,7 @@ Registers `RegisterSymbolAction` on `Page`, `Query`, `Table`, `Field`, `Action`, - Field controls in HeadlinePart pages, including those added by a pageextension targeting one, are skipped (`IsInHeadlinePartPage`): the runtime ignores `Caption` there and only honours `Expression`, `Visible`, `ApplicationArea`, `Drilldown` and `DrillDownPageID` ([docs](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-create-role-center-headline#in-development), [#293](https://github.com/ALCops/Analyzers/issues/293)). The page object, actions and groups of a HeadlinePart page remain checked. - Area, Grid, Repeater, UserControl and SystemPart controls have no user-facing caption requirement. - System tables and fields (Id >= 2000000000) are Microsoft-owned. -- Predefined action category groups (`Category_Process` and friends) get their captions from the platform. +- Predefined action category groups (`Category_Process` and friends) get their captions from the platform. The test is Common's `IActionSymbol.IsPredefinedPromotedCategoryGroup()`, shared with LC0092 so both cops skip the same groups. - Empty enum values conventionally have no caption. - Non-assignable permission sets are never shown in the assignment UI. diff --git a/.claude/rules/diagnostics/lc0092-naming-pattern.md b/.claude/rules/diagnostics/lc0092-naming-pattern.md index 02a9d10a..629abaa0 100644 --- a/.claude/rules/diagnostics/lc0092-naming-pattern.md +++ b/.claude/rules/diagnostics/lc0092-naming-pattern.md @@ -48,7 +48,7 @@ Registers `CompilationStartAction` (settings, AppSourceCop affixes, `NamingPatte - Whitespace-only names such as `value(0; " ")`: a common "empty" enum value, not a naming issue. - Action areas (`area(Processing)`, `area(Promoted)`, ...) and layout areas (`area(Content)`, `area(FactBoxes)`, ...): the name selects a platform area, so it is fixed by `ActionAreaKind` / the page layout rather than chosen. - `systemaction(OK)` and friends: the name selects a `SystemActionKind` member. -- Action groups named after a predefined promoted category (`Category_New`, `Category_Process`, `Category_Report`, `Category_Category4` .. `Category_Category20`): the name binds the group to that platform category slot, the same skip AC0011 applies. +- Action groups named after a predefined promoted category (`Category_New`, `Category_Process`, `Category_Report`, `Category_Category4` .. `Category_Category20`): the name binds the group to that platform category slot. AC0011 skips them through the same Common helper. - Ordinary action groups, separators, action references, custom actions and file-upload actions stay checked under `Action`; every control kind other than the layout area stays checked under `Control`. - Enum values, unless a pattern is configured. - Obsolete symbols (standard ALCops convention). @@ -58,7 +58,7 @@ Registers `CompilationStartAction` (settings, AppSourceCop affixes, `NamingPatte - Every node inside an `actions { }` block is a single `SymbolKind.Action` symbol; `IActionSymbol.ActionKind` is the only thing distinguishing `area` from `group`, `action`, `separator`, `actionref`, `customaction`, `systemaction` and `fileuploadaction`. `SymbolKind.Control` is shaped the same way, with `IControlSymbol.ControlKind` telling the layout `area` apart from groups, fields and parts. - Action and control names come from the source text (`syntax.Name.Unquoted()`), not from the canonical enum member, so `area(processing)` really is named `processing` and fails an uppercase-start pattern. - `ActionKind.SystemAction` is absent from the oldest supported SDK (it arrives in 12.1), so `EnumProvider` resolves it through the string overload with an out-of-range sentinel: `default(ActionKind)` is `Area`, and falling back to it would make every action area read as a system action. -- The predefined promoted-category names come from `SyntaxFacts.PromotedCategoriesSynthesizedSymbolNames`, an `ImmutableHashSet` the SDK already builds over `PromotedCategoryKind` with `SemanticFacts.NameEqualityComparer`. It holds the same `Category_*` names as `SyntaxFacts.PredefinedActionCategoryNames` (which AC0011 uses) but is immutable and needs no local copy. +- The predefined promoted-category test lives in Common as `IActionSymbol.IsPredefinedPromotedCategoryGroup()` and is shared with AC0011, so both cops skip exactly the same groups. It wraps `SyntaxFacts.PromotedCategoriesSynthesizedSymbolNames`, an `ImmutableHashSet` the SDK already builds over `PromotedCategoryKind` with a case-insensitive comparer, so no local copy or lowercasing is needed. ## Test notes diff --git a/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs b/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs index d6d085ef..019d54ac 100644 --- a/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs +++ b/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs @@ -14,9 +14,6 @@ public sealed class CaptionRequired : DiagnosticAnalyzer ImmutableArray.Create( DiagnosticDescriptors.CaptionRequired); - private static readonly HashSet _predefinedActionCategoryNames = - SyntaxFacts.PredefinedActionCategoryNames.Select(x => x.Key.ToLowerInvariant()).ToHashSet(); - public override void Initialize(AnalysisContext context) => context.RegisterSymbolAction( CheckForMissingCaptions, @@ -175,7 +172,7 @@ private static bool CaptionIsMissing(ISymbol Symbol, SymbolAnalysisContext conte return false; } - if (Symbol.Kind == EnumProvider.SymbolKind.Action && ((IActionSymbol)Symbol).ActionKind == EnumProvider.ActionKind.Group && _predefinedActionCategoryNames.Contains(Symbol.Name.ToLowerInvariant())) + if (Symbol is IActionSymbol action && action.IsPredefinedPromotedCategoryGroup()) return false; if (Symbol.GetBooleanPropertyValue(EnumProvider.PropertyKind.ShowCaption) != false) diff --git a/src/ALCops.Common/Extensions/ActionSymbolInterfaceExtensions.cs b/src/ALCops.Common/Extensions/ActionSymbolInterfaceExtensions.cs new file mode 100644 index 00000000..22052eca --- /dev/null +++ b/src/ALCops.Common/Extensions/ActionSymbolInterfaceExtensions.cs @@ -0,0 +1,23 @@ +using ALCops.Common.Reflection; +using Microsoft.Dynamics.Nav.CodeAnalysis; + +namespace ALCops.Common.Extensions; + +public static class ActionSymbolInterfaceExtensions +{ + /// + /// Tells whether the action is a group whose name binds it to one of the platform's predefined promoted + /// category slots (Category_New, Category_Process, Category_Report, + /// Category_Category4 .. Category_Category20). Such a group takes its name and its caption + /// from the platform, so it is neither renameable nor missing a caption of its own. + /// + /// + /// SyntaxFacts.PromotedCategoriesSynthesizedSymbolNames is the SDK's own immutable set, built over + /// every PromotedCategoryKind member with an ordinal case-insensitive comparer, which matches AL's + /// case-insensitive identifiers. It holds the same names as SyntaxFacts.PredefinedActionCategoryNames + /// and needs no local copy or lowercasing at the call site. + /// + public static bool IsPredefinedPromotedCategoryGroup(this IActionSymbol action) => + action.ActionKind == EnumProvider.ActionKind.Group && + SyntaxFacts.PromotedCategoriesSynthesizedSymbolNames.Contains(action.Name); +} diff --git a/src/ALCops.LinterCop/Analyzers/NamingPattern.cs b/src/ALCops.LinterCop/Analyzers/NamingPattern.cs index 8b72b2f3..e6fae692 100644 --- a/src/ALCops.LinterCop/Analyzers/NamingPattern.cs +++ b/src/ALCops.LinterCop/Analyzers/NamingPattern.cs @@ -165,8 +165,7 @@ private static void AnalyzeAction(SymbolAnalysisContext ctx, NamingPatternConfig // the group to that platform category slot. if (action.ActionKind == EnumProvider.ActionKind.Area || action.ActionKind == EnumProvider.ActionKind.SystemAction || - (action.ActionKind == EnumProvider.ActionKind.Group && - SyntaxFacts.PromotedCategoriesSynthesizedSymbolNames.Contains(action.Name))) + action.IsPredefinedPromotedCategoryGroup()) return; CheckName(ctx, action.Name, NamingTarget.Action, config, "Action"); From 325e1cb5c1fb494a60bb92faf3f62d82acf78203 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sat, 12 Sep 2026 10:51:18 +0200 Subject: [PATCH 5/6] docs: correct the LC0092 SDK version claims and drop a duplicate bullet The 12.1 and 14.0 claims about systemaction and the ConfigurationDialog page type had no evidence behind them; the availability tables only record absent at 12.0 and present by 16.0. Also removes a deliberate non-report bullet that restated the design-decision row and listed what the rule checks, not why. Co-Authored-By: Claude Fable 5.1 --- .claude/rules/diagnostics/lc0092-naming-pattern.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.claude/rules/diagnostics/lc0092-naming-pattern.md b/.claude/rules/diagnostics/lc0092-naming-pattern.md index 629abaa0..7e99eef4 100644 --- a/.claude/rules/diagnostics/lc0092-naming-pattern.md +++ b/.claude/rules/diagnostics/lc0092-naming-pattern.md @@ -49,7 +49,6 @@ Registers `CompilationStartAction` (settings, AppSourceCop affixes, `NamingPatte - Action areas (`area(Processing)`, `area(Promoted)`, ...) and layout areas (`area(Content)`, `area(FactBoxes)`, ...): the name selects a platform area, so it is fixed by `ActionAreaKind` / the page layout rather than chosen. - `systemaction(OK)` and friends: the name selects a `SystemActionKind` member. - Action groups named after a predefined promoted category (`Category_New`, `Category_Process`, `Category_Report`, `Category_Category4` .. `Category_Category20`): the name binds the group to that platform category slot. AC0011 skips them through the same Common helper. -- Ordinary action groups, separators, action references, custom actions and file-upload actions stay checked under `Action`; every control kind other than the layout area stays checked under `Control`. - Enum values, unless a pattern is configured. - Obsolete symbols (standard ALCops convention). @@ -57,13 +56,13 @@ Registers `CompilationStartAction` (settings, AppSourceCop affixes, `NamingPatte - Every node inside an `actions { }` block is a single `SymbolKind.Action` symbol; `IActionSymbol.ActionKind` is the only thing distinguishing `area` from `group`, `action`, `separator`, `actionref`, `customaction`, `systemaction` and `fileuploadaction`. `SymbolKind.Control` is shaped the same way, with `IControlSymbol.ControlKind` telling the layout `area` apart from groups, fields and parts. - Action and control names come from the source text (`syntax.Name.Unquoted()`), not from the canonical enum member, so `area(processing)` really is named `processing` and fails an uppercase-start pattern. -- `ActionKind.SystemAction` is absent from the oldest supported SDK (it arrives in 12.1), so `EnumProvider` resolves it through the string overload with an out-of-range sentinel: `default(ActionKind)` is `Area`, and falling back to it would make every action area read as a system action. +- `ActionKind.SystemAction` is absent at the 12.0 SDK floor and present by 16.0, so `EnumProvider` resolves it through the string overload with an out-of-range sentinel: `default(ActionKind)` is `Area`, and falling back to it would make every action area read as a system action. - The predefined promoted-category test lives in Common as `IActionSymbol.IsPredefinedPromotedCategoryGroup()` and is shared with AC0011, so both cops skip exactly the same groups. It wraps `SyntaxFacts.PromotedCategoriesSynthesizedSymbolNames`, an `ImmutableHashSet` the SDK already builds over `PromotedCategoryKind` with a case-insensitive comparer, so no local copy or lowercasing is needed. ## Test notes - Custom patterns are injected as `alcops.json` through a `MemoryFileSystem`; `NamingPatternSettings.cs` unit-tests the inheritance-chain resolution of `NamingPatternConfig` directly. -- The `systemaction` fixture is gated with `SkipTestIfVersionIsTooLow(..., "16.2.31", ...)`. `systemaction` itself arrives with `PageType = PromptDialog` in 12.1, but the fixture uses `ConfigurationDialog`: the parser accepts it from 14.0, yet SDKs up to 16.2.28 still reject it as a feature under development (AL0574), and 16.2.31 downgrades that to a public-preview warning. The gate follows the first SDK that compiles the page type, not the parser or the action. The page also needs `Extensible = false` (AL0223) and a system-action name the page type supports (`Ok` or `Cancel`). +- The `systemaction` fixture is gated with `SkipTestIfVersionIsTooLow(..., "16.2.31", ...)`. `systemaction` itself is absent at the 12.0 SDK floor and present by 16.0, but the gate is set by the fixture's `ConfigurationDialog` page type: SDKs up to 16.2.28 reject it as a feature under development (AL0574), and 16.2.31 downgrades that to a public-preview warning. The gate therefore follows the first SDK that compiles the page type, not the parser or the action. The page also needs `Extensible = false` (AL0223) and a system-action name the page type supports (`Ok` or `Cancel`). ## Settings From 43fee2ef33fbd57f2fed0651d9d04125ff891cc8 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sat, 12 Sep 2026 10:51:52 +0200 Subject: [PATCH 6/6] test(LC0092): factor the settings fixture into one helper Both custom-settings test methods built the same MemoryFileSystem fixture inline; CreateFixtureWithSettings mirrors the helper the sibling EventSubscriberNamingPattern tests already use. Co-Authored-By: Claude Fable 5.1 --- .../Rules/NamingPattern/NamingPattern.cs | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs index 54980b42..44ed0eb1 100644 --- a/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs +++ b/src/ALCops.LinterCop.Test/Rules/NamingPattern/NamingPattern.cs @@ -78,17 +78,7 @@ public async Task HasDiagnosticWithCustomSettings(string testCase) var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasDiagnostic), $"{testCase}.al")) .ConfigureAwait(false); - var files = new Dictionary - { - { "alcops.json", CustomNamingSettings } - }; - var fileSystem = new MemoryFileSystem(files); - - var fixture = RoslynFixtureFactory.Create( - new AnalyzerTestFixtureConfig - { - FileSystem = fileSystem - }); + var fixture = CreateFixtureWithSettings(CustomNamingSettings); fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.NamingPattern); } @@ -109,19 +99,24 @@ public async Task NoDiagnosticWithCustomSettings(string testCase) var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) .ConfigureAwait(false); + var fixture = CreateFixtureWithSettings(CustomNamingSettings); + + fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.NamingPattern); + } + + private static AnalyzerTestFixture CreateFixtureWithSettings(byte[] settings) + { var files = new Dictionary { - { "alcops.json", CustomNamingSettings } + { "alcops.json", settings } }; var fileSystem = new MemoryFileSystem(files); - var fixture = RoslynFixtureFactory.Create( + return RoslynFixtureFactory.Create( new AnalyzerTestFixtureConfig { FileSystem = fileSystem }); - - fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.NamingPattern); } } }