mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
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:
parent
0120b874b2
commit
3842ef7138
26 changed files with 219 additions and 89 deletions
|
|
@ -13,28 +13,46 @@ application-area: [all]
|
||||||
|
|
||||||
The work date is a per-user session setting the user controls from the
|
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
|
client (the date shown in the top-right corner, used to default posting
|
||||||
dates and date filters). Application code must never call the `WorkDate`
|
dates and date filters). Business logic unrelated to that setting must not
|
||||||
function to set a new value. Doing so changes what the user sees and
|
call `WorkDate(NewDate)` as a side effect of doing something else — that
|
||||||
defaults to for the rest of their session, as a side effect of running
|
silently changes what the user sees and defaults to for the rest of their
|
||||||
unrelated business logic — a surprising, hard-to-trace behavior change the
|
session, a surprising, hard-to-trace behavior change the user never asked
|
||||||
user never asked for and has no visibility into.
|
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
|
This is a call-direction distinction for the read side: reading the
|
||||||
`WorkDate` (or `WorkDate()` with no argument) is fine and common — it is
|
current work date via `WorkDate` (or `WorkDate()` with no argument) is
|
||||||
only the assignment form, `WorkDate(NewDate)`, that is the anti-pattern.
|
always fine.
|
||||||
|
|
||||||
## Best Practice
|
## 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`.
|
See sample: `code-must-not-change-workdate.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Setting the work date from within a codeunit, report, or page action
|
Setting the work date from within a codeunit, report, or page action whose
|
||||||
changes session state the user owns, for the duration of a call that has
|
purpose is unrelated to the user's date preference — for example, a
|
||||||
nothing to do with the user's date preference. If a scenario genuinely
|
posting or calculation routine that calls `WorkDate(SomeDate)` to make its
|
||||||
needs a specific date for a calculation, pass or compute that date as a
|
own logic simpler. This changes session state the user owns for the
|
||||||
local variable — never repurpose the session's `WorkDate`.
|
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`.
|
See sample: `code-must-not-change-workdate.bad.al`.
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ file attachment blob unrelated to picture rendering).
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
Use `Media` (or `MediaSet` for multiple image variants) for any field that
|
Use `Media` for a single image, or `MediaSet` for multiple independent
|
||||||
holds a picture.
|
images, for any field that holds a picture.
|
||||||
|
|
||||||
See sample: `pictures-must-use-media-not-blob.good.al`.
|
See sample: `pictures-must-use-media-not-blob.good.al`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Both fields guarded the same way, out of habit rather than analysis.
|
// Both fields guarded the same way, out of habit rather than analysis.
|
||||||
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
|
if Customer.Get(SalesHeader."Sell-to Customer No.") then
|
||||||
VATRegNo := SalesHeader."VAT Registration No."; // low blast radius - fine
|
CustomerHomePage := Customer."Home Page"; // low blast radius - fine
|
||||||
|
|
||||||
// but the same pattern, unexamined, was also applied here:
|
// but the same pattern, unexamined, was also applied here:
|
||||||
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
|
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// Low blast radius: guard, with an explicit chosen fallback.
|
// Low blast radius: guard, with an explicit chosen fallback.
|
||||||
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
|
if Customer.Get(SalesHeader."Sell-to Customer No.") then
|
||||||
VATRegNo := SalesHeader."VAT Registration No.";
|
CustomerHomePage := Customer."Home Page";
|
||||||
// Blank is an acceptable, deliberately-considered default here - the field
|
// 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.
|
// High blast radius: let it fail loud, because this feeds posted VAT.
|
||||||
SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo);
|
SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,6 @@ See sample: `defensive-vs-offensive-code-must-match-blast-radius.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## 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`.
|
See sample: `defensive-vs-offensive-code-must-match-blast-radius.bad.al`.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ codeunit 50100 "Sample Error Log Writer"
|
||||||
// session — there is no shared memory with the caller's instance.
|
// session — there is no shared memory with the caller's instance.
|
||||||
TableNo = "Sample Error Log Buffer";
|
TableNo = "Sample Error Log Buffer";
|
||||||
|
|
||||||
trigger OnRun(var Rec: Record "Sample Error Log Buffer")
|
trigger OnRun()
|
||||||
var
|
var
|
||||||
ErrorLogEntry: Record "Sample Error Log";
|
ErrorLogEntry: Record "Sample Error Log";
|
||||||
begin
|
begin
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,17 @@ application-area: [all]
|
||||||
|
|
||||||
## Description
|
## 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
|
## 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`.
|
See sample: `exposed-objects-must-be-in-a-permission-set.good.al`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,21 @@ Each feature folder holds every object type it needs; shared code has one dedica
|
||||||
|
|
||||||
## Anti Pattern
|
## 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/
|
src/
|
||||||
├── Tables/
|
├── Sales/
|
||||||
├── Pages/
|
│ └── Invoice/
|
||||||
|
├── Tables/ <- new objects land here instead of a feature folder
|
||||||
└── Codeunits/
|
└── 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.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ A PerformanceTest app that ships with only the generic Microsoft BCPT samples (c
|
||||||
|
|
||||||
## Best Practice
|
## 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`.
|
See sample: `bcpt-scenarios-must-be-app-specific.good.al`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@
|
||||||
procedure PostSalesOrder_CreatesInvoice()
|
procedure PostSalesOrder_CreatesInvoice()
|
||||||
var
|
var
|
||||||
SalesHeader: Record "Sales Header";
|
SalesHeader: Record "Sales Header";
|
||||||
|
SalesInvoiceHeader: Record "Sales Invoice Header";
|
||||||
|
InvoiceNo: Code[20];
|
||||||
begin
|
begin
|
||||||
// [GIVEN] a sales order — posting groups left to whatever exists in the test company
|
// [GIVEN] a sales order — posting groups left to whatever exists in the test company
|
||||||
LibrarySales.CreateSalesOrder(SalesHeader);
|
LibrarySales.CreateSalesOrder(SalesHeader);
|
||||||
// [WHEN]
|
// [WHEN]
|
||||||
LibrarySales.PostSalesOrder(SalesHeader, false, true);
|
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true);
|
||||||
// [THEN]
|
// [THEN]
|
||||||
|
SalesInvoiceHeader.Get(InvoiceNo);
|
||||||
Assert.RecordIsNotEmpty(SalesInvoiceHeader);
|
Assert.RecordIsNotEmpty(SalesInvoiceHeader);
|
||||||
end;
|
end;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ codeunit 50102 "Item Price Testing"
|
||||||
Subtype = Test;
|
Subtype = Test;
|
||||||
|
|
||||||
var
|
var
|
||||||
ItemPriceMgt: Codeunit "Item Price Mgt.";
|
LibrarySales: Codeunit "Library - Sales";
|
||||||
|
LibraryInventory: Codeunit "Library - Inventory";
|
||||||
|
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
|
||||||
Assert: Codeunit "Library Assert";
|
Assert: Codeunit "Library Assert";
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -11,11 +13,21 @@ codeunit 50102 "Item Price Testing"
|
||||||
var
|
var
|
||||||
Customer: Record Customer;
|
Customer: Record Customer;
|
||||||
Item: Record Item;
|
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
|
begin
|
||||||
// setup mixed with assertions, no clear layers
|
// setup mixed with assertions, no clear layers, no FEATURE/SCENARIO/GIVEN/WHEN/THEN tags
|
||||||
Customer.Insert(false);
|
LibrarySales.CreateCustomer(Customer);
|
||||||
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', Price, Disc);
|
LibraryInventory.CreateItem(Item);
|
||||||
Assert.AreEqual(100, Price, '');
|
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;
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ codeunit 50103 "Item Price Testing"
|
||||||
|
|
||||||
var
|
var
|
||||||
LibrarySales: Codeunit "Library - Sales";
|
LibrarySales: Codeunit "Library - Sales";
|
||||||
ItemPriceMgt: Codeunit "Item Price Mgt.";
|
LibraryInventory: Codeunit "Library - Inventory";
|
||||||
|
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
|
||||||
Assert: Codeunit "Library Assert";
|
Assert: Codeunit "Library Assert";
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -13,14 +14,24 @@ codeunit 50103 "Item Price Testing"
|
||||||
var
|
var
|
||||||
Customer: Record Customer;
|
Customer: Record Customer;
|
||||||
Item: Record Item;
|
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
|
begin
|
||||||
// [SCENARIO] Customer with a specific price list line gets that unit price
|
// [SCENARIO] Customer with a specific price list line gets that unit price
|
||||||
// [GIVEN] a customer with a price list line at 100 LCY
|
// [GIVEN] a customer and an item with a customer-specific sales price list line
|
||||||
LibrarySales.CreateCustomerWithPrice(Customer, Item, '', 100);
|
LibrarySales.CreateCustomer(Customer);
|
||||||
// [WHEN]
|
LibraryInventory.CreateItem(Item);
|
||||||
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
|
LibraryPriceCalculation.CreatePriceHeader(
|
||||||
// [THEN]
|
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
|
||||||
Assert.AreEqual(100, UnitPrice, 'Unit price must match customer price list');
|
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;
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,32 @@
|
||||||
[Test]
|
[Test]
|
||||||
procedure GetPrice_ThenGetDiscount_ReturnsCorrectValues()
|
procedure GetPrice_ThenGetPriceLines_ReturnsCorrectValues()
|
||||||
var
|
var
|
||||||
TempBuffer: Record "Item Price Tier Buffer" temporary;
|
Customer: Record Customer;
|
||||||
UnitPrice, LineDiscPct: Decimal;
|
Item: Record Item;
|
||||||
|
PriceListHeader: Record "Price List Header";
|
||||||
|
PriceListLine: Record "Price List Line";
|
||||||
|
SalesHeader: Record "Sales Header";
|
||||||
|
SalesLine: Record "Sales Line";
|
||||||
begin
|
begin
|
||||||
// [GIVEN] ...
|
// [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
|
// [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
|
// [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
|
// [THEN] asserting two unrelated things
|
||||||
Assert.AreEqual(100, UnitPrice, '');
|
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
|
||||||
Assert.IsFalse(TempBuffer.IsEmpty(), '');
|
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
|
||||||
|
Assert.AreEqual(2, PriceListLine.Count(), '');
|
||||||
end;
|
end;
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,51 @@ procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
|
||||||
var
|
var
|
||||||
Customer: Record Customer;
|
Customer: Record Customer;
|
||||||
Item: Record Item;
|
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
|
begin
|
||||||
// [GIVEN] a customer with a price list line at 100
|
// [GIVEN] a customer with a price list line for the item
|
||||||
LibrarySales.CreateCustomerWithPrice(Customer, Item, '', 100);
|
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]
|
// [WHEN]
|
||||||
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
|
LibrarySales.CreateSalesDocumentWithItem(
|
||||||
|
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
|
||||||
// [THEN]
|
// [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;
|
end;
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
procedure GetPriceTiers_CustomerTier_ReturnsOneTierLine()
|
procedure GetPriceLines_TwoMinimumQuantityLines_ReturnsBoth()
|
||||||
var
|
var
|
||||||
Customer: Record Customer;
|
Customer: Record Customer;
|
||||||
Item: Record Item;
|
Item: Record Item;
|
||||||
TempBuffer: Record "Item Price Tier Buffer" temporary;
|
PriceListHeader: Record "Price List Header";
|
||||||
|
PriceListLine: Record "Price List Line";
|
||||||
begin
|
begin
|
||||||
// [GIVEN] a customer with a tier price at min qty 10
|
// [GIVEN] a customer price list with two minimum-quantity price lines for the same item
|
||||||
LibrarySales.CreateCustomerWithTierPrice(Customer, Item, '', 10, 90);
|
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]
|
// [WHEN]
|
||||||
ItemPriceMgt.GetSalesPriceTiers(Customer."No.", Item."No.", '', TempBuffer);
|
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
|
||||||
// [THEN]
|
// [THEN]
|
||||||
Assert.AreEqual(1, TempBuffer.Count(), 'Exactly one tier line expected');
|
Assert.AreEqual(2, PriceListLine.Count(), 'Exactly two price lines expected');
|
||||||
end;
|
end;
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,25 @@ codeunit 50104 "Item Price Testing"
|
||||||
Subtype = Test;
|
Subtype = Test;
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
procedure GetPrice_LogicTest()
|
procedure ApplyDiscount_LogicTest()
|
||||||
var
|
var
|
||||||
Customer: Record Customer;
|
Assert: Codeunit "Library Assert";
|
||||||
Item: Record Item;
|
|
||||||
UnitPrice, LineDiscPct: Decimal;
|
|
||||||
begin
|
begin
|
||||||
// logic test — fine on its own, but not paired with a UI test below
|
// 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;
|
end;
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
procedure Page_ShowsPrice_UT()
|
procedure CustomerCard_Opens_UT()
|
||||||
var
|
var
|
||||||
ItemPricePage: TestPage "Item Price";
|
CustomerCard: TestPage "Customer Card";
|
||||||
begin
|
begin
|
||||||
// UI test mixed into a logic-test codeunit, and the codeunit lacks the _UT suffix
|
// UI test mixed into a logic-test codeunit, and the codeunit lacks the _UT suffix
|
||||||
ItemPricePage.OpenNew();
|
CustomerCard.OpenNew();
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,18 @@ codeunit 50105 "Item Price Testing"
|
||||||
Subtype = Test;
|
Subtype = Test;
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
procedure GetPrice_CustomerPrice_ReturnsUnitPrice()
|
procedure ApplyDiscount_ReducesUnitPrice()
|
||||||
var
|
var
|
||||||
Customer: Record Customer;
|
Assert: Codeunit "Library Assert";
|
||||||
Item: Record Item;
|
DiscountedPrice: Decimal;
|
||||||
UnitPrice, LineDiscPct: Decimal;
|
|
||||||
begin
|
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;
|
end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,16 +23,20 @@ codeunit 50106 "Item Price Testing_UT"
|
||||||
Subtype = Test;
|
Subtype = Test;
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
procedure Page_EnterCustomerAndItem_FactBoxShowsPrice()
|
procedure CustomerCard_SetName_UpdatesField()
|
||||||
var
|
var
|
||||||
Customer: Record Customer;
|
Customer: Record Customer;
|
||||||
Item: Record Item;
|
CustomerCard: TestPage "Customer Card";
|
||||||
ItemPricePage: TestPage "Item Price";
|
|
||||||
Assert: Codeunit "Library Assert";
|
Assert: Codeunit "Library Assert";
|
||||||
|
LibrarySales: Codeunit "Library - Sales";
|
||||||
begin
|
begin
|
||||||
ItemPricePage.OpenNew();
|
LibrarySales.CreateCustomer(Customer);
|
||||||
ItemPricePage.CustomerNo.SetValue(Customer."No.");
|
CustomerCard.OpenEdit();
|
||||||
ItemPricePage.ItemNo.SetValue(Item."No.");
|
CustomerCard.GoToRecord(Customer);
|
||||||
Assert.AreEqual('100.00', ItemPricePage.PriceInfo.UnitPrice.Value(), '');
|
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;
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
table — signals a design step was skipped, not a stylistic choice. A
|
||||||
Card page over a composite-key table is not automatically this anti
|
Card page over a composite-key table is not automatically this anti
|
||||||
pattern; check whether the table supplements a master record first. Also watch
|
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
|
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
|
hiding them when it should show them). A page with no `UsageCategory` set
|
||||||
`UsageCategory` set, which makes it invisible to Tell Me search even
|
is not automatically a defect either: supporting pages, subpages, dialogs,
|
||||||
though it otherwise works.
|
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`.
|
See sample: `page-design-must-match-bc-page-type-conventions.bad.al`.
|
||||||
|
|
|
||||||
|
|
@ -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 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.
|
- 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`.
|
||||||
|
- 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`.
|
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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 `* 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`.
|
- 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 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`.
|
- 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`.
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
- `[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`.
|
- 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`.
|
- 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`.
|
- `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`.
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -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 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 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`.
|
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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 `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.
|
- 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.
|
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 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 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`) — `ui-test-codeunit-naming`.
|
- 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 — `given-blocks-must-cover-full-precondition-chain`.
|
- 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.
|
- 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`.
|
- 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`.
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
- **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.
|
- 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`).
|
- 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`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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 `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()`.
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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.
|
- 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.
|
- 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`).
|
- 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.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue