From 570e776a37ab2cb55bc2029cfdfa433e4b3cb438 Mon Sep 17 00:00:00 2001 From: demiliani Date: Wed, 23 Sep 2026 17:09:24 +0200 Subject: [PATCH] Add data handling and test isolation guidance - add SCM guidance for deriving base quantities through line unit-of-measure validation`n- add security guidance for parameterizing SetFilter with external text`n- add test isolation guidance for resetting per-test state before initialization guards`n- add web-service guidance for JSON null handling and invariant standard format 9`n- route and cover all five rules with paired evaluation fixtures --- evaluation/review-fixtures.json | 18 +++++- ...es-through-the-line-unit-of-measure.bad.al | 26 ++++++++ ...s-through-the-line-unit-of-measure.good.al | 25 ++++++++ ...tities-through-the-line-unit-of-measure.md | 37 +++++++++++ ...tenate-external-text-into-setfilter.bad.al | 11 ++++ ...enate-external-text-into-setfilter.good.al | 25 ++++++++ ...oncatenate-external-text-into-setfilter.md | 36 +++++++++++ ...tate-before-the-isinitialized-guard.bad.al | 57 +++++++++++++++++ ...ate-before-the-isinitialized-guard.good.al | 62 +++++++++++++++++++ ...st-state-before-the-isinitialized-guard.md | 41 ++++++++++++ ...-json-null-before-converting-values.bad.al | 11 ++++ ...json-null-before-converting-values.good.al | 28 +++++++++ ...heck-json-null-before-converting-values.md | 35 +++++++++++ ...anged-values-with-standard-format-9.bad.al | 27 ++++++++ ...nged-values-with-standard-format-9.good.al | 27 ++++++++ ...exchanged-values-with-standard-format-9.md | 39 ++++++++++++ microsoft/skills/review/al-scm-review.md | 1 + microsoft/skills/review/al-security-review.md | 2 +- microsoft/skills/review/al-testing-review.md | 3 +- .../skills/review/al-web-services-review.md | 3 +- 20 files changed, 508 insertions(+), 6 deletions(-) create mode 100644 microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al create mode 100644 microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al create mode 100644 microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md create mode 100644 microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.bad.al create mode 100644 microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.good.al create mode 100644 microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.md create mode 100644 microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al create mode 100644 microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al create mode 100644 microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md create mode 100644 microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al create mode 100644 microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al create mode 100644 microsoft/knowledge/web-services/check-json-null-before-converting-values.md create mode 100644 microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al create mode 100644 microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al create mode 100644 microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index cefd406..90fc7d1 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -76,7 +76,14 @@ "reconcile-warehouse-adjustments-with-the-item-ledger", "post-transfers-through-shipment-and-receipt-codeunits", "use-date-aware-availability-for-promising", - "carry-out-requisition-actions-through-the-standard-workflow" + "carry-out-requisition-actions-through-the-standard-workflow", + "derive-base-quantities-through-the-line-unit-of-measure" + ] + }, + "security": { + "articles": [ + "al-has-no-built-in-htmlencode", + "do-not-concatenate-external-text-into-setfilter" ] }, "style": { @@ -89,7 +96,10 @@ "article": "telemetry-event-id-stable-unique" }, "testing": { - "article": "ui-handlers-in-tests" + "articles": [ + "ui-handlers-in-tests", + "reset-per-test-state-before-the-isinitialized-guard" + ] }, "upgrade": { "article": "initvalue-does-not-update-existing-rows", @@ -99,7 +109,9 @@ "articles": [ "expose-systemid-as-the-api-key", "handle-httpclient-platform-failure-before-response-access", - "check-http-status-before-consuming-response-body" + "check-http-status-before-consuming-response-body", + "check-json-null-before-converting-values", + "format-exchanged-values-with-standard-format-9" ] } } diff --git a/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al new file mode 100644 index 0000000..744ad53 --- /dev/null +++ b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al @@ -0,0 +1,26 @@ +codeunit 50181 "Scanner Receipt Import Bad" +{ + procedure PostScannedReceipt(ItemNo: Code[20]; LocationCode: Code[10]; UnitOfMeasureCode: Code[10]; ScannedQuantity: Decimal; DocumentNo: Code[20]) + var + ItemJournalLine: Record "Item Journal Line"; + ItemJnlPostLine: Codeunit "Item Jnl.-Post Line"; + begin + if ScannedQuantity <= 0 then + Error(PositiveQuantityErr); + + ItemJournalLine.Init(); + ItemJournalLine.Validate("Posting Date", WorkDate()); + ItemJournalLine.Validate("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt."); + ItemJournalLine.Validate("Document No.", DocumentNo); + ItemJournalLine.Validate("Item No.", ItemNo); + ItemJournalLine.Validate("Location Code", LocationCode); + ItemJournalLine."Unit of Measure Code" := UnitOfMeasureCode; + ItemJournalLine.Quantity := ScannedQuantity; + ItemJournalLine."Quantity (Base)" := ScannedQuantity; + + ItemJnlPostLine.RunWithCheck(ItemJournalLine); + end; + + var + PositiveQuantityErr: Label 'The scanned quantity must be greater than zero.'; +} diff --git a/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al new file mode 100644 index 0000000..6280b59 --- /dev/null +++ b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al @@ -0,0 +1,25 @@ +codeunit 50180 "Scanner Receipt Import Good" +{ + procedure PostScannedReceipt(ItemNo: Code[20]; LocationCode: Code[10]; UnitOfMeasureCode: Code[10]; ScannedQuantity: Decimal; DocumentNo: Code[20]) + var + ItemJournalLine: Record "Item Journal Line"; + ItemJnlPostLine: Codeunit "Item Jnl.-Post Line"; + begin + if ScannedQuantity <= 0 then + Error(PositiveQuantityErr); + + ItemJournalLine.Init(); + ItemJournalLine.Validate("Posting Date", WorkDate()); + ItemJournalLine.Validate("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt."); + ItemJournalLine.Validate("Document No.", DocumentNo); + ItemJournalLine.Validate("Item No.", ItemNo); + ItemJournalLine.Validate("Location Code", LocationCode); + ItemJournalLine.Validate("Unit of Measure Code", UnitOfMeasureCode); + ItemJournalLine.Validate(Quantity, ScannedQuantity); + + ItemJnlPostLine.RunWithCheck(ItemJournalLine); + end; + + var + PositiveQuantityErr: Label 'The scanned quantity must be greater than zero.'; +} diff --git a/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md new file mode 100644 index 0000000..3f3d3a0 --- /dev/null +++ b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md @@ -0,0 +1,37 @@ +--- +bc-version: [all] +domain: scm +keywords: [quantity-base, qty-per-unit-of-measure, unit-of-measure-code, unit-of-measure-management, calcbaseqty, getqtyperunitofmeasure, qty-rounding-precision, item-journal-line] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Derive base quantities through the line's unit of measure + +## Description + +Inventory, item ledger entries, reservations, item tracking, and warehouse quantities are measured in the item's base unit of measure. Document and journal lines hold `Quantity` in the line's `"Unit of Measure Code"`, together with `"Qty. per Unit of Measure"` and base-unit fields such as `"Quantity (Base)"`. Ten boxes of twelve pieces are 120 base units, not 10. When a line's `Quantity` is validated, the table derives the base quantity through its `CalcBaseQty` procedure. That procedure calls `"Unit of Measure Management".CalcBaseQty` with the line's quantity rounding precision and raises an error when rounding would turn a non-zero quantity into a zero base quantity. Code that bypasses this conversion creates a line whose quantity and base quantity disagree, or makes a stock decision in the wrong unit. + +## Best Practice + +On a document or journal line, validate `"Unit of Measure Code"` before `Quantity`, and validate both. Validating the unit of measure sets `"Qty. per Unit of Measure"` from the item unit of measure; validating the quantity then fills the base fields with the correct rounding. Compare line quantities with inventory or availability in base units, for example `"Quantity (Base)"` or `"Outstanding Qty. (Base)"`. + +Outside a line, get the factor with `"Unit of Measure Management".GetQtyPerUnitOfMeasure(Item, UnitOfMeasureCode)` and convert with its `CalcBaseQty` or `CalcQtyFromBase` procedures instead of multiplying by hand. Pass the item unit's quantity rounding precision where the available overload accepts it. + +Reading these fields for display, reporting, or a temporary buffer that is never posted is not a conversion defect. Code that proves the line uses the base unit of measure (`"Qty. per Unit of Measure"` equal to 1) is also correct, but don't assume this from the item alone, because a line can use another unit. + +See sample: [`derive-base-quantities-through-the-line-unit-of-measure.good.al`](derive-base-quantities-through-the-line-unit-of-measure.good.al). + +## Anti Pattern + +Assigning `Quantity` directly on an item journal, sales, purchase, or transfer line and then inserting, modifying, or posting it. Also assigning a base field such as `"Quantity (Base)" := Quantity`, multiplying by a hard-coded or separately looked-up factor without the line's rounding, or comparing a line's `Quantity` with `Item.Inventory` or another base-unit value. Detection signal: a direct `:=` to `Quantity`, `"Qty. per Unit of Measure"`, or a `(Base)` quantity field on a persisted or posted line, or a comparison between a non-base line quantity and an inventory quantity. + +See sample: [`derive-base-quantities-through-the-line-unit-of-measure.bad.al`](derive-base-quantities-through-the-line-unit-of-measure.bad.al). + +## References + +- [Set up units of measure, including quantity rounding precision](https://learn.microsoft.com/en-us/dynamics365/business-central/inventory-how-setup-units-of-measure) +- [BCApps: Unit of Measure Management conversions](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Foundation/UOM/UnitofMeasureManagement.Codeunit.al) +- [BCApps: Item Journal Line quantity validation and CalcBaseQty](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Inventory/Journal/ItemJournalLine.Table.al) +- [BCApps: Sales Line quantity validation and CalcBaseQty](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al) diff --git a/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.bad.al b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.bad.al new file mode 100644 index 0000000..ef67908 --- /dev/null +++ b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.bad.al @@ -0,0 +1,11 @@ +codeunit 50161 "Cancel External Quotes Bad" +{ + procedure CancelQuote(ExternalDocumentNo: Text) + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote); + SalesHeader.SetFilter("External Document No.", ExternalDocumentNo); + SalesHeader.DeleteAll(true); + end; +} diff --git a/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.good.al b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.good.al new file mode 100644 index 0000000..329bc09 --- /dev/null +++ b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.good.al @@ -0,0 +1,25 @@ +codeunit 50160 "Cancel External Quotes Good" +{ + procedure CancelQuote(ExternalDocumentNo: Code[35]) + var + SalesHeader: Record "Sales Header"; + begin + if ExternalDocumentNo = '' then + Error(MissingExternalDocumentNoErr); + + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote); + SalesHeader.SetRange("External Document No.", ExternalDocumentNo); + SalesHeader.DeleteAll(true); + end; + + procedure CancelQuotes(ExternalDocumentNos: List of [Code[35]]) + var + ExternalDocumentNo: Code[35]; + begin + foreach ExternalDocumentNo in ExternalDocumentNos do + CancelQuote(ExternalDocumentNo); + end; + + var + MissingExternalDocumentNoErr: Label 'The external document number is missing.'; +} diff --git a/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.md b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.md new file mode 100644 index 0000000..df6a87f --- /dev/null +++ b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: security +keywords: [setfilter, setrange, filter-expression, filter-injection, external-input, wildcard, deleteall, modifyall] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not concatenate external text into a SetFilter expression + +## Description + +The `String` argument of `SetFilter` is a filter expression, not a value. Characters such as `..`, `|`, `&`, `<`, `>`, `=`, `*`, `?`, `@`, parentheses, and single quotes are operators. Text from a user, request page, API payload, file, or another system that is concatenated into that expression can therefore change which records match: `*` matches every value, `A|B` widens an exact lookup to two values, `10000..` becomes a range, and `J & V` becomes a conjunction or an invalid filter. `SetFilter` with an empty expression applies no filter at all. When the filtered record is then modified, deleted, exported, or used for a permission-relevant decision, the procedure acts on records the caller never identified. + +## Best Practice + +Treat an externally supplied identifier as a value. Use `SetRange(Field, Value)` for equality and `SetRange(Field, FromValue, ToValue)` for a typed range; neither parses operators. Reject an empty identifier explicitly when "no value" must not mean "every record". For several external values, apply `SetRange` once per value instead of joining them with `|`. + +Use `SetFilter` when an operator is part of the procedure's own contract. Keep the operator in a constant expression and pass operands through replacement fields (`%1`, `%2`) of the field's data type, such as a `Date` or `Decimal` operand for `'>=%1'`. Do not rely on wrapping text in single quotes to neutralize it: according to the filter syntax, quotes protect `&`, `(`, `)`, `=`, and `|`, but `*` still acts as a wildcard inside `'J & V*'`. + +Text that the user deliberately entered as a filter is supposed to be parsed. Do not report a request-page or `FilterPageBuilder` filter, a `GetFilters`/`GetView` round trip, a FlowFilter, or a field whose documented purpose is to hold a filter expression. Constant filter strings and operands produced by the extension's own code are also not external input. + +See sample: [`do-not-concatenate-external-text-into-setfilter.good.al`](do-not-concatenate-external-text-into-setfilter.good.al). + +## Anti Pattern + +`Rec.SetFilter(Field, ExternalText)` or `Rec.SetFilter(Field, Prefix + ExternalText + Suffix)` where the text is meant to identify one record or a known list of records, with no validation that restricts it to a literal value. The finding is strongest when the resulting set is written by `Modify`, `ModifyAll`, `Delete`, or `DeleteAll`, or is returned to an external caller. Detection signal: a non-constant expression, concatenation, or `StrSubstNo` result passed as the `String` argument of `SetFilter`, where the operand comes from a parameter, page field, JSON/XML value, file line, or HTTP request. + +See sample: [`do-not-concatenate-external-text-into-setfilter.bad.al`](do-not-concatenate-external-text-into-setfilter.bad.al). + +## References + +- [Record.SetFilter method, including the empty-filter remark](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-setfilter-method) +- [Filtering with SetRange and SetFilter](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-setcurrentkey-setrange-setfilter-getrangemin-and-getrangemax-methods) +- [Filter criteria, operators, and values that contain symbols](https://learn.microsoft.com/en-us/dynamics365/business-central/ui-enter-criteria-filters#filter-criteria-and-operators) diff --git a/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al new file mode 100644 index 0000000..afebd56 --- /dev/null +++ b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al @@ -0,0 +1,57 @@ +codeunit 50411 "Test Sales Setup Initialize Bad" +{ + Subtype = Test; + + [Test] + [HandlerFunctions('CustomerCardHandler')] + procedure CustomerCardOpensForSelectedCustomer() + var + Customer: Record Customer; + begin + Initialize(); + LibrarySales.CreateCustomer(Customer); + LibraryVariableStorage.Enqueue(Customer."No."); + + Page.RunModal(Page::"Customer Card", Customer); + + LibraryVariableStorage.AssertEmpty(); + end; + + [Test] + procedure StockoutWarningCanBeDisabled() + var + SalesSetup: Record "Sales & Receivables Setup"; + begin + Initialize(); + + LibrarySales.SetStockoutWarning(false); + + SalesSetup.Get(); + Assert.IsFalse(SalesSetup."Stockout Warning", 'The stockout warning was not disabled.'); + end; + + local procedure Initialize() + begin + if IsInitialized then + exit; + + LibraryVariableStorage.Clear(); + LibrarySetupStorage.Restore(); + LibrarySales.SetStockoutWarning(true); + IsInitialized := true; + LibrarySetupStorage.SaveSalesSetup(); + end; + + [ModalPageHandler] + procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card") + begin + Assert.AreEqual(LibraryVariableStorage.DequeueText(), CustomerCard."No.".Value(), 'The customer card opened for the wrong customer.'); + end; + + var + Assert: Codeunit Assert; + LibrarySales: Codeunit "Library - Sales"; + LibrarySetupStorage: Codeunit "Library - Setup Storage"; + LibraryVariableStorage: Codeunit "Library - Variable Storage"; + IsInitialized: Boolean; +} diff --git a/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al new file mode 100644 index 0000000..0d1d7c7 --- /dev/null +++ b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al @@ -0,0 +1,62 @@ +codeunit 50410 "Test Sales Setup Initialize Good" +{ + Subtype = Test; + + [Test] + [HandlerFunctions('CustomerCardHandler')] + procedure CustomerCardOpensForSelectedCustomer() + var + Customer: Record Customer; + begin + Initialize(); + LibrarySales.CreateCustomer(Customer); + LibraryVariableStorage.Enqueue(Customer."No."); + + Page.RunModal(Page::"Customer Card", Customer); + + LibraryVariableStorage.AssertEmpty(); + end; + + [Test] + procedure StockoutWarningCanBeDisabled() + var + SalesSetup: Record "Sales & Receivables Setup"; + begin + Initialize(); + + LibrarySales.SetStockoutWarning(false); + + SalesSetup.Get(); + Assert.IsFalse(SalesSetup."Stockout Warning", 'The stockout warning was not disabled.'); + end; + + local procedure Initialize() + begin + LibraryTestInitialize.OnTestInitialize(Codeunit::"Test Sales Setup Initialize Good"); + LibraryVariableStorage.Clear(); + LibrarySetupStorage.Restore(); + + if IsInitialized then + exit; + LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Test Sales Setup Initialize Good"); + + LibrarySales.SetStockoutWarning(true); + IsInitialized := true; + LibrarySetupStorage.SaveSalesSetup(); + LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Test Sales Setup Initialize Good"); + end; + + [ModalPageHandler] + procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card") + begin + Assert.AreEqual(LibraryVariableStorage.DequeueText(), CustomerCard."No.".Value(), 'The customer card opened for the wrong customer.'); + end; + + var + Assert: Codeunit Assert; + LibrarySales: Codeunit "Library - Sales"; + LibrarySetupStorage: Codeunit "Library - Setup Storage"; + LibraryTestInitialize: Codeunit "Library - Test Initialize"; + LibraryVariableStorage: Codeunit "Library - Variable Storage"; + IsInitialized: Boolean; +} diff --git a/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md new file mode 100644 index 0000000..2d2c417 --- /dev/null +++ b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md @@ -0,0 +1,41 @@ +--- +bc-version: [all] +domain: testing +keywords: [initialize, isinitialized, library-test-initialize, ontestinitialize, library-variable-storage, library-setup-storage, test-fixture, test-codeunit] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reset per-test state before the IsInitialized guard + +## Description + +Standard Business Central test codeunits call a local `Initialize` procedure at the start of every test method. Global variables in a test codeunit keep their values between the codeunit's test methods, so a Boolean such as `IsInitialized` lets `Initialize` run expensive shared setup only once. The procedure therefore has two parts with different lifetimes: work that must run before **every** test, and one-time setup behind the guard. If per-test reset is placed after the guard, it runs only for the first test. Values left in `Library - Variable Storage` by a failed test, or setup records a test changed, then leak into later tests, which pass or fail depending on execution order. + +## Best Practice + +Call `Initialize()` as the first statement of every test method. Inside it, keep this order, which the Base Application tests follow: + +1. Per-test work, before the guard: raise `"Library - Test Initialize".OnTestInitialize`, call `LibraryVariableStorage.Clear()`, and call `LibrarySetupStorage.Restore()` when setup tables were saved. +2. `if IsInitialized then exit;` +3. One-time work: raise `OnBeforeTestSuiteInitialize`, create the shared fixture and setup values, set `IsInitialized := true`, save the setup tables that tests may change (for example `LibrarySetupStorage.SaveSalesSetup()`), and raise `OnAfterTestSuiteInitialize`. + +Create data that a single test changes inside that test, not in the shared fixture. Base Application suites also commit after the one-time setup so the shared fixture survives each test's transaction; whether that commit is valid depends on the test transaction model and runner isolation, see [`transactionmodel-attribute-governs-test-transactions.md`](transactionmodel-attribute-governs-test-transactions.md) and [`testisolation-belongs-on-the-test-runner.md`](testisolation-belongs-on-the-test-runner.md). + +A test codeunit with no shared setup and no queued values doesn't need an `Initialize` procedure. Don't report its absence on its own. + +See sample: [`reset-per-test-state-before-the-isinitialized-guard.good.al`](reset-per-test-state-before-the-isinitialized-guard.good.al). + +## Anti Pattern + +`if IsInitialized then exit;` as the first statement of `Initialize`, followed by `LibraryVariableStorage.Clear()`, `LibrarySetupStorage.Restore()`, or other reset calls that are then skipped for every test after the first. A related defect is a test method in a codeunit that uses the pattern but doesn't call `Initialize()`, so it runs with whatever state the previous test left. Detection signal: in a `Subtype = Test` codeunit, a reset call placed after the `IsInitialized` exit, or a `[Test]` procedure that uses shared globals or queued values without first calling `Initialize()`. + +See sample: [`reset-per-test-state-before-the-isinitialized-guard.bad.al`](reset-per-test-state-before-the-isinitialized-guard.bad.al). + +## References + +- [BCApps: `Initialize` in the ERM Sales Document tests](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/Tests/ERM-Sales/ERMSalesDocument.Codeunit.al) +- [BCApps: Library - Test Initialize events](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/Tests/ApplicationTestLibrary/LibraryTestInitialize.Codeunit.al) +- [BCApps: Library - Setup Storage](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/Tests/ApplicationTestLibrary/LibrarySetupStorage.Codeunit.al) +- [Testing the application](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-testing-application) diff --git a/microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al b/microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al new file mode 100644 index 0000000..d52ebf9 --- /dev/null +++ b/microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al @@ -0,0 +1,11 @@ +codeunit 50173 "Contact Payload Reader Bad" +{ + procedure ApplyPayload(var Contact: Record Contact; Payload: JsonObject) + var + Token: JsonToken; + begin + if Payload.Get('email', Token) then + Contact.Validate("E-Mail", CopyStr(Token.AsValue().AsText(), 1, MaxStrLen(Contact."E-Mail"))); + Contact.Modify(true); + end; +} diff --git a/microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al b/microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al new file mode 100644 index 0000000..91f83d5 --- /dev/null +++ b/microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al @@ -0,0 +1,28 @@ +codeunit 50172 "Contact Payload Reader Good" +{ + procedure ApplyPayload(var Contact: Record Contact; Payload: JsonObject) + var + EmailAddress: Text; + begin + if TryGetText(Payload, 'email', EmailAddress) then + Contact.Validate("E-Mail", CopyStr(EmailAddress, 1, MaxStrLen(Contact."E-Mail"))); + Contact.Modify(true); + end; + + local procedure TryGetText(Payload: JsonObject; PropertyName: Text; var Value: Text): Boolean + var + Token: JsonToken; + begin + if not Payload.Get(PropertyName, Token) then + exit(false); + if not Token.IsValue() then + Error(NotAValueErr, PropertyName); + if Token.AsValue().IsNull() then + exit(false); + Value := Token.AsValue().AsText(); + exit(true); + end; + + var + NotAValueErr: Label 'The property %1 must contain a single value.', Comment = '%1 = JSON property name'; +} diff --git a/microsoft/knowledge/web-services/check-json-null-before-converting-values.md b/microsoft/knowledge/web-services/check-json-null-before-converting-values.md new file mode 100644 index 0000000..b5a560e --- /dev/null +++ b/microsoft/knowledge/web-services/check-json-null-before-converting-values.md @@ -0,0 +1,35 @@ +--- +bc-version: [all] +domain: web-services +keywords: [jsonobject, jsontoken, jsonvalue, isnull, asvalue, astext, asdecimal, optional-property, json-null, payload-parsing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Check for JSON null before converting a value + +## Description + +An optional property in a JSON payload can be missing or present with the value `null`, and the two cases behave differently in AL. When the Boolean result is captured, `JsonObject.Get` returns `false` for a missing key but `true` for `"email": null`, because the key exists. The conversion methods on `JsonValue` (`AsText`, `AsCode`, `AsDecimal`, `AsInteger`, `AsDate`, `AsBoolean`, and the others) fail with a runtime error when the value is `NULL` or `UNDEFINED`. Code that guards only with `Get` therefore passes its tests with the property omitted and fails in production when the sender serializes an empty field as `null`, which many services do by default. + +## Best Practice + +For every property that the contract allows to be optional or nullable, check three things before converting: that `Get` (or `SelectToken`) returned `true`, that the token `IsValue()` rather than an object or array, and that `AsValue().IsNull()` is `false`. Put this in one small helper per target type and decide explicitly what a missing or null property means: a default, leaving the field unchanged, or a validation error that names the property. + +Required properties can still fail fast, but with an error that states the missing or null property instead of a generic conversion error. Keep a numeric or date conversion strict when the contract says the value must be a number or a date; `IsNull` covers only `null`, not a value of the wrong type. + +See sample: [`check-json-null-before-converting-values.good.al`](check-json-null-before-converting-values.good.al). + +## Anti Pattern + +`if Json.Get('email', Token) then Email := Token.AsValue().AsText();` or an unguarded `Token.AsValue().AsDecimal()` on a property that the external contract allows to be `null`. The `Get` check makes the code look defensive, but it doesn't handle a present `null`. Detection signal: an `As()` call on a `JsonValue` obtained from an external payload with no preceding `IsNull()` check on the same token, where the property isn't documented as always non-null. + +See sample: [`check-json-null-before-converting-values.bad.al`](check-json-null-before-converting-values.bad.al). + +## References + +- [JsonObject.Get method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonobject/jsonobject-get-method) +- [JsonValue.AsText method: fails on NULL or UNDEFINED](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonvalue/jsonvalue-astext-method) +- [JsonValue.IsNull method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonvalue/jsonvalue-isnull-method) +- [JsonToken data type](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsontoken/jsontoken-data-type) diff --git a/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al new file mode 100644 index 0000000..3e36c87 --- /dev/null +++ b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al @@ -0,0 +1,27 @@ +codeunit 50171 "Exchange Rate Export Bad" +{ + procedure SendRate(CurrencyCode: Code[10]; StartingDate: Date; ExchangeRate: Decimal) + var + Client: HttpClient; + Response: HttpResponseMessage; + RequestUrl: Text; + begin + RequestUrl := StrSubstNo(RateUrlTok, CurrencyCode, Format(StartingDate), Format(ExchangeRate)); + if not Client.Get(RequestUrl, Response) then + Error(RequestFailedErr); + if not Response.IsSuccessStatusCode() then + Error(RateRejectedErr, Response.HttpStatusCode()); + end; + + procedure ReadRate(RateText: Text) ExchangeRate: Decimal + begin + if not Evaluate(ExchangeRate, RateText) then + Error(InvalidRateErr, RateText); + end; + + var + RateUrlTok: Label 'https://rates.example.com/rates?currency=%1&date=%2&rate=%3', Locked = true; + RequestFailedErr: Label 'The exchange rate service could not be reached.'; + RateRejectedErr: Label 'The exchange rate service rejected the rate. Status code: %1.', Comment = '%1 = HTTP status code'; + InvalidRateErr: Label 'The exchange rate %1 is not a valid decimal number.', Comment = '%1 = received value'; +} diff --git a/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al new file mode 100644 index 0000000..21d6a22 --- /dev/null +++ b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al @@ -0,0 +1,27 @@ +codeunit 50170 "Exchange Rate Export Good" +{ + procedure SendRate(CurrencyCode: Code[10]; StartingDate: Date; ExchangeRate: Decimal) + var + Client: HttpClient; + Response: HttpResponseMessage; + RequestUrl: Text; + begin + RequestUrl := StrSubstNo(RateUrlTok, CurrencyCode, Format(StartingDate, 0, 9), Format(ExchangeRate, 0, 9)); + if not Client.Get(RequestUrl, Response) then + Error(RequestFailedErr); + if not Response.IsSuccessStatusCode() then + Error(RateRejectedErr, Response.HttpStatusCode()); + end; + + procedure ReadRate(RateText: Text) ExchangeRate: Decimal + begin + if not Evaluate(ExchangeRate, RateText, 9) then + Error(InvalidRateErr, RateText); + end; + + var + RateUrlTok: Label 'https://rates.example.com/rates?currency=%1&date=%2&rate=%3', Locked = true; + RequestFailedErr: Label 'The exchange rate service could not be reached.'; + RateRejectedErr: Label 'The exchange rate service rejected the rate. Status code: %1.', Comment = '%1 = HTTP status code'; + InvalidRateErr: Label 'The exchange rate %1 is not a valid decimal number.', Comment = '%1 = received value'; +} diff --git a/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md new file mode 100644 index 0000000..adc1dae --- /dev/null +++ b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md @@ -0,0 +1,39 @@ +--- +bc-version: [all] +domain: web-services +keywords: [format, evaluate, standard-format-9, xml-format, locale, regional-settings, decimal-separator, data-exchange, integration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Format exchanged values with standard format 9 + +## Description + +`Format(Value)` uses standard format 0, the display format, and follows the current user's regional settings. The same decimal renders as `-76.543,21` for a European region and `-76,543.21` for English (US); the same date renders as `05-04-21` or `04/05/21`. Text built this way and sent outside Business Central (an HTTP query string or body, an XML or CSV file, a signature or hash input, or an external key) changes with the user or job queue session that produces it. The receiver can reject it or, worse, misread it. `Evaluate` without a format number has the same dependency when it parses machine-generated text. + +## Best Practice + +Use `Format(Value, 0, 9)` for machine-readable text. Standard format 9 is the XML format and doesn't depend on the region: `-76543.21` for a decimal, `2021-04-05` for a date, `04:35:55.553` for a time, `true`/`false` for a Boolean, and a UTC `DateTime` such as `2021-04-05T03:35:55.553Z`. Parse such text with `Evaluate(Variable, Text, 9)`. + +Prefer typed APIs when they exist. `JsonObject.Add` and `JsonValue.SetValue` with a `Decimal`, `Date`, or `Boolean` argument write a JSON value without going through display text. An XMLport handles this with `FormatEvaluate = Xml`. + +For `Enum` and `Option` values, format 9 produces the ordinal number, not the name. When the external contract exchanges names, map them explicitly; see [`api-enum-values-are-a-contract-by-name-not-ordinal.md`](api-enum-values-are-a-contract-by-name-not-ordinal.md). + +Text shown to a person (messages, captions, report columns, notifications) should keep the regional display format. `Code`, `Text`, and `Guid` values don't need format 9 because their standard formats don't vary by region. + +See sample: [`format-exchanged-values-with-standard-format-9.good.al`](format-exchanged-values-with-standard-format-9.good.al). + +## Anti Pattern + +`Format(Amount)`, `Format(PostingDate)`, or `Format(SomeDateTime)` concatenated into a URL, request body, XML or CSV line, file name, or hash input. Passing the `Decimal` or `Date` itself to `StrSubstNo` for such text has the same effect, because `StrSubstNo` formats it with the display format. Also `Evaluate(DecimalOrDateVariable, ExternalText)` without format number 9 on text received from another system. The code usually works for the developer's own region and fails for users or job queue sessions in another one. Detection signal: `Format` with one argument, or with a format number other than 9, applied to a `Decimal`, `Date`, `Time`, `DateTime`, or `Boolean` on a path that writes to an `HttpContent`, `HttpRequestMessage`, `OutStream`, `XmlDocument`, or file. + +See sample: [`format-exchanged-values-with-standard-format-9.bad.al`](format-exchanged-values-with-standard-format-9.bad.al). + +## References + +- [Formatting values, dates, and time: standard formats by region and format 9](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-format-property) +- [System.Format method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/system/system-format-joker-integer-integer-method) +- [System.Evaluate method and format number 9](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/system/system-evaluate-method) +- [FormatEvaluate property for XMLports](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-formatevaluate-property) diff --git a/microsoft/skills/review/al-scm-review.md b/microsoft/skills/review/al-scm-review.md index a434529..8e22e2c 100644 --- a/microsoft/skills/review/al-scm-review.md +++ b/microsoft/skills/review/al-scm-review.md @@ -77,6 +77,7 @@ paths; they select articles, not findings. Facts and exceptions stay in articles | Registered warehouse quantity/physical-adjustment synchronization, `"Directed Put-away and Pick"`, `"Adjustment Bin Code"`, `"Warehouse Adjustment"`, or `"Calculate Whse. Adjustment"` and the resulting item-journal posting | `reconcile-warehouse-adjustments-with-the-item-ledger` | | `"Transfer Header"`/`"Transfer Line"` shipment/receipt completion, transfer posting publishers, in-transit/document-link changes, or item-journal posting presented as transfer-order completion | `post-transfers-through-shipment-and-receipt-codeunits` | | `Inventory`, `CalcQtyAvailableToPromise`, or stock sums used in a dated supply/demand promise, including changed location/variant/date filters and source-demand context | `use-date-aware-availability-for-promising` | +| Direct assignment to `Quantity`, `"Unit of Measure Code"`, `"Qty. per Unit of Measure"`, or a `(Base)` quantity field on a persisted or posted item journal, sales, purchase, or transfer line, or a line quantity compared with a base-unit inventory value | `derive-base-quantities-through-the-line-unit-of-measure` | | `"Requisition Line"` action-message execution, accepted planning suggestions, `"Req. Wksh.-Make Order"`, `CarryOutBatchAction`, or linked supply creation/change plus requisition-line deletion | `carry-out-requisition-actions-through-the-standard-workflow` | Route clean supported calls through the same cues, not just suspicious writes. diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 00e8d10..e18d3fc 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially permission sets, codeunits handling authentication or authorization, objects touching `Isolated Storage`, `OAuth2` flows, web service endpoints, API pages, event publishers, and RecordRef helpers. - The changed procedures and triggers, weighted toward those that call `HttpClient`, validate or compose URLs, write to telemetry, read or write secrets, unwrap SecretText, manipulate record-level security, expose var Boolean guard parameters, or bypass the permission model (for example, `RecordRef.Open`, `Record.WritePermission`, direct table access from a non-owning app). -- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `TransferFields`, `Codeunit.Run`, `Access = Internal`, `internalsVisibleTo`, `Open`, `IntegrationEvent`, `SkipValidation`, `HasAccess`, `Permission`, `UserSecurityId`, `Commit`). +- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `TransferFields`, `Codeunit.Run`, `Access = Internal`, `internalsVisibleTo`, `Open`, `IntegrationEvent`, `SkipValidation`, `HasAccess`, `Permission`, `UserSecurityId`, `Commit`, `SetFilter`). 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. diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index c96ac83..bff7899 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially codeunits with `Subtype = Test`, test runner codeunits with `TestIsolation`, test libraries, and codeunits that define UI handlers. - The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[TestPermissions(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls. -- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`). +- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Initialize`, `IsInitialized`, `OnTestInitialize`, `LibrarySetupStorage`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`). 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 testing-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -49,6 +49,7 @@ The following targeted checks cover every current `testing` article. Treat each - 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`. +- A test codeunit's `Initialize` procedure exits on `IsInitialized` before per-test reset such as `LibraryVariableStorage.Clear`, `LibrarySetupStorage.Restore`, or `LibraryTestInitialize.OnTestInitialize`, or a `[Test]` method in a codeunit using that pattern does not call `Initialize()` first — `reset-per-test-state-before-the-isinitialized-guard`. - `asserterror` is added or changed without a following `Assert.ExpectedError`, `Assert.ExpectedErrorCode`, or a purpose-built assertion such as `ExpectedTestFieldError` — `asserterror-needs-expectederror-and-code`. - A test path raises UI and `[HandlerFunctions(...)]` does not match the invoked handlers, or the test has no meaningful evidence of the UI result (for example, it treats a Boolean set before the action as proof of success) — `ui-handlers-in-tests`. A capture/reset/assert-after-`RunModal` pattern is valid. Enqueue/dequeue and `AssertEmpty` are required only when order, count, text, replies, or a scripted sequence is part of the contract. Only nonoptional handlers have to execute: a listed handler declared `[SendNotificationHandler(true)]` or `[RecallNotificationHandler(true)]` is optional by design, so do not treat it as unmatched when the run never raises the notification. diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index 8d4c7ff..5d20389 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -40,8 +40,9 @@ 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. - Outbound HTTP integration code that constructs or sends requests, captures a client method's optional Boolean result, checks an `HttpResponseMessage`, or reads and parses response content. +- Integration code that converts values to or from exchanged text: `Format` or `Evaluate` on a request URL, body, or file line, and `JsonToken`/`JsonValue` conversions of payload properties. - 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. -- 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`, `HttpClient`, `HttpRequestMessage`, `HttpResponseMessage`, `Get`, `Post`, `Put`, `Delete`, `Send`, `IsSuccessStatusCode`, `HttpStatusCode`, `Content`, `ReadAs`, `JsonObject`, `XmlDocument`). +- 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`, `HttpClient`, `HttpRequestMessage`, `HttpResponseMessage`, `Get`, `Post`, `Put`, `Delete`, `Send`, `IsSuccessStatusCode`, `HttpStatusCode`, `Content`, `ReadAs`, `JsonObject`, `JsonToken`, `JsonValue`, `AsValue`, `IsNull`, `SelectToken`, `Format`, `Evaluate`, `XmlDocument`). 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.