Add AL-focused AppSource validation guidance (#142)

* knowledge(appsource): add AL validation guidance

* Address Marketplace review feedback

* Address remaining Marketplace review feedback

* Align AppSource review applicability outcome
This commit is contained in:
Stefano Demiliani 2026-09-14 12:42:45 +02:00 • committed by GitHub
parent 35d0966a8d
commit 45ac371e7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 322 additions and 4 deletions

View file

@ -0,0 +1,12 @@
codeunit 50100 "Rental Profile Install"
{
Subtype = Install;
trigger OnInstallAppPerDatabase()
var
RentalProfile: Record Profile;
begin
RentalProfile.Init();
RentalProfile.Insert(true);
end;
}

View file

@ -0,0 +1,6 @@
profile "RENTAL MANAGER"
{
Caption = 'Rental Manager';
Description = 'Manages rental agreements and equipment availability.';
RoleCenter = "Business Manager Role Center";
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [profile-object, profile-table, install-codeunit, role-center, page-customization]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Define profiles as AL objects
## Description
Profiles delivered by a Marketplace extension must be declared as AL `profile` objects. A profile object is validated with its Role Center and page customizations when the extension is compiled and is registered through extension synchronization. Inserting profile-table records from install or setup code bypasses that object lifecycle.
## Best Practice
Declare each app-owned profile with the `profile` object and set its `RoleCenter`, user-facing caption, and optional customizations in AL. Let installation and synchronization register the object.
See sample: [`define-profiles-as-al-objects.good.al`](define-profiles-as-al-objects.good.al).
## Anti Pattern
Install, upgrade, or setup code that creates an app-owned profile by inserting a `Profile` table record. Detection signal: a `Record Profile` variable followed by `Insert` in profile provisioning code.
See sample: [`define-profiles-as-al-objects.bad.al`](define-profiles-as-al-objects.bad.al).

View file

@ -0,0 +1,7 @@
codeunit 50100 "Rental Audit"
{
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
begin
RentalAgreement."Created At" := CurrentDateTime() + 7200000;
end;
}

View file

@ -0,0 +1,7 @@
codeunit 50100 "Rental Audit"
{
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
begin
RentalAgreement."Created At" := CurrentDateTime();
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [datetime, time-zone, utc, currentdatetime, locale, regional-settings]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not hard-code time-zone offsets
## Description
Marketplace extensions run for users and services in many time zones. Adding a fixed offset to a `DateTime` assumes one locale, ignores daylight-saving transitions, and changes an absolute timestamp into an incorrect value for other regions.
## Best Practice
Store and compare `DateTime` values without a manually applied regional offset. Business Central stores `DateTime` values in UTC and presents them according to the client time zone. Keep service contracts time-zone explicit and perform a conversion only when the business requirement identifies a particular zone.
See sample: [`do-not-hard-code-time-zone-offsets.good.al`](do-not-hard-code-time-zone-offsets.good.al).
## Anti Pattern
Adding or subtracting a fixed duration solely to convert `CurrentDateTime` or another timestamp to an assumed local time. Detection signals include fixed hour-sized millisecond values near `DateTime` assignments and comments naming a specific time zone; confirm the duration is an offset rather than a legitimate deadline or schedule interval.
See sample: [`do-not-hard-code-time-zone-offsets.bad.al`](do-not-hard-code-time-zone-offsets.bad.al).

View file

@ -0,0 +1,21 @@
codeunit 50100 "Rental Service"
{
[ServiceEnabled]
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
var
RentalAgreement: Record "Rental Agreement";
begin
if not Confirm(CloseAgreementQst, false, AgreementNo) then
exit(false);
RentalAgreement.Get(AgreementNo);
RentalAgreement.Closed := true;
RentalAgreement.Modify(true);
Message(AgreementClosedMsg, AgreementNo);
exit(true);
end;
var
CloseAgreementQst: Label 'Close rental agreement %1?';
AgreementClosedMsg: Label 'Rental agreement %1 was closed.';
}

View file

@ -0,0 +1,15 @@
codeunit 50100 "Rental Service"
{
[ServiceEnabled]
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
var
RentalAgreement: Record "Rental Agreement";
begin
if not RentalAgreement.Get(AgreementNo) then
exit(false);
RentalAgreement.Closed := true;
RentalAgreement.Modify(true);
exit(true);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [web-service, serviceenabled, guiallowed, message, confirm, strmenu]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep web-service paths free of UI calls
## Description
Pages and codeunits exposed as web services run without an interactive client. Calls that require a UI callback, including `Confirm`, `StrMenu`, and modal pages, can terminate the service request instead of completing the operation. `Message` does not raise the callback error: the message is suppressed and logged, making it ineffective for communicating a service result.
## Best Practice
Keep service entry points and every procedure they call free of interactive UI. Return data through the service contract and report validation failures with service-safe error handling. When a procedure is shared with an interactive client, guard UI-only behavior with `GuiAllowed` while preserving the underlying operation.
See sample: [`keep-web-service-paths-free-of-ui-calls.good.al`](keep-web-service-paths-free-of-ui-calls.good.al).
## Anti Pattern
A web-service-exposed page or codeunit calls an interactive UI method directly or indirectly. Detection signals include `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, and confirmation-dialog pages on a service call path. Treat `Message` as suppressed and ineffective, not as a callback failure. Do not flag a controlled `Error` solely because it returns a service fault.
See sample: [`keep-web-service-paths-free-of-ui-calls.bad.al`](keep-web-service-paths-free-of-ui-calls.bad.al).

View file

@ -0,0 +1,15 @@
pageextension 50100 "Rental Customer List" extends "Customer List"
{
actions
{
addafter("Customer Ledger Entries")
{
action(OpenRentalAgreements)
{
ApplicationArea = All;
Caption = 'Rental Agreements';
RunObject = page "Rental Agreement List";
}
}
}
}

View file

@ -0,0 +1,15 @@
pageextension 50100 "Rental Customer List" extends "Customer List"
{
actions
{
addlast(Processing)
{
action(OpenRentalAgreements)
{
ApplicationArea = All;
Caption = 'Rental Agreements';
RunObject = page "Rental Agreement List";
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [pageextension, actions, addfirst, addlast, addbefore, addafter]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Place page extension actions with addfirst or addlast
## Description
Place new page-extension actions at the beginning or end of an existing action group with `addfirst` or `addlast`. Anchoring a new action relative to a specific base-app action with `addbefore` or `addafter` couples the extension to an implementation detail that can move or disappear between Business Central releases.
## Best Practice
Choose the semantic action area or group and append or prepend the extension's actions. This keeps placement deterministic without depending on the continued existence of one neighboring action.
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.good.al`](place-page-extension-actions-with-addfirst-or-addlast.good.al).
## Anti Pattern
Using `addbefore` or `addafter` to place newly added actions next to a specific action from another app. The syntax is valid AL, but the placement anchor is brittle for a Marketplace extension.
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.bad.al`](place-page-extension-actions-with-addfirst-or-addlast.bad.al).

View file

@ -0,0 +1,20 @@
page 50100 "Rental Agreement List"
{
PageType = List;
SourceTable = "Rental Agreement";
ApplicationArea = All;
layout
{
area(Content)
{
repeater(Agreements)
{
field("No."; Rec."No.")
{
ApplicationArea = All;
}
}
}
}
}

View file

@ -0,0 +1,21 @@
page 50100 "Rental Agreement List"
{
PageType = List;
SourceTable = "Rental Agreement";
ApplicationArea = All;
UsageCategory = Lists;
layout
{
area(Content)
{
repeater(Agreements)
{
field("No."; Rec."No.")
{
ApplicationArea = All;
}
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [usagecategory, tell-me, search, page, report, discoverability]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Set UsageCategory on searchable entry points
## Description
Pages and reports that users are expected to open directly must set `UsageCategory`. Without it, the object is absent from Tell Me and users cannot bookmark it from the web client. Supporting objects such as list parts, dialogs, API pages, and objects reached only through another page do not need to be searchable entry points.
## Best Practice
Set `UsageCategory` to the category that matches the entry point, such as `Lists`, `Tasks`, `ReportsAndAnalysis`, or `Documents`. Also set the appropriate object-level `ApplicationArea` so search results respect feature visibility.
See sample: [`set-usagecategory-on-searchable-entry-points.good.al`](set-usagecategory-on-searchable-entry-points.good.al).
## Anti Pattern
A user-facing page or report intended for direct discovery omits `UsageCategory` or sets it to `None`. Do not infer intent from the object type alone; require evidence that the object is a direct user entry point.
See sample: [`set-usagecategory-on-searchable-entry-points.bad.al`](set-usagecategory-on-searchable-entry-points.bad.al).

View file

@ -0,0 +1,10 @@
codeunit 50100 "Rental Period Defaults"
{
procedure GetPolicyStartDate(): Date
var
PolicyStartDate: Date;
begin
Evaluate(PolicyStartDate, '01/31/2025');
exit(PolicyStartDate);
end;
}

View file

@ -0,0 +1,7 @@
codeunit 50100 "Rental Period Defaults"
{
procedure GetPolicyStartDate(): Date
begin
exit(20250131D);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [date-literal, invariant-date, dateformula, localization, appsourcecop]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use invariant date literals
## Description
Write fixed dates in AL with the invariant `yyyymmddD` syntax. A locale-dependent text value parsed with `Evaluate` can change meaning or fail under another user's regional settings, which makes the Marketplace extension unreliable across markets.
## Best Practice
Represent a fixed date directly as an AL date literal, such as `20250131D`. Use `CalcDate` with a date formula when the value is relative rather than fixed.
See sample: [`use-invariant-date-literals.good.al`](use-invariant-date-literals.good.al).
## Anti Pattern
Building a fixed date by passing localized text such as `01/02/2025` to `Evaluate`. Detection signal: `Evaluate` converting a hard-coded or label-backed formatted string into a `Date`.
See sample: [`use-invariant-date-literals.bad.al`](use-invariant-date-literals.bad.al).

View file

@ -16,7 +16,7 @@ application-area: [all]
Reviews AL source and app metadata changes against the `appsource` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. Reviews AL source and app metadata changes against the `appsource` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply to AppSource-facing metadata and complete permission coverage that requires repository context. Mechanical AppSourceCop diagnostics are intentionally outside this skill. The skill returns `not-applicable` when none of those surfaces apply. An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply to Marketplace-facing metadata, complete permission coverage that requires repository context, and contextual AL constructs covered by Marketplace submission requirements. Mechanical compiler and analyzer diagnostics are intentionally outside this skill. The skill returns `not-applicable` when none of those surfaces apply.
## Source ## Source
@ -37,14 +37,20 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- The changed files and AL object types — especially `app.json`, permission-set objects, setup and usage entry points, and AppSource-facing help metadata. - The changed files and AL object types — especially `app.json`, permission-set and profile objects, setup and usage entry points, service-enabled procedures, user-facing pages and reports, and AppSource-facing help metadata.
- Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). - Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `profile`, `Record Profile`, `Evaluate`, `Date`, `DateTime`, `CurrentDateTime`, `UsageCategory`, `PageType`, `addfirst`, `addlast`, `addbefore`, `addafter`, `ServiceEnabled`, `GuiAllowed`, `Message`, `Confirm`, `StrMenu`, `RunModal`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action.
- The app has no assignable permission set covering its setup and usage paths, omits visible object/tabledata grants, or requires `SUPER` for normal operation — `permission-sets-cover-setup-and-usage-without-super`. Require repository-level app context; one isolated permission-set object cannot prove complete coverage. - The app has no assignable permission set covering its setup and usage paths, omits visible object/tabledata grants, or requires `SUPER` for normal operation — `permission-sets-cover-setup-and-usage-without-super`. Require repository-level app context; one isolated permission-set object cannot prove complete coverage.
- Install, upgrade, or setup code provisions an app-owned profile through `Record Profile` and `Insert` instead of declaring a `profile` object — `define-profiles-as-al-objects`.
- A hard-coded or label-backed formatted string is converted to `Date` with `Evaluate` — `use-invariant-date-literals`. Do not select this article for variable external input whose format must be validated at runtime.
- A page extension uses `addbefore` or `addafter` to place a newly added action relative to a specific action owned by another app — `place-page-extension-actions-with-addfirst-or-addlast`. Do not flag those keywords in layouts or placement relative to an action owned by the same extension.
- A page or codeunit web-service entry point, including a `[ServiceEnabled]` procedure, contains or reaches `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, or a confirmation-dialog page without an effective non-GUI guard — `keep-web-service-paths-free-of-ui-calls`. Treat `Message` as suppressed and logged, making it ineffective as a service response; treat the other UI calls as callback-failure risks. Do not treat a controlled `Error` as interactive UI solely because it returns a service fault.
- A page or report that repository context identifies as a direct user entry point omits `UsageCategory` or sets it to `None` — `set-usagecategory-on-searchable-entry-points`. Do not select this article based only on object type; exclude supporting parts, dialogs, API pages, and objects intentionally reached through another page.
- A `DateTime` assignment adds or subtracts a fixed duration to represent an assumed regional offset — `do-not-hard-code-time-zone-offsets`. Require contextual evidence such as an hour-sized constant, offset-oriented name, or time-zone comment; do not flag deadlines, schedules, or elapsed-time calculations.
- For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. - For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
@ -75,7 +81,7 @@ Outcome selection:
- `completed` — the skill evaluated every worklist item. - `completed` — the skill evaluated every worklist item.
- `no-knowledge` — no applicable AppSource knowledge survived filtering. - `no-knowledge` — no applicable AppSource knowledge survived filtering.
- `not-applicable` — the diff touches no AppSource permission or app-metadata surface. - `not-applicable` — the diff touches no Marketplace-related source, permission, or app-metadata surface.
- `partial` — a budget was hit before the worklist was exhausted. - `partial` — a budget was hit before the worklist was exhausted.
- `failed` — an unrecoverable error occurred. - `failed` — an unrecoverable error occurred.