Address second round of Jesper Schulz-Wedde's review on PR #157

- log-writes-must-survive-rollback.good.al: fixed invalid trigger
  OnRun(var Rec: ...) declaration; Rec is implicit when TableNo is set.
- exposed-objects-must-be-in-a-permission-set.md: distinguished the three
  exposure mechanisms (page/query web service or API, codeunit published
  as a web service, [ServiceEnabled] bound action on a page) and their
  actual permission targets (page/query "..." = X vs codeunit "..." = X).
- code-must-not-change-workdate.md: scoped from an absolute "never" to
  "not as a side effect of unrelated logic" - verified real WorkDate(x)
  setter usage in BCApps demo-data generators and test codeunits.
- bcpt-scenarios-must-be-app-specific.md: SingleInstance and
  StartScenario/EndScenario reframed as context-dependent patterns, not
  mandatory requirements - BCPT Create Customer uses neither.
- test-feature-scenario-tags.good.al/.bad.al: replaced the invented
  LibrarySales.CreateCustomerWithPrice/"Item Price Mgt." calls with a real,
  verified price-list-line test using Library - Sales/Library - Inventory/
  Library - Price Calculation.
- page-design-must-match-bc-page-type-conventions.md: scoped the missing
  UsageCategory anti-pattern to pages intended as searchable entry points.
- defensive-vs-offensive-code-must-match-blast-radius.md/.good.al/.bad.al:
  replaced the VAT registration number "low blast radius" example with a
  genuinely cosmetic field (customer home page URL).
- source-organized-by-feature-not-object-type.md: anti-pattern reframed as
  inconsistency with a repo's own convention, not the object-type scheme
  itself.
- pictures-must-use-media-not-blob.md: removed leftover "image variants"
  wording contradicting the already-corrected MediaSet description.

Proactively fixed while sweeping all fixtures for invented APIs:
- given-blocks-must-cover-full-precondition-chain.bad.al: PostSalesOrder
  called with wrong arity and referenced an undeclared variable.
- ui-test-codeunit-naming.good.al/.bad.al: replaced the same fake
  "Item Price Mgt."/TestPage "Item Price" with real Library - Sales calls
  and the real Customer Card TestPage.

Worklist completeness: added review-skill cues for the 12 of 18 new rules
that had none (al-appsource-review.md, al-data-modeling-review.md,
al-error-handling-review.md, al-security-review.md, al-style-review.md x3,
al-testing-review.md x2, al-ui-review.md, al-upgrade-review.md,
al-web-services-review.md), and fixed test-feature-scenario-tags' cue,
which only matched the compliant (tagged) shape instead of the anti-pattern
(untagged/generic-named test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Dieringer 2026-09-08 20:05:56 +02:00
parent 0120b874b2
commit 3842ef7138
26 changed files with 219 additions and 89 deletions

View file

@ -13,28 +13,46 @@ application-area: [all]
The work date is a per-user session setting the user controls from the
client (the date shown in the top-right corner, used to default posting
dates and date filters). Application code must never call the `WorkDate`
function to set a new value. Doing so changes what the user sees and
defaults to for the rest of their session, as a side effect of running
unrelated business logic — a surprising, hard-to-trace behavior change the
user never asked for and has no visibility into.
dates and date filters). Business logic unrelated to that setting must not
call `WorkDate(NewDate)` as a side effect of doing something else — that
silently changes what the user sees and defaults to for the rest of their
session, a surprising, hard-to-trace behavior change the user never asked
for and has no visibility into. This is not a blanket ban on the setter
itself: BCApps' own demo-data generators legitimately save the current
work date, set a specific one to backdate the data they create, and
restore it afterward (see `CreateDemoEDocsBE.Codeunit.al`'s
`WorkDate(SampleInvoiceDate)` / `WorkDate(SavedWorkDate)` pair), and test
codeunits routinely set `WorkDate` deliberately to control the date context
a test runs under (hundreds of calls across BCApps' test suite, for
example `SustainabilityPostingTest.Codeunit.al`). Both are the code's
*actual purpose*, not a side effect of something unrelated.
This is a call-direction distinction: reading the current work date via
`WorkDate` (or `WorkDate()` with no argument) is fine and common — it is
only the assignment form, `WorkDate(NewDate)`, that is the anti-pattern.
This is a call-direction distinction for the read side: reading the
current work date via `WorkDate` (or `WorkDate()` with no argument) is
always fine.
## Best Practice
Read the work date to default a value; never write to it.
Read the work date to default a value. Only write to it when changing it
*is* the operation being performed — implementing the user's own
work-date/settings action, or a test or demo-data routine that deliberately
establishes a date context (saving and restoring the prior value if the
routine must leave the session as it found it). Business logic that exists
to do something else must never write `WorkDate` as an incidental side
effect; if a calculation needs a specific date, pass or compute that date
as a local variable instead.
See sample: `code-must-not-change-workdate.good.al`.
## Anti Pattern
Setting the work date from within a codeunit, report, or page action
changes session state the user owns, for the duration of a call that has
nothing to do with the user's date preference. If a scenario genuinely
needs a specific date for a calculation, pass or compute that date as a
local variable — never repurpose the session's `WorkDate`.
Setting the work date from within a codeunit, report, or page action whose
purpose is unrelated to the user's date preference — for example, a
posting or calculation routine that calls `WorkDate(SomeDate)` to make its
own logic simpler. This changes session state the user owns for the
duration of a call that was never about the work date, and never restores
it. This is a different case from a test or demo-data routine explicitly
declaring a date context: the anti-pattern is unrelated logic silently
mutating state it does not own, not the setter form itself.
See sample: `code-must-not-change-workdate.bad.al`.

View file

@ -30,8 +30,8 @@ file attachment blob unrelated to picture rendering).
## Best Practice
Use `Media` (or `MediaSet` for multiple image variants) for any field that
holds a picture.
Use `Media` for a single image, or `MediaSet` for multiple independent
images, for any field that holds a picture.
See sample: `pictures-must-use-media-not-blob.good.al`.

View file

@ -1,6 +1,6 @@
// Both fields guarded the same way, out of habit rather than analysis.
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
VATRegNo := SalesHeader."VAT Registration No."; // low blast radius - fine
if Customer.Get(SalesHeader."Sell-to Customer No.") then
CustomerHomePage := Customer."Home Page"; // low blast radius - fine
// but the same pattern, unexamined, was also applied here:
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then

View file

@ -1,8 +1,8 @@
// Low blast radius: guard, with an explicit chosen fallback.
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
VATRegNo := SalesHeader."VAT Registration No.";
if Customer.Get(SalesHeader."Sell-to Customer No.") then
CustomerHomePage := Customer."Home Page";
// Blank is an acceptable, deliberately-considered default here - the field
// is informational and a reviewer sees it before the document ships.
// is purely a display convenience and a reviewer sees it before the document ships.
// High blast radius: let it fail loud, because this feeds posted VAT.
SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo);

View file

@ -21,6 +21,6 @@ See sample: `defensive-vs-offensive-code-must-match-blast-radius.good.al`.
## Anti Pattern
Guarding two fields the same way purely out of habit, without analyzing what each one feeds. A low-blast-radius field, such as a VAT registration number shown only on a printed document, and a high-blast-radius field, such as the VAT posting group that determines VAT actually applied to a posted transaction, are both wrapped in the same `if Header.Get(...) then ... else` pattern with a blank/zero fallback — leaving the posting-critical field free to post with a silently wrong value.
Guarding two fields the same way purely out of habit, without analyzing what each one feeds. A low-blast-radius field, such as a customer's home page URL shown only for convenience on a printed document, and a high-blast-radius field, such as the VAT posting group that determines VAT actually applied to a posted transaction, are both wrapped in the same `if Header.Get(...) then ... else` pattern with a blank/zero fallback — leaving the posting-critical field free to post with a silently wrong value. A VAT registration number is not a safe stand-in for the low-risk side of this example: it is legally relevant, often validated, and can feed external VAT services or mandated document output, so it belongs on the offensive/fail-fast side alongside the posting group, not next to it as the "safe" contrast.
See sample: `defensive-vs-offensive-code-must-match-blast-radius.bad.al`.

View file

@ -15,7 +15,7 @@ codeunit 50100 "Sample Error Log Writer"
// session — there is no shared memory with the caller's instance.
TableNo = "Sample Error Log Buffer";
trigger OnRun(var Rec: Record "Sample Error Log Buffer")
trigger OnRun()
var
ErrorLogEntry: Record "Sample Error Log";
begin

View file

@ -11,11 +11,17 @@ application-area: [all]
## Description
An object that is reachable from outside the app's own UI is only usable if it is also granted execute access through a permission set. Three distinct mechanisms make an object reachable this way, and each needs to be checked on its own terms: a page or query published through the **Web Services** configuration page; a custom REST endpoint declared with `PageType = API` / `QueryType = API`; or an individual codeunit method exposed with the `[ServiceEnabled]` attribute (a method-level attribute — it does not apply to pages or queries as a property). When such an object is left out of every permission set, it becomes both unusable (no caller, human or service, can reach it) and invisible in review: nobody deliberately decided who may call it. Exposure without a matching grant is not a safe default; it is an endpoint nobody is governing.
An object that is reachable from outside the app's own UI is only usable if it is also granted execute access through a permission set. Three distinct mechanisms make an object reachable this way, each with its own permission target:
- A page or query published through the **Web Services** configuration page, or a custom REST endpoint declared with `PageType = API` / `QueryType = API` — both need a `page "..." = X` / `query "..." = X` entry for that object.
- A codeunit published through **Web Services** exposes *every* public procedure on it as an OData/SOAP operation automatically — there is no per-method attribute to add. The permission target is the codeunit itself: `codeunit "..." = X`.
- `[ServiceEnabled]` is a method-level attribute used on a *page* procedure to expose it as an OData v4 bound action (for example a `Post` action on an invoice page) — it does not apply to pages, queries, or codeunits as an object-level property, and it does not create its own permission target. The action is still a call into that page object, so the page's own `page "..." = X` entry is what governs it.
When such an object is left out of every permission set, it becomes both unusable (no caller, human or service, can reach it) and invisible in review: nobody deliberately decided who may call it. Exposure without a matching grant is not a safe default; it is an endpoint nobody is governing.
## Best Practice
Give every exposed object an explicit execute entry (`page "..." = X`, `query "..." = X`) in a permission set shipped by the app. Route sensitive endpoints into a dedicated, non-default admin permission set so reaching them requires a deliberate grant rather than being included by default. If an object should never be reachable from outside the app, remove the exposure itself (drop `PageType = API` / `ServiceEnabled`) rather than leaving an orphaned endpoint with no permission-set membership.
Give every exposed object an explicit execute entry in a permission set shipped by the app: `page "..." = X` / `query "..." = X` for a published or API page/query (including one that exposes a `[ServiceEnabled]` bound action), and `codeunit "..." = X` for a codeunit published as a web service. Route sensitive endpoints into a dedicated, non-default admin permission set so reaching them requires a deliberate grant rather than being included by default. If an object should never be reachable from outside the app, remove the exposure itself (drop `PageType = API` / the Web Services registration) rather than leaving an orphaned endpoint with no permission-set membership.
See sample: `exposed-objects-must-be-in-a-permission-set.good.al`.

View file

@ -28,9 +28,21 @@ Each feature folder holds every object type it needs; shared code has one dedica
## Anti Pattern
A repository that documents or has established feature-based organization
as its convention, but then mixes in object-type folders for new work
anyway:
src/
├── Tables/
├── Pages/
├── Sales/
│ └── Invoice/
├── Tables/ <- new objects land here instead of a feature folder
└── Codeunits/
Finding everything related to one feature now requires searching multiple folders and mentally reassembling it from scattered pieces.
The anti-pattern is inconsistency with the project's own chosen convention,
not the object-type scheme itself — a repository that deliberately and
consistently organizes by object type throughout is exercising the other
reasonable choice described above, not violating this rule. What actually
costs a reader time is a codebase where some features live under their own
folder and others are scattered across type folders, so finding everything
related to one feature means checking both schemes and reassembling it from
wherever each object happened to land.

View file

@ -15,7 +15,7 @@ A PerformanceTest app that ships with only the generic Microsoft BCPT samples (c
## Best Practice
For every major business flow the extension adds, create a matching `BCPT*` scenario codeunit: `SingleInstance = true`, implementing `"BCPT Test Param. Provider"`, wrapping the operation under test in `BCPTTestContext.StartScenario()` / `EndScenario()`, and building its own test data in a local `InitTest()` procedure rather than depending on hardcoded records. Give each distinct step its own named scenario so a regression in one step doesn't hide inside a coarser measurement.
For every major business flow the extension adds, create a matching `BCPT*` scenario codeunit implementing `"BCPT Test Param. Provider"`, building its own test data in a local `InitTest()` procedure rather than depending on hardcoded records. Beyond that shared shape, the interface details are context-dependent, not fixed requirements: most of Microsoft's own shipped BCPT samples declare `SingleInstance = true`, but `codeunit "BCPT Create Customer"` does not, relying instead on `OnRun` calling `InitTest()` unconditionally every run. Likewise, wrapping the operation under test in `BCPTTestContext.StartScenario()` / `EndScenario()` is a real, available pattern for splitting one codeunit's run into several separately measured steps — useful when a regression in one step should not hide inside a coarser, whole-`OnRun` measurement — but it is not what every sample does; `"BCPT Create Customer"` measures its entire `OnRun` as a single implicit scenario and never calls `StartScenario`/`EndScenario` at all. Choose per-step scenarios when step-level granularity matters to the flow being tested; otherwise a single measured `OnRun` is a legitimate, simpler choice.
See sample: `bcpt-scenarios-must-be-app-specific.good.al`.

View file

@ -2,11 +2,14 @@
procedure PostSalesOrder_CreatesInvoice()
var
SalesHeader: Record "Sales Header";
SalesInvoiceHeader: Record "Sales Invoice Header";
InvoiceNo: Code[20];
begin
// [GIVEN] a sales order — posting groups left to whatever exists in the test company
LibrarySales.CreateSalesOrder(SalesHeader);
// [WHEN]
LibrarySales.PostSalesOrder(SalesHeader, false, true);
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true);
// [THEN]
SalesInvoiceHeader.Get(InvoiceNo);
Assert.RecordIsNotEmpty(SalesInvoiceHeader);
end;

View file

@ -3,7 +3,9 @@ codeunit 50102 "Item Price Testing"
Subtype = Test;
var
ItemPriceMgt: Codeunit "Item Price Mgt.";
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
Assert: Codeunit "Library Assert";
[Test]
@ -11,11 +13,21 @@ codeunit 50102 "Item Price Testing"
var
Customer: Record Customer;
Item: Record Item;
Price, Disc: Decimal;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// setup mixed with assertions, no clear layers
Customer.Insert(false);
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', Price, Disc);
Assert.AreEqual(100, Price, '');
// setup mixed with assertions, no clear layers, no FEATURE/SCENARIO/GIVEN/WHEN/THEN tags
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
end;
}

View file

@ -5,7 +5,8 @@ codeunit 50103 "Item Price Testing"
var
LibrarySales: Codeunit "Library - Sales";
ItemPriceMgt: Codeunit "Item Price Mgt.";
LibraryInventory: Codeunit "Library - Inventory";
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
Assert: Codeunit "Library Assert";
[Test]
@ -13,14 +14,24 @@ codeunit 50103 "Item Price Testing"
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [SCENARIO] Customer with a specific price list line gets that unit price
// [GIVEN] a customer with a price list line at 100 LCY
LibrarySales.CreateCustomerWithPrice(Customer, Item, '', 100);
// [WHEN]
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
// [THEN]
Assert.AreEqual(100, UnitPrice, 'Unit price must match customer price list');
// [GIVEN] a customer and an item with a customer-specific sales price list line
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [WHEN] a sales line is created for that customer and item
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [THEN] the sales line picks up the customer's price list line
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match customer price list');
end;
}

View file

@ -1,15 +1,32 @@
[Test]
procedure GetPrice_ThenGetDiscount_ReturnsCorrectValues()
procedure GetPrice_ThenGetPriceLines_ReturnsCorrectValues()
var
TempBuffer: Record "Item Price Tier Buffer" temporary;
UnitPrice, LineDiscPct: Decimal;
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [GIVEN] ...
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [WHEN] first action
ItemPriceMgt.GetSalesPrice(CustomerNo, ItemNo, '', UnitPrice, LineDiscPct);
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [WHEN] second action — this is a second test in disguise
ItemPriceMgt.GetSalesPriceTiers(CustomerNo, ItemNo, '', TempBuffer);
PriceListLine.Validate("Minimum Quantity", 10);
PriceListLine.Modify(true);
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [THEN] asserting two unrelated things
Assert.AreEqual(100, UnitPrice, '');
Assert.IsFalse(TempBuffer.IsEmpty(), '');
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
Assert.AreEqual(2, PriceListLine.Count(), '');
end;

View file

@ -3,27 +3,51 @@ procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [GIVEN] a customer with a price list line at 100
LibrarySales.CreateCustomerWithPrice(Customer, Item, '', 100);
// [GIVEN] a customer with a price list line for the item
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [WHEN]
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [THEN]
Assert.AreEqual(100, UnitPrice, 'Unit price must match price list');
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match price list');
end;
[Test]
procedure GetPriceTiers_CustomerTier_ReturnsOneTierLine()
procedure GetPriceLines_TwoMinimumQuantityLines_ReturnsBoth()
var
Customer: Record Customer;
Item: Record Item;
TempBuffer: Record "Item Price Tier Buffer" temporary;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
begin
// [GIVEN] a customer with a tier price at min qty 10
LibrarySales.CreateCustomerWithTierPrice(Customer, Item, '', 10, 90);
// [GIVEN] a customer price list with two minimum-quantity price lines for the same item
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
PriceListLine.Validate("Minimum Quantity", 10);
PriceListLine.Modify(true);
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
PriceListLine.Validate("Minimum Quantity", 50);
PriceListLine.Modify(true);
// [WHEN]
ItemPriceMgt.GetSalesPriceTiers(Customer."No.", Item."No.", '', TempBuffer);
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
// [THEN]
Assert.AreEqual(1, TempBuffer.Count(), 'Exactly one tier line expected');
Assert.AreEqual(2, PriceListLine.Count(), 'Exactly two price lines expected');
end;

View file

@ -3,22 +3,25 @@ codeunit 50104 "Item Price Testing"
Subtype = Test;
[Test]
procedure GetPrice_LogicTest()
procedure ApplyDiscount_LogicTest()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
Assert: Codeunit "Library Assert";
begin
// logic test — fine on its own, but not paired with a UI test below
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
Assert.AreEqual(90, ApplyDiscount(100, 10), 'A 10% discount on 100 must yield 90');
end;
local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
begin
exit(UnitPrice - (UnitPrice * DiscountPct / 100));
end;
[Test]
procedure Page_ShowsPrice_UT()
procedure CustomerCard_Opens_UT()
var
ItemPricePage: TestPage "Item Price";
CustomerCard: TestPage "Customer Card";
begin
// UI test mixed into a logic-test codeunit, and the codeunit lacks the _UT suffix
ItemPricePage.OpenNew();
CustomerCard.OpenNew();
end;
}

View file

@ -3,13 +3,18 @@ codeunit 50105 "Item Price Testing"
Subtype = Test;
[Test]
procedure GetPrice_CustomerPrice_ReturnsUnitPrice()
procedure ApplyDiscount_ReducesUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
Assert: Codeunit "Library Assert";
DiscountedPrice: Decimal;
begin
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
DiscountedPrice := ApplyDiscount(100, 10);
Assert.AreEqual(90, DiscountedPrice, 'A 10% discount on 100 must yield 90');
end;
local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
begin
exit(UnitPrice - (UnitPrice * DiscountPct / 100));
end;
}
@ -18,16 +23,20 @@ codeunit 50106 "Item Price Testing_UT"
Subtype = Test;
[Test]
procedure Page_EnterCustomerAndItem_FactBoxShowsPrice()
procedure CustomerCard_SetName_UpdatesField()
var
Customer: Record Customer;
Item: Record Item;
ItemPricePage: TestPage "Item Price";
CustomerCard: TestPage "Customer Card";
Assert: Codeunit "Library Assert";
LibrarySales: Codeunit "Library - Sales";
begin
ItemPricePage.OpenNew();
ItemPricePage.CustomerNo.SetValue(Customer."No.");
ItemPricePage.ItemNo.SetValue(Item."No.");
Assert.AreEqual('100.00', ItemPricePage.PriceInfo.UnitPrice.Value(), '');
LibrarySales.CreateCustomer(Customer);
CustomerCard.OpenEdit();
CustomerCard.GoToRecord(Customer);
CustomerCard.Name.SetValue('Updated Name');
CustomerCard.Close();
Customer.Get(Customer."No.");
Assert.AreEqual('Updated Name', Customer.Name, 'Name must be updated through the page');
end;
}

View file

@ -75,9 +75,11 @@ page with no `CardPageID` even though a Card page exists for the same
table — signals a design step was skipped, not a stylistic choice. A
Card page over a composite-key table is not automatically this anti
pattern; check whether the table supplements a master record first. Also watch
for: a Worksheet or List page showing primary-key fields it shouldn't (or
hiding them when it should show them), and a page with no
`UsageCategory` set, which makes it invisible to Tell Me search even
though it otherwise works.
for a Worksheet or List page showing primary-key fields it shouldn't (or
hiding them when it should show them). A page with no `UsageCategory` set
is not automatically a defect either: supporting pages, subpages, dialogs,
and pages intended only to be reached through another workflow correctly
have no `UsageCategory` — flag its absence only on a page intended as a
searchable entry point in its own right.
See sample: `page-design-must-match-bc-page-type-conventions.bad.al`.

View file

@ -52,6 +52,7 @@ The following targeted checks cover every current `appsource` article across the
- 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`.
- A release/submission pipeline change (`AL-Go-Settings.json`, a publish/release workflow) or an `app.json` version bump is present without the new complete version being strictly greater than the previously submitted one, or the change asserts a hand-edited build/revision or every-merge-is-a-release policy as a universal AppSource rule rather than a project-specific workflow choice — `release-must-update-app-version`. Require repository/pipeline context to know the previously submitted version; a single `app.json` diff cannot prove ordering on its own.
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`.

View file

@ -47,6 +47,7 @@ The following targeted checks cover every current `data-modeling` article. Treat
- A `* Setup` table or its page changes singleton structure, uses a nonblank or generated key, permits insert/delete, uses a List page, or does not ensure the blank-keyed row exists — `setup-table-is-a-singleton`.
- A new field is typed `Media`, `MediaSet`, or `BLOB` and the field's caption/name suggests a picture or image — `pictures-must-use-media-not-blob`.
- Code outside a test codeunit or a demo-data generator calls `WorkDate(NewDate)` (the assignment form, not a bare `WorkDate()` read) as part of logic whose purpose is unrelated to the work date itself — `code-must-not-change-workdate`. A test deliberately setting a date context, or a demo-data routine that saves, sets, and restores the work date to backdate the data it creates, is not this anti-pattern.
- A new or extended table declares its `keys` block, primary-key field list, or naming suffix (`Ledger Entry`, `Journal Line`, `Header`/`Line`, `Setup`) — `table-design-must-match-bc-table-type-conventions`.
- A custom master table changes its primary key, `No.`/`No. Series` fields, or `OnInsert` without assigning a blank `No.` from setup through a number series — `master-table-no-from-number-series-in-oninsert`.
- BC v22 or later code introduces or retains `NoSeriesManagement`, `InitSeries`, `SelectSeries`, or `SetSeries`, or number assignment/manual-entry checks do not use codeunit `"No. Series"` methods such as `GetNextNo`, `IsManual`, or `TestManual` — `use-no-series-codeunit-not-noseriesmanagement`.

View file

@ -48,6 +48,7 @@ The following targeted checks cover every current `error-handling` article:
- `[ErrorBehavior(ErrorBehavior::Collect)]`, `ErrorInfo.Collectible`, `HasCollectedErrors`, `GetCollectedErrors`, or `ClearCollectedErrors` is added or changed, especially when errors are collected without later surfacing/clearing them — `collect-validation-errors-with-errorbehavior`.
- New or changed code calls `Session.StartSession` from within error/duration logging around a web-service call, background job, or other operation expected to fail — `log-writes-must-survive-rollback`.
- A guarded lookup (`if Record.Get(...) then ... else` or similar) sets a value used later, and the same guard shape (with the same blank/zero fallback style) is applied to a field that feeds a posted amount, a tax/VAT calculation, a quantity or price actually used in a transaction, or a legally/compliance-facing output — `defensive-vs-offensive-code-must-match-blast-radius`. The signal is a posting-critical or compliance-facing field guarded defensively with a silent fallback, not the mere presence of a guarded lookup.
- Developer-only invariant text is raised with default client visibility, or a user-actionable validation is hidden as `ErrorType::Internal` — `errortype-internal-vs-client-for-diagnostics`.
- `FieldError` receives a complete capitalized sentence, repeats the field caption/value, or ends the predicate with punctuation — `fielderror-default-message-logic`.
- An unguarded `FieldError` is used as though it performed a comparison, or `TestField` is forced onto a complex rule needing a tailored predicate — `fielderror-vs-testfield`.

View file

@ -49,6 +49,7 @@ For secret values, select the most specific sink owner:
- When a `Text`/`Code` credential is declared, passed, returned, or unwrapped without a visible HTTP URI/header/body sink, use `secrettext-for-credentials.md`.
- When that value is interpolated into a URI, authorization header, or HTTP body and sent through `HttpClient`, use `secrettext-with-httpclient.md` as the primary finding. It supersedes the generic credential-type article at that location; keep the latter only as a supporting reference when useful.
- When a page/query is registered in Web Services, declares `PageType = API`/`QueryType = API`, a codeunit is registered in Web Services, or `[ServiceEnabled]` is added to a page procedure, and no permission set in the app grants a matching `page "..." = X` / `query "..." = X` / `codeunit "..." = X` entry for that specific object — use `exposed-objects-must-be-in-a-permission-set.md`. The anti-pattern is the exposed object missing its own execute entry, even when the underlying table's `tabledata` permissions look complete; require repository-level permission-set context, since one file cannot prove an entry is absent elsewhere in the app.
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`.

View file

@ -51,6 +51,10 @@ Apply these high-signal mappings before fuzzy topic ranking:
- A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder — `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged.
- A normal two-argument `Evaluate` has a resolved `DateFormula` destination and a hard-coded non-angle-bracket date-formula literal, directly or through a visible constant — `dateformula-evaluate-needs-language-independent-literals.md`. Do not use this cue for dynamic/localized external input, already invariant `<...>` input, or direct `CalcDate(Text, ...)` calls.
- `function-call-parentheses-required.md` applies only to a zero-argument invocation written without `()`. Never worklist it from an invocation that already has parentheses or supplies arguments, including `Error(Label, Arg1, Arg2)`.
- A new or changed comment restates what the adjacent code already makes obvious from its own names and structure (a comment that just repeats a variable/field/method name in prose) rather than explaining a non-obvious constraint, invariant, or workaround — `al-comments-must-not-restate-what-code-already-shows.md`. A comment absent entirely is not this anti-pattern; only a present-but-redundant comment is.
- A `page`/`pageextension` adds or changes a procedure body that performs a calculation, validation, or record mutation belonging to a business operation reused across entry points, rather than presentation-specific state or a call into a codeunit — `pages-must-not-contain-business-logic.md`. A page calling a codeunit procedure, or a page's own presentation-only state and formatting, is not this anti-pattern; nor is a data invariant that belongs on the table itself.
- Changed source files are added under an object-type folder (`Tables/`, `Pages/`, `Codeunits/`, etc.) in a repository whose existing structure is predominantly feature-based, or vice versa — `source-organized-by-feature-not-object-type.md`. The anti-pattern is inconsistency with the repository's own established convention, not the choice of either scheme; a repository consistently organized by object type throughout is not a violation. Require repository-level folder context; a single new file's path cannot prove the project's convention alone.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.

View file

@ -46,9 +46,11 @@ A file enters the candidate worklist when its `keywords` intersect the extracted
The following targeted checks cover every current `testing` article. 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.
- A method in a `Subtype = Test` codeunit adds or changes `[TransactionModel(...)]`, exercises code that calls `Commit` under `AutoRollback`, defaults broadly to `AutoCommit`, or chooses `None` for a writing test — `transactionmodel-attribute-governs-test-transactions`.
- A test procedure or comment adds `[FEATURE]`/`[SCENARIO]`/`[GIVEN]`/`[WHEN]`/`[THEN]` tags — `test-feature-scenario-tags`.
- A test codeunit calls `TestPage` methods (`OpenNew`, `OpenView`, `OpenEdit`) — `ui-test-codeunit-naming`.
- A `[GIVEN]`-tagged setup precedes a posting call or report execution — `given-blocks-must-cover-full-precondition-chain`.
- A new or changed `[Test]` procedure is added, whether or not it already carries `[FEATURE]`/`[SCENARIO]`/`[GIVEN]`/`[WHEN]`/`[THEN]` tags — `test-feature-scenario-tags`. A procedure with no tags at all, or a generic name like `Test1`, is the anti-pattern signal; presence of the tags is the compliant shape, not the thing to search for.
- A test codeunit calls `TestPage` methods (`OpenNew`, `OpenView`, `OpenEdit`) alongside `[Test]` procedures in the same codeunit that call business-logic procedures directly with no `TestPage` involved — `ui-test-codeunit-naming`. The anti-pattern signal is both kinds of test mixed into one codeunit (or, on a project using the `_UT` convention, a UI-layer codeunit missing the suffix); a codeunit containing only `TestPage`-driven tests is not itself a violation.
- A `[GIVEN]`-tagged setup precedes a posting call or report execution and does not visibly set up posting-group/VAT setup records, an explicit date, or (for a report test) both an included and an excluded record — `given-blocks-must-cover-full-precondition-chain`.
- A test procedure contains more than one `[WHEN]` block, or more than one distinct action not labelled `[GIVEN]`, without the procedure name declaring a flow/defect-then-fix shape — `test-one-when-per-test`.
- A `BCPT*` scenario codeunit is added and the PerformanceTest app's only other scenario codeunits are copies of Microsoft's shipped BCPT samples (`BCPT Create Customer`, `BCPT Create Item Journal`, `BCPT Post GL Entries`, etc.) with no scenario exercising the extension's own codeunits, FlowFields, or pages — `bcpt-scenarios-must-be-app-specific`.
- An `AutoCommit` test runs under a `Subtype = TestRunner` codeunit that omits `TestIsolation` or sets it to `Disabled`, leaving committed data between tests — `testisolation-belongs-on-the-test-runner`. Require runner/repository context; a standalone test file cannot prove which runner executes it.
- A permission-sensitive test uses `TestPermissions = Disabled`, claims to test a restricted user without `"Permissions Mock"`/`"Library - Lower Permissions"`, or declares `[TestPermissions(...)]` without applying that context — `permission-tests-must-lower-the-execution-context`.
- Test fixture code manually calls `Init`/`Insert`, invents keys or prerequisite records, or bypasses available `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, or equivalent library codeunits — `use-library-codeunits-for-test-fixtures`.

View file

@ -40,6 +40,7 @@ Discard files that are not applicable. Retain conditionally applicable files onl
Narrow the relevant files to the subset that applies to the changes under review.
- **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files.
- A new or changed page's name/suffix, primary-key handling, `CardPageID`, `SubPageLink`, `AutoSplitKey`, or `UsageCategory` doesn't match the conventions of its own declared `PageType` — `page-design-must-match-bc-page-type-conventions.md`. A Card page over a composite-key table that supplements a master record, or a supporting/subpage/dialog page intended only to be reached through another workflow and correctly omitting `UsageCategory`, is not this anti-pattern on its own; check whether the page is actually mixing conventions or is meant as a searchable entry point before flagging.
- For each relevant knowledge file, compute overlap against changed page declarations and control add-in files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, promoted action definitions, field importance, page background tasks, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL.
- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `fieldgroups`, `DropDown`, `UpdatePropagation`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions, `CardPageID`, `AutoSplitKey`, `PageType = Card`, `PageType = List`, `PageType = Worksheet`, `PageType = Document`).

View file

@ -45,6 +45,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- Worklist the install-versus-upgrade rule when migration helpers are reachable only from an install codeunit.
- Worklist `install-and-upgrade-codeunits-have-no-order.md` when a change adds multiple install or upgrade codeunits whose same-phase triggers share state or depend on one another.
- Worklist `appversion-meaning-depends-on-execution-context.md` when install or upgrade code branches on `ModuleInfo.AppVersion()` or confuses it with `DataVersion()`.
- An upgrade-tag procedure nests a record loop or a multi-branch business-data condition inside the tag-check/exit guard, going past the tag-check-then-exit-then-single-upgrade-action shape, or one procedure mixes the gated logic for more than one distinct upgrade tag — `upgrade-tag-logic-must-not-nest-deeply.md`. A single `if UpgradeTag.HasUpgradeTag(...) then exit;` guard followed by one flat upgrade action (even one that loops over records to apply that single action) is the compliant shape, not the signal to flag — the anti-pattern is a buried, separately-conditioned business decision nested inside that action.
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 upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.

View file

@ -40,6 +40,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types — especially pages declared with `PageType = API`, API page `part` controls, queries declared with `QueryType = API`, and procedures that expose bound actions.
- The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`, `SourceTableTemporary`), navigation metadata (`SubPageLink`, `Multiplicity`, and visible singleton or collection semantics), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields.
- Webhook subscriber handlers and subscription lifecycle code, especially code that creates or renews subscriptions, handles `validationToken`, schedules from `expirationDateTime`, or targets resources whose eligibility is visible in the diff.
- An API page (`PageType = API`) is added or changed and its `InsertAllowed`/`ModifyAllowed`/`DeleteAllowed` properties are left at their default `true` (or explicitly set `true`) for an operation the endpoint's stated purpose does not need — `api-page-least-privilege-write-access.md`. The signal is an operation left enabled beyond what the endpoint's own described purpose requires, not the mere presence of these properties; a page that genuinely needs full CRUD and grants it deliberately is not a violation.
- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `QueryType`, `API`, `api-page`, `page-part`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `SubPageLink`, `subpagelink`, `Multiplicity`, `multiplicity`, `Many`, `ZeroOrOne`, `SourceTableTemporary`, `Job Queue Entry`, `webhook`, `webhookSupportedResources`, `webhook-supported-resources`, `subscriptions`, `notificationUrl`, `validationToken`, `validationtoken`, `expirationDateTime`, `expirationdatetime`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`).
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.