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/.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 9a19f34e..7e99eef4 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,23 @@ 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. AC0011 skips them through the same Common helper. - 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 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 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 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.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.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index 9240cf2c..8b1d06d9 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -61,19 +61,32 @@ private static T ParseEnum(string value, T fallback = default) where T : stru /// public static class ActionKind { + // 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(() => Parse("SystemAction")); 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; } /// @@ -219,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; 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..44ed0eb1 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,24 +72,51 @@ 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")) .ConfigureAwait(false); + var fixture = CreateFixtureWithSettings(CustomNamingSettings); + + fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.NamingPattern); + } + + [Test] + [TestCase("ActionAreaCustomPattern")] + [TestCase("ControlAreaCustomPattern")] + [TestCase("PromotedCategoryGroupCustomPattern")] + [TestCase("SystemActionCustomPattern")] + public async Task NoDiagnosticWithCustomSettings(string testCase) + { + SkipTestIfVersionIsTooLow( + ["SystemActionCustomPattern"], + testCase, + "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); + + var fixture = CreateFixtureWithSettings(CustomNamingSettings); + + fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.NamingPattern); + } + + private static AnalyzerTestFixture CreateFixtureWithSettings(byte[] settings) + { var files = new Dictionary { - { "alcops.json", EnumValueNamingSettings } + { "alcops.json", settings } }; var fileSystem = new MemoryFileSystem(files); - var fixture = RoslynFixtureFactory.Create( + return RoslynFixtureFactory.Create( new AnalyzerTestFixtureConfig { FileSystem = fileSystem }); - - fixture.HasDiagnosticAtAllMarkers(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..e6fae692 100644 --- a/src/ALCops.LinterCop/Analyzers/NamingPattern.cs +++ b/src/ALCops.LinterCop/Analyzers/NamingPattern.cs @@ -156,15 +156,28 @@ 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.IsPredefinedPromotedCategoryGroup()) 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 +185,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)