Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/instructions/ac0011-caption-required.instructions.md
Original file line number Diff line number Diff line change
@@ -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). HeadlinePartPageExtension requires SDK 13.0+ (older compilers reject an extension whose target is declared in the same module, AL0334).
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -26,6 +27,7 @@ public void Setup()

[Test]
[TestCase("EnumObject")]
[TestCase("HeadlinePartPage")]
[TestCase("PageObject")]
[TestCase("PageAnalysisView")]
[TestCase("TableObject")]
Expand All @@ -48,6 +50,8 @@ public async Task HasDiagnostic(string testCase)
[Test]
[TestCase("ApiPage")]
[TestCase("EnumObject")]
[TestCase("HeadlinePartPage")]
[TestCase("HeadlinePartPageExtension")]
[TestCase("PageObject")]
[TestCase("PageAnalysisView")]
[TestCase("TableObject")]
Expand All @@ -60,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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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)
{
}
}
}
18 changes: 18 additions & 0 deletions src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/ALCops.Common/Reflection/EnumProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,8 @@ public static class PageTypeKind
new(() => ParseEnum<NavCodeAnalysis.PageTypeKind>(nameof(NavCodeAnalysis.PageTypeKind.Card)));
private static readonly Lazy<NavCodeAnalysis.PageTypeKind> _document =
new(() => ParseEnum<NavCodeAnalysis.PageTypeKind>(nameof(NavCodeAnalysis.PageTypeKind.Document)));
private static readonly Lazy<NavCodeAnalysis.PageTypeKind> _headlinePart =
new(() => ParseEnum<NavCodeAnalysis.PageTypeKind>(nameof(NavCodeAnalysis.PageTypeKind.HeadlinePart)));
private static readonly Lazy<NavCodeAnalysis.PageTypeKind> _list =
new(() => ParseEnum<NavCodeAnalysis.PageTypeKind>(nameof(NavCodeAnalysis.PageTypeKind.List)));
private static readonly Lazy<NavCodeAnalysis.PageTypeKind> _listPart =
Expand All @@ -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;
Expand Down
Loading