From 20a6ca6a07a5555988343f75ce098749005a66b8 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 19 Aug 2026 14:55:57 +0200 Subject: [PATCH 1/2] fix(AC0011): skip Caption check for field controls on HeadlinePart pages The runtime ignores the Caption property on HeadlinePart page field controls; only Expression, Visible, ApplicationArea, Drilldown and DrillDownPageID apply there. Suppress AC0011 for field controls in HeadlinePart pages and in pageextensions targeting HeadlinePart pages. The page object, actions and groups remain checked. Fixes #293 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ac0011-caption-required.instructions.md | 45 +++++++++++++++++++ .../instruction-maintenance.instructions.md | 1 + .../Rules/CaptionRequired/CaptionRequired.cs | 3 ++ .../HasDiagnostic/HeadlinePartPage.al | 28 ++++++++++++ .../NoDiagnostic/HeadlinePartPage.al | 28 ++++++++++++ .../NoDiagnostic/HeadlinePartPageExtension.al | 29 ++++++++++++ .../Analyzers/CaptionRequired.cs | 18 ++++++++ src/ALCops.Common/Reflection/EnumProvider.cs | 3 ++ 8 files changed, 155 insertions(+) create mode 100644 .github/instructions/ac0011-caption-required.instructions.md create mode 100644 src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/HasDiagnostic/HeadlinePartPage.al create mode 100644 src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPage.al create mode 100644 src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPageExtension.al diff --git a/.github/instructions/ac0011-caption-required.instructions.md b/.github/instructions/ac0011-caption-required.instructions.md new file mode 100644 index 00000000..0974bfb2 --- /dev/null +++ b/.github/instructions/ac0011-caption-required.instructions.md @@ -0,0 +1,45 @@ +--- +applyTo: 'src/ALCops.ApplicationCop/**/CaptionRequired*' +--- + +# AC0011: CaptionRequired + +## Purpose + +Checks that user-facing symbols define a `Caption` (or `CaptionClass`/`CaptionML`) property: pages, tables, table fields, page controls, actions, enum values, permission sets, and analysis views. + +## Diagnostic properties + +**AC0011** · Category: Design · Severity: Warning · Enabled: true +Message: `Caption is missing.` +No version gate · Full netstandard2.1 support + +## Design decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Caption satisfied by | `Caption`, `CaptionClass`, or `CaptionML` | Any of the three provides a user-facing caption. | +| `ShowCaption = false` | Suppresses the check | Explicitly hidden captions need no value. | +| API pages | Entire page skipped (`IsInApiPage`) | API pages are not user-facing. No pageextension handling needed: API pages cannot be extended. | +| HeadlinePart field controls | Skipped (`IsInHeadlinePartPage`), including via pageextension targets | The runtime ignores `Caption` on HeadlinePart field controls; only `Expression`, `Visible`, `ApplicationArea`, `Drilldown`, and `DrillDownPageID` apply ([docs](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-create-role-center-headline#in-development)). Page object, actions, and groups in HeadlinePart pages remain checked. See issue #293. | +| Field controls | Fall back to `RelatedFieldSymbol` caption | A page field without a caption inherits the source table field's caption. | +| Part controls | Fall back to `RelatedPartSymbol` caption | Same inheritance principle for page parts. | +| Area/Grid/Repeater/UserControl/SystemPart controls | Skipped | No user-facing caption requirement. | +| System tables/fields (Id >= 2000000000) | Skipped | System objects are Microsoft-owned. | +| Predefined action category groups | Skipped | Names like `Category_Process` get captions from the platform. | +| Promoted SplitButton groups | Checked only when containing repeater-scoped actionrefs | Only case where the runtime displays the group caption. | +| Empty enum values | Skipped | Blank enum values conventionally have no caption. | +| Non-assignable permission sets | Skipped | Not shown in the UI for assignment. | + +## Architecture + +Single `RegisterSymbolAction` over Page, Query, Table, Field, Action, EnumValue, Control, PermissionSet, and AnalysisView symbol kinds. Per-symbol dispatch on symbol kind, then control kind/action kind. + +`IsInHeadlinePartPage` resolves the containing object via `GetContainingObjectTypeSymbol()`; for pageextensions it resolves `IApplicationObjectExtensionTypeSymbol.Target?.OriginalDefinition as IPageBaseTypeSymbol` (same pattern as `PermissionResolver`), then compares `PageType` to `EnumProvider.PageTypeKind.HeadlinePart`. + +## Test coverage + +**HasDiagnostic (5 cases):** EnumObject, HeadlinePartPage, PageObject, PageAnalysisView, TableObject. +**NoDiagnostic (7 cases):** ApiPage, EnumObject, HeadlinePartPage, HeadlinePartPageExtension, PageObject, PageAnalysisView, TableObject. + +PageAnalysisView cases require the net10.0 SDK (skipped below version 18.0.36). diff --git a/.github/instructions/instruction-maintenance.instructions.md b/.github/instructions/instruction-maintenance.instructions.md index dbfcb543..186bd0dc 100644 --- a/.github/instructions/instruction-maintenance.instructions.md +++ b/.github/instructions/instruction-maintenance.instructions.md @@ -86,6 +86,7 @@ Include: project structure, templates, step-by-step guides, API reference, commo | `cicd` | `'.github/**'` | CI/CD workflows | | `release-strategy` | `'.github/**'` | Release channels, versioning, cleanup | | `get-bc-devtools` | `'.github/actions/get-bc-devtools/**'` | BC DevTools discovery and caching | +| `ac0011-caption-required` | rule-scoped | AC0011 rule | | `ac0013-field-groups-required` | rule-scoped | AC0013 rule | | `ac0014-tooltip-must-end-with-punctuation` | rule-scoped | AC0014 rule | | `ac0026-allow-in-customizations-for-omitted-fields` | rule-scoped | AC0026 rule | diff --git a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs index 6eaf41a6..4588aef9 100644 --- a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs +++ b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs @@ -26,6 +26,7 @@ public void Setup() [Test] [TestCase("EnumObject")] + [TestCase("HeadlinePartPage")] [TestCase("PageObject")] [TestCase("PageAnalysisView")] [TestCase("TableObject")] @@ -48,6 +49,8 @@ public async Task HasDiagnostic(string testCase) [Test] [TestCase("ApiPage")] [TestCase("EnumObject")] + [TestCase("HeadlinePartPage")] + [TestCase("HeadlinePartPageExtension")] [TestCase("PageObject")] [TestCase("PageAnalysisView")] [TestCase("TableObject")] diff --git a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/HasDiagnostic/HeadlinePartPage.al b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/HasDiagnostic/HeadlinePartPage.al new file mode 100644 index 00000000..697fb47c --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/HasDiagnostic/HeadlinePartPage.al @@ -0,0 +1,28 @@ +page 50100 [|HeadlinePartPage|] +{ + PageType = HeadlinePart; + + layout + { + area(Content) + { + field(MyHeadlineField; MyHeadlineText) + { + ApplicationArea = All; + } + } + } + + actions + { + area(Processing) + { + action([|MyAction|]) + { + } + } + } + + var + MyHeadlineText: Text; +} diff --git a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPage.al b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPage.al new file mode 100644 index 00000000..84f8c6ca --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPage.al @@ -0,0 +1,28 @@ +page 50100 HeadlinePartPage +{ + PageType = HeadlinePart; + Caption = 'Headline'; + + layout + { + area(Content) + { + field([|MyHeadlineField|]; MyHeadlineText) + { + ApplicationArea = All; + Visible = true; + } + group(MyGroup) + { + ShowCaption = false; + field([|MyGroupedHeadlineField|]; MyHeadlineText) + { + ApplicationArea = All; + } + } + } + } + + var + MyHeadlineText: Text; +} diff --git a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPageExtension.al b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPageExtension.al new file mode 100644 index 00000000..fc1bec4c --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPageExtension.al @@ -0,0 +1,29 @@ +pageextension 50100 MyHeadlineExtension extends HeadlinePartPage +{ + layout + { + addlast(Content) + { + field([|MyExtensionHeadlineField|]; MyExtensionHeadlineText) + { + ApplicationArea = All; + } + } + } + + var + MyExtensionHeadlineText: Text; +} + +page 50100 HeadlinePartPage +{ + PageType = HeadlinePart; + Caption = 'Headline'; + + layout + { + area(Content) + { + } + } +} diff --git a/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs b/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs index 6339820b..0f415211 100644 --- a/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs +++ b/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs @@ -45,6 +45,12 @@ private void CheckForMissingCaptions(SymbolAnalysisContext context) switch (Control.ControlKind) { case var _ when Control.ControlKind == EnumProvider.ControlKind.Field: + // The Caption property is ignored by the runtime for field controls on HeadlinePart pages; + // only Expression, Visible, ApplicationArea, Drilldown and DrillDownPageID apply there. + // https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-create-role-center-headline#in-development + if (IsInHeadlinePartPage(context)) + break; + if (CaptionIsMissing(context.Symbol, context)) if (Control.RelatedFieldSymbol is not null) { @@ -190,6 +196,18 @@ private static bool IsInApiPage(SymbolAnalysisContext context) return ((IPageTypeSymbol)containingObject).PageType == EnumProvider.PageTypeKind.API; } + private static bool IsInHeadlinePartPage(SymbolAnalysisContext context) + { + IPageBaseTypeSymbol? pageBase = context.Symbol.GetContainingObjectTypeSymbol() switch + { + IPageTypeSymbol page => page, + IApplicationObjectExtensionTypeSymbol ext => ext.Target?.OriginalDefinition as IPageBaseTypeSymbol, + _ => null + }; + + return pageBase?.PageType == EnumProvider.PageTypeKind.HeadlinePart; + } + private void RaiseDiagnostic(SymbolAnalysisContext context) { context.ReportDiagnostic(Diagnostic.Create( diff --git a/src/ALCops.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index a23f0a01..5f73ad96 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -555,6 +555,8 @@ public static class PageTypeKind new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.Card))); private static readonly Lazy _document = new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.Document))); + private static readonly Lazy _headlinePart = + new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.HeadlinePart))); private static readonly Lazy _list = new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.List))); private static readonly Lazy _listPart = @@ -567,6 +569,7 @@ public static class PageTypeKind public static NavCodeAnalysis.PageTypeKind API => _api.Value; public static NavCodeAnalysis.PageTypeKind Card => _card.Value; public static NavCodeAnalysis.PageTypeKind Document => _document.Value; + public static NavCodeAnalysis.PageTypeKind HeadlinePart => _headlinePart.Value; public static NavCodeAnalysis.PageTypeKind List => _list.Value; public static NavCodeAnalysis.PageTypeKind ListPart => _listPart.Value; public static NavCodeAnalysis.PageTypeKind ListPlus => _listPlus.Value; From bd3b2601a2e750d218b0815acf6bbbf9393e987a Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 19 Aug 2026 15:06:21 +0200 Subject: [PATCH 2/2] test(AC0011): skip HeadlinePartPageExtension on SDK below 13.0 SDK versions before 13.0 reject a page extension whose target is declared in the same module (AL0334). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../instructions/ac0011-caption-required.instructions.md | 2 +- .../Rules/CaptionRequired/CaptionRequired.cs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/instructions/ac0011-caption-required.instructions.md b/.github/instructions/ac0011-caption-required.instructions.md index 0974bfb2..e822cb3e 100644 --- a/.github/instructions/ac0011-caption-required.instructions.md +++ b/.github/instructions/ac0011-caption-required.instructions.md @@ -42,4 +42,4 @@ Single `RegisterSymbolAction` over Page, Query, Table, Field, Action, EnumValue, **HasDiagnostic (5 cases):** EnumObject, HeadlinePartPage, PageObject, PageAnalysisView, TableObject. **NoDiagnostic (7 cases):** ApiPage, EnumObject, HeadlinePartPage, HeadlinePartPageExtension, PageObject, PageAnalysisView, TableObject. -PageAnalysisView cases require the net10.0 SDK (skipped below version 18.0.36). +PageAnalysisView cases require the net10.0 SDK (skipped below version 18.0.36). HeadlinePartPageExtension requires SDK 13.0+ (older compilers reject an extension whose target is declared in the same module, AL0334). diff --git a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs index 4588aef9..25c3f90b 100644 --- a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs +++ b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs @@ -10,6 +10,7 @@ public class CaptionRequired : NavCodeAnalysisBase private string _testCasePath; private static readonly string[] AnalysisViewTestCases = ["PageAnalysisView"]; + private static readonly string[] SameModuleExtensionTestCases = ["HeadlinePartPageExtension"]; [SetUp] public void Setup() @@ -63,6 +64,13 @@ public async Task NoDiagnostic(string testCase) "PageAnalysisView requires net10.0 SDK." ); + SkipTestIfVersionIsTooLow( + SameModuleExtensionTestCases, + testCase, + "13.0", + "No support for page extensions when the target itself is already declared in the same module." + ); + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) .ConfigureAwait(false);