From 8584217c7506eea7eef27a9db353d78c0cd6a9a1 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Mon, 7 Sep 2026 15:13:35 +0200 Subject: [PATCH 01/51] Clarify page field caption and tooltip inheritance (#160) * Clarify page field caption and tooltip inheritance Prevent redundant page-level properties by documenting inherited captions and BC24/runtime 13.0 table-field tooltips. Correct companion examples and version-scoped tooltip guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address tooltip quality and knowledge scope review feedback Require useful, behavior-grounded tooltip text rather than caption repetition, improve the samples, and explain why compiler feedback does not prevent redundant page captions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../caption-required-on-page-fields.bad.al | 8 +++- .../caption-required-on-page-fields.good.al | 45 ++++++++++++++++-- .../style/caption-required-on-page-fields.md | 24 +++++++--- .../tooltip-required-on-page-fields.bad.al | 12 +++-- .../tooltip-required-on-page-fields.good.al | 46 +++++++++++++++++-- .../style/tooltip-required-on-page-fields.md | 30 ++++++++---- ...age-field-inherits-source-field-tooltip.md | 14 ++++-- 7 files changed, 147 insertions(+), 32 deletions(-) diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al index fd458a4..21dccb3 100644 --- a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al +++ b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al @@ -9,16 +9,22 @@ page 50253 "Sample Caption Bad" { group(General) { - field("Customer No."; Rec."No.") + Caption = 'General'; + field(CustomerNoValue; CustomerNoValue) { ApplicationArea = All; + ToolTip = 'Specifies the customer number to look up.'; } field("Customer Name"; Rec.Name) { ApplicationArea = All; Caption = ''; + ToolTip = 'Specifies the customer name shown on sales documents.'; } } } } + + var + CustomerNoValue: Code[20]; } diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.good.al b/microsoft/knowledge/style/caption-required-on-page-fields.good.al index 0493b6e..f91ed73 100644 --- a/microsoft/knowledge/style/caption-required-on-page-fields.good.al +++ b/microsoft/knowledge/style/caption-required-on-page-fields.good.al @@ -1,7 +1,36 @@ +// BC24 / runtime 13.0 or later for table-field tooltips. +table 50252 "Sample Caption Source" +{ + Caption = 'Caption Source'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + ToolTip = 'Specifies the unique number used to distinguish this customer record from other records.'; + } + field(2; Name; Text[100]) + { + Caption = 'Name'; + ToolTip = 'Specifies the name used to identify the customer alongside the unique customer number.'; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } +} + page 50252 "Sample Caption Good" { PageType = Card; - SourceTable = Customer; + SourceTable = "Sample Caption Source"; layout { @@ -10,19 +39,25 @@ page 50252 "Sample Caption Good" group(General) { Caption = 'General'; - field("Customer No."; Rec."No.") + field("No."; Rec."No.") { ApplicationArea = All; - Caption = 'Customer No.'; - ToolTip = 'Specifies the customer number.'; } field("Customer Name"; Rec.Name) { ApplicationArea = All; Caption = 'Customer Name'; - ToolTip = 'Specifies the customer name.'; + } + field(DisplayValue; DisplayValue) + { + ApplicationArea = All; + Caption = 'Display Value'; + ToolTip = 'Specifies temporary text for this page; the text is not saved in the customer record.'; } } } } + + var + DisplayValue: Text[100]; } diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.md b/microsoft/knowledge/style/caption-required-on-page-fields.md index e3a69c3..b5d2e03 100644 --- a/microsoft/knowledge/style/caption-required-on-page-fields.md +++ b/microsoft/knowledge/style/caption-required-on-page-fields.md @@ -1,28 +1,38 @@ --- bc-version: [all] domain: style -keywords: [caption, page-field, aa0225, aa0226, codecop, captionclass] +keywords: [caption, page-field, source-field, inheritance, aa0225, aa0226, codecop, captionclass, false-positive] technologies: [al] countries: [w1] application-area: [all] --- -# Every page field needs a `Caption` (CodeCop AA0225/AA0226) +# Page fields can inherit their source table field's `Caption` ## Description -CodeCop AA0225 and AA0226 require every field control to expose a `Caption` property, separately from the field's source name. The caption is what the user sees as the column header or label; the source name is what the code uses to reference the field. Without an explicit `Caption`, AL falls back to the source field's caption — which may be wrong for the page's context — or to the field name itself in code casing, which surfaces internal naming to users and to translators. +A page field bound to a table field inherits the source field's `Caption` unless the page overrides it. An inherited caption is valid, user-facing, and translatable; omitting a page-level `Caption` does not mean the control displays an internal identifier or loses translations. CodeCop AA0225/AA0226 concern missing or empty captions, not a requirement to duplicate a caption already supplied by the source table field. -Acceptable exceptions: a field whose caption is inherited via `CaptionClass = '3,5,' + CurrencyCode` (or another CaptionClass formula) does not need a literal `Caption`; the formula provides it. API pages and test pages may omit captions because their consumers are not human users. Boolean fields whose name already reads as a sentence — `Enabled`, `Posted`, `Released` — do not need a redundant Caption that repeats the name. +Redundant page-level captions compile successfully, so compiler-error recovery does not prevent an agent from adding them. This guidance prevents that false positive rather than replacing analyzer diagnostics. + +Controls bound to variables or expressions cannot rely on table-field caption inheritance. For user-facing fields that need a label, supply a `Caption` or a `CaptionClass` that resolves to the intended caption. API pages are not human-facing UI; do not apply this UI-label guidance to their API contract names. ## Best Practice -`Caption = 'Customer No.';` paired with `ToolTip = 'Specifies …';`. Captions are short, noun-phrase, title-case for primary labels; sentence-case is allowed for descriptive labels that read as a sentence fragment. +Define the shared caption on the table field and let bound page fields inherit it. Add a page-level `Caption` only when there is no suitable inherited caption or the page genuinely needs different wording. Keep a valid `CaptionClass` rather than adding a redundant literal caption. -See sample: `caption-required-on-page-fields.good.al`. +Before reporting a missing caption, inspect the binding and source field, including dependency symbols when needed. If the source definition is unavailable, do not treat an omitted page property as proof that the caption is missing. Caption and tooltip requirements are separate: do not add a `ToolTip` just because a caption is being reviewed; see [tooltip inheritance guidance](tooltip-required-on-page-fields.md). + +See sample: `caption-required-on-page-fields.good.al`. Caption inheritance applies across BC versions; the sample uses BC24/runtime 13.0 or later to also define tooltips on its table fields. ## Anti Pattern -A field control with no `Caption` and no `CaptionClass`, or `Caption = '';`. The user sees the internal identifier as the column header and the translation pipeline has nothing to translate. +A user-facing field that needs a label but has no non-empty explicit or inherited caption and no resolving `CaptionClass` has a genuine labeling gap. This includes `Caption = '';` when no `CaptionClass` supplies the label. A variable name alone is not a translatable caption. + +The opposite review defect is flagging a bound field solely because it omits a page-level `Caption`, or inserting a copy of the table field's caption to satisfy AA0225/AA0226. That adds redundant text and prevents subsequent table-caption changes from flowing through to the page. See sample: `caption-required-on-page-fields.bad.al`. + +## References + +[Caption property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-caption-property) and [ToolTip property remarks documenting inheritance of both properties](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property). diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al index e6b356c..6f5f269 100644 --- a/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al @@ -1,23 +1,29 @@ page 50251 "Sample Tooltip Bad" { PageType = Card; - SourceTable = Customer; layout { area(Content) { group(General) { - field("No."; Rec."No.") + Caption = 'General'; + field(CustomerNoValue; CustomerNoValue) { ApplicationArea = All; + Caption = 'Customer No.'; } - field(Amount; Rec."Balance (LCY)") + field(PreviewAmount; PreviewAmount) { ApplicationArea = All; + Caption = 'Preview Amount'; ToolTip = ''; } } } } + + var + CustomerNoValue: Code[20]; + PreviewAmount: Decimal; } diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al index 1816de5..cd1fc8c 100644 --- a/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al @@ -1,24 +1,62 @@ +// BC24 / runtime 13.0 or later. +table 50250 "Sample Tooltip Source" +{ + Caption = 'Tooltip Source'; + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + ToolTip = 'Specifies the unique number used to distinguish this entry from other entries.'; + } + field(2; Amount; Decimal) + { + Caption = 'Amount'; + ToolTip = 'Specifies the monetary value recorded for this entry; changing it updates the saved entry.'; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + } +} + page 50250 "Sample Tooltip Good" { PageType = Card; - SourceTable = Customer; + SourceTable = "Sample Tooltip Source"; layout { area(Content) { group(General) { + Caption = 'General'; field("No."; Rec."No.") { ApplicationArea = All; - ToolTip = 'Specifies the number that identifies the customer.'; } - field(Amount; Rec."Balance (LCY)") + field(Amount; Rec.Amount) { ApplicationArea = All; - ToolTip = 'Shows the total balance in local currency.'; + ToolTip = 'Specifies the recorded amount to compare with the temporary preview amount.'; + } + field(PreviewAmount; PreviewAmount) + { + ApplicationArea = All; + Caption = 'Preview Amount'; + ToolTip = 'Specifies a temporary amount to compare with the recorded entry amount; this value is not saved.'; } } } } + + var + PreviewAmount: Decimal; } diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.md b/microsoft/knowledge/style/tooltip-required-on-page-fields.md index a11d4a9..bd1dd2b 100644 --- a/microsoft/knowledge/style/tooltip-required-on-page-fields.md +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.md @@ -1,30 +1,44 @@ --- bc-version: [all] domain: style -keywords: [tooltip, page-field, aa0218, codecop, accessibility, specifies] +keywords: [tooltip, page-field, source-field, inheritance, aa0218, codecop, accessibility, specifies] technologies: [al] countries: [w1] application-area: [all] --- -# Every page field needs a `ToolTip` (CodeCop AA0218) +# Page fields need an explicit or inherited `ToolTip` (CodeCop AA0218) ## Description -CodeCop AA0218 requires a non-empty `ToolTip` property on every field control on a page. The tooltip is what users see on hover and is what screen readers announce; an empty or missing tooltip removes a piece of UI affordance that is part of BC's accessibility baseline. AppSource technical validation rejects pages with missing tooltips. The companion rules AA0219 and AA0220 push the wording further — tooltips should describe what the field shows, conventionally starting with `'Specifies …'`, though `'Shows …'` and similar variants are acceptable when they clearly describe the field's purpose. +User-facing page fields need tooltip text, but it does not have to be declared on each page control. Starting with BC24 (2024 release wave 1), runtime 13.0 supports `ToolTip` on table fields, and bound page fields inherit it unless they override it. A non-empty inherited tooltip satisfies the requirement; do not interpret CodeCop AA0218 as a requirement to repeat it on the page. -Acceptable exceptions: table fields inside `Upgrade`, `Migration`, `HybridBC14`, `HybridSL`, and `HybridGP` codeunits and tables are allowed to omit the tooltip — those types are not surfaced to users. +For targets before runtime 13.0, table-field tooltip inheritance is not available, so user-facing page fields need page-level tooltips. Controls bound to variables or expressions also need page-level tooltips because they have no table field to inherit from. This is UI guidance, not a blanket requirement to add tooltips to every table field, including fields never exposed to users. -AA0218 is a compiler analyzer, but its severity is configured per app in the ruleset and is frequently downgraded to `info`/`None` or disabled entirely. PR review therefore cannot assume the compiler will surface the gap: it is the last line of defence for a missing tooltip and should flag it independently. The one case review must *not* flag is a bound field that inherits a `ToolTip` from its source table field — see `bound-page-field-inherits-source-field-tooltip`. +AA0218's severity is configured per app and may be downgraded or disabled. Review should still report a genuinely missing tooltip, but absence of a page-level declaration alone is not evidence of a gap. See [bound page-field tooltip inheritance](../ui/bound-page-field-inherits-source-field-tooltip.md). ## Best Practice -Every field control on a regular page carries `ToolTip = 'Specifies …';` (or a clear alternative phrasing). Compose the text in the form "what this value shows" rather than "what the user does with it". In review, raise a `medium`-severity finding for a field that has neither an inline nor an inherited tooltip, independently of whether AA0218 is active in the app's ruleset. +On runtime 13.0 or later, define shared tooltip text on the table field and omit duplicate page-level properties. Add a page-level `ToolTip` when no tooltip can be inherited or when the page needs different, context-specific help. Describe what the value shows, conventionally starting with "Specifies" or another clear phrasing. -See sample: `tooltip-required-on-page-fields.good.al`. +Make the text answer a question the caption does not: what the value is used for, which values or units are expected, or what changing it affects. Do not mechanically generate "Specifies the ." and consider the help complete. Use behavior established by the implementation or requirements; do not invent effects, defaults, or constraints to make a tooltip sound useful. Keep shared table-field help applicable to all pages that inherit it, and improve that shared text rather than duplicating it on each page. + +Before raising a `medium`-severity finding, check the target runtime, the control's binding, and the source field's tooltip, including dependency symbols when needed. Report a field with neither an explicit nor an inherited tooltip independently of whether AA0218 is active. If the source definition or target runtime is unavailable, do not assume a missing page property means missing tooltip text. + +See sample: `tooltip-required-on-page-fields.good.al` (BC24/runtime 13.0 or later). ## Anti Pattern -A field control with no `ToolTip` property at all, or `ToolTip = '';`. AA0218 flags both; the hover state is blank and the screen reader has nothing to announce. +A user-facing control with no page-level `ToolTip` and no non-empty source tooltip it can inherit, or a page-level `ToolTip = '';` that leaves the effective tooltip empty. + +Flagging a bound field that already inherits its tooltip, or adding the same tooltip to every page, is also incorrect: duplicate overrides add maintenance and translation work and prevent source-field tooltip changes from reaching those pages. + +Treating a non-empty tooltip that merely repeats the caption as useful help is a separate quality issue, not a missing-tooltip finding. Point out the concrete information users need rather than demanding longer wording or a page-level override for its own sake. See sample: `tooltip-required-on-page-fields.bad.al`. + +## References + +[ToolTip property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property). + +[Guidelines for tooltip text](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/user-assistance#guidelines-for-tooltip-text). diff --git a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md index 87b539b..00dc9b8 100644 --- a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md +++ b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md @@ -1,5 +1,5 @@ --- -bc-version: [all] +bc-version: [24..] domain: ui keywords: [tooltip, page-field, source-field, inheritance, aa0218, false-positive] technologies: [al] @@ -11,14 +11,20 @@ application-area: [all] ## Description -A page field bound to a table field inherits the source field's `ToolTip` at runtime: the control shows the table field's `ToolTip` even when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect — the text may be supplied by the bound source field. +Starting with BC24 (2024 release wave 1), runtime 13.0 supports `ToolTip` on table fields. A page field bound to a table field inherits the source field's `ToolTip` when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect. This inheritance is not available when targeting earlier runtimes. -The genuinely-missing case is different: a bound field whose source table field *also* carries no `ToolTip`, or an unbound control, has no text to inherit and is a real accessibility gap. The compiler analyzer AA0218 detects this mechanically, but its severity is set by each app's ruleset and is routinely downgraded or disabled — so it cannot be relied on as the only net. PR review is the last line of defence and should raise this case independently. +The genuinely-missing case is different: a control with no inline `ToolTip` also has no text to inherit when it is unbound or its source table field carries no non-empty `ToolTip`. This leaves a real user-assistance gap. The compiler analyzer AA0218 detects this mechanically, but its severity is set by each app's ruleset and may be downgraded or disabled, so review should raise the genuine gap independently. ## Best Practice -Do not raise a missing-`ToolTip` finding for a bound page field whose source table field supplies a `ToolTip`; assume the control inherits it. Do raise a `medium`-severity finding when the field has no inline `ToolTip` **and** no inherited one — that is, a bound field whose source field is also tooltip-less, or an unbound control — rather than assuming AA0218 will catch it downstream. +Check the target runtime and inspect the source field, including dependency symbols when needed. On runtime 13.0 or later, do not raise a missing-`ToolTip` finding for a bound page field whose source table field supplies a non-empty `ToolTip`, and do not add a duplicate page-level property. A page-level override is appropriate only when the page needs different help text or no tooltip can be inherited. + +Do raise a `medium`-severity finding when the field has no inline `ToolTip` **and** no inherited one, rather than assuming AA0218 will catch it downstream. If the source definition is unavailable, do not infer that its tooltip is missing. See [tooltip requirements across target versions](../style/tooltip-required-on-page-fields.md). ## Anti Pattern Two opposite failures: (1) flagging every page field that has no inline `ToolTip` as a violation, ignoring that a bound field inherits its source field's tooltip; and (2) staying silent on a field that has neither an inline nor an inherited tooltip on the assumption that the compiler's AA0218 will report it — a ruleset that downgrades or disables AA0218 then lets a genuine gap ship unflagged. + +## References + +[ToolTip property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property). From 17bb84a25e23e8384eecb8d89e22b16957079bd4 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 9 Sep 2026 16:55:35 +0200 Subject: [PATCH 02/51] Support standalone runners and complete app-folder reviews (#172) * Document standalone review runner contract Keep model selection and scheduling outside BCQuality while allowing orchestrators to run isolated review leaves concurrently with deterministic rollup semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Support complete app folder reviews Define folder-path as a current-state review scope and accept it across the standalone adapter, broad coordinator, and every AL review leaf. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add walk-up app review quick start Put the complete app-folder installation and prompt flow directly in the README so partners can discover the standalone experience without reading integration details first. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Organize conceptual guides under docs Move architecture and standalone runner documentation out of the repository root, add a documentation index, and update all inbound links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 34 +++++- docs/README.md | 8 ++ .../agent-consumption.md | 3 +- docs/standalone-runner.md | 103 ++++++++++++++++++ .../skills/review/al-appsource-review.md | 4 +- .../review/al-breaking-changes-review.md | 4 +- microsoft/skills/review/al-code-review.md | 14 +-- .../skills/review/al-data-modeling-review.md | 4 +- .../skills/review/al-error-handling-review.md | 4 +- microsoft/skills/review/al-events-review.md | 4 +- .../skills/review/al-interfaces-review.md | 4 +- .../skills/review/al-performance-review.md | 4 +- microsoft/skills/review/al-privacy-review.md | 4 +- microsoft/skills/review/al-query-review.md | 2 +- microsoft/skills/review/al-security-review.md | 4 +- microsoft/skills/review/al-style-review.md | 4 +- .../skills/review/al-telemetry-review.md | 4 +- microsoft/skills/review/al-testing-review.md | 4 +- microsoft/skills/review/al-ui-review.md | 4 +- microsoft/skills/review/al-upgrade-review.md | 4 +- .../skills/review/al-web-services-review.md | 4 +- skills/README.md | 2 +- skills/al-code-review/SKILL.md | 10 +- skills/do.md | 43 +++++++- skills/entry.md | 1 + 25 files changed, 229 insertions(+), 51 deletions(-) create mode 100644 docs/README.md rename agent-consumption.md => docs/agent-consumption.md (98%) create mode 100644 docs/standalone-runner.md diff --git a/README.md b/README.md index 43032d4..f3ea476 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,12 @@ Skills define how agents consume knowledge. They come in three flavors: ### Agent bootstrapping -An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s) to invoke. The agent then invokes each dispatched skill in turn, reading READ and DO on demand. No prior knowledge of BCQuality's structure is baked into the orchestrator — only the convention *"invoke `/skills/entry.md` first."* +An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s) to invoke. The agent then invokes the dispatched skills, reading READ and DO on demand. No prior knowledge of BCQuality's structure is baked into the orchestrator — only the convention *"invoke `/skills/entry.md` first."* ### Standalone plugin installation -BCQuality can also be installed directly as a plugin. The plugin registers one +BCQuality can also be installed directly as a plugin to review a complete AL +app folder, a change set, or an individual file. The plugin registers one host-native skill, [`al-code-review`](skills/al-code-review/SKILL.md), which adapts the caller's request to the same Entry protocol used by orchestrators. @@ -75,6 +76,24 @@ For GitHub Copilot CLI: copilot plugin install microsoft/BCQuality ``` +#### Review a complete app folder + +1. Open the Business Central app folder in GitHub Copilot and start a fresh + session after installing the plugin. +2. Ask: + + > Use the installed `al-code-review` skill to review the complete Business + > Central app in this folder. Execute every dispatched review domain and + > return the complete BCQuality findings report. + +That is the complete walk-up flow. The folder does not need to be a Git +repository; BCQuality reviews `app.json` and the AL source below it. To pick up +a newer BCQuality release later, run: + +```shell +copilot plugin update bcquality +``` + Plugin version `0.2.0` renamed the former `bcquality-al-review` skill to `al-code-review`; explicit invocations and allowlists using the old skill name must be updated. The name remains distinct from BC-ALAgents' public @@ -108,6 +127,12 @@ formats. Their paths make the boundary explicit. The adapter lives under `skills/al-code-review/SKILL.md`; the internal Microsoft-layer coordinator lives at `microsoft/skills/review/al-code-review.md`. +Partners that want model selection, parallel leaf execution, retries, or usage +telemetry can add a thin runner outside BCQuality. See +[Build a lightweight standalone review runner](docs/standalone-runner.md) for the +integration contract and a minimal implementation checklist. Architecture and +partner guides are collected in the [documentation index](docs/README.md). + ## Knowledge file format Every knowledge file is a markdown file with mandatory YAML frontmatter. Files target under 100 lines (ideal under 50). If two ideas would share a file, split them. @@ -153,16 +178,17 @@ Action skills follow a four-step pattern: Every action skill produces output in a common format that orchestrators can consume without skill-specific parsing. The format is JSON and includes an `outcome` (so a clean run, a not-applicable skill, and a partial failure are all distinguishable), `findings` (what the skill observed), structured `references` back to the knowledge files that informed each finding, per-finding `confidence`, and a `suppressed` list recording any knowledge files overridden by layer precedence. This contract is defined in the Action Skill meta-skill so that orchestrators and action skills remain independently evolvable. -BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. Super-skills (such as `al-code-review`) run a self-review pass alongside their sub-skills and surface concerns the agent identified on its own, marked with `from-sub-skill: "agent"` and an empty `references: []` so consumers can render them distinctly from knowledge-backed findings. See [agent-consumption.md](agent-consumption.md) and [`skills/do.md`](skills/do.md) for the full contract. +BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. Super-skills (such as `al-code-review`) run a self-review pass alongside their sub-skills and surface concerns the agent identified on its own, marked with `from-sub-skill: "agent"` and an empty `references: []` so consumers can render them distinctly from knowledge-backed findings. See [How agents consume BCQuality](docs/agent-consumption.md) and [`skills/do.md`](skills/do.md) for the full contract. The meta-skills in `/skills/` define this pattern. Every concrete action skill follows it. -For the end-to-end flow — from orchestrator trigger through to how output reaches developers — see [agent-consumption.md](agent-consumption.md). +For the end-to-end flow — from orchestrator trigger through to how output reaches developers — see [How agents consume BCQuality](docs/agent-consumption.md). ## Repository structure ``` ├── /skills/ # Global: entry-point skill + meta-skill contracts (READ, DO, WRITE) +├── /docs/ # Architecture and partner integration guides ├── /evaluation/ # Neutral good/bad review fixtures and scoring contract ├── /.github/ # Actions and workflows ├── /microsoft/ # Microsoft-endorsed layer diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c8e8290 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Documentation + +- [How agents consume BCQuality](agent-consumption.md) explains the operational + flow from Entry dispatch through structured findings and integration. +- [Build a lightweight standalone review runner](standalone-runner.md) explains + the walk-up app-folder flow and how an external runner can add model + selection, concurrency, retries, and telemetry without moving orchestration + into BCQuality. diff --git a/agent-consumption.md b/docs/agent-consumption.md similarity index 98% rename from agent-consumption.md rename to docs/agent-consumption.md index 37a7a10..aaa6772 100644 --- a/agent-consumption.md +++ b/docs/agent-consumption.md @@ -2,7 +2,8 @@ BCQuality is content — knowledge files and skills. It is consumed by agents that live elsewhere (AL-Go, a VS Code extension, a GitHub Agent invocation, etc.). This document explains the end-to-end flow, so that skill authors, orchestrator maintainers, and contributors share one mental model. -For the high-level framing and repo structure, start with the [README](README.md). This document is the operational view. +For the high-level framing and repo structure, start with the +[README](../README.md). This document is the operational view. ## The actors diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md new file mode 100644 index 0000000..605ffcc --- /dev/null +++ b/docs/standalone-runner.md @@ -0,0 +1,103 @@ +# Build a lightweight standalone review runner + +BCQuality provides review knowledge, routing, execution instructions, and +structured output contracts. It intentionally does not choose models, schedule +agents, retry failures, or collect usage telemetry. A standalone runner can add +those host-specific capabilities without copying Business Central rules out of +BCQuality. + +Use the built-in standalone plugin when the host's default execution is +sufficient. Build a runner when you need explicit control over cost, latency, +concurrency, or integration with another review surface. + +## Keep BCQuality current + +Install or update the plugin with GitHub Copilot CLI: + +```shell +copilot plugin install microsoft/BCQuality +copilot plugin update bcquality +``` + +A runner that reads BCQuality from a checkout should pin a commit or release +and upgrade it deliberately. Do not copy knowledge files or action-skill prose +into the runner; doing so creates a second, drifting quality policy. + +## Review a complete app folder + +For a committed app, generated fixture, or source tree that has no meaningful +diff, supply the app's root directory as `folder-path`. The review scope is +every relevant file below that directory, including `app.json` and AL source. +The folder does not need to be a Git repository. + +With the standalone plugin installed, start a fresh Copilot session in the app +folder and ask: + +> Use the installed `al-code-review` skill to review the complete Business +> Central app in this folder. Execute every dispatched review domain and return +> the complete BCQuality findings report. + +The adapter maps this request to `folder-path`; Entry routes it to the broad +review super-skill. Because a folder is a current-state snapshot, the review +must not invent a previous app version when evaluating comparison-only rules. + +## Minimal runner flow + +1. Give the agent the review input and a task context containing the user's + actual goal, available input types, and any known BC applicability + dimensions. +2. Invoke `skills/entry.md`. Entry prepares the knowledge index and returns the + action skills to run. Do not reproduce its routing logic. +3. Execute every dispatched action skill with the exact input subset in its + dispatch record. Read `skills/read.md` and `skills/do.md` on demand. +4. When an action skill declares `sub-skills`, execute every relevant leaf as a + discrete invocation. Leaves are independent and may be scheduled serially + or concurrently. +5. Collect each complete findings-report into `sub-results` in the declared + `sub-skills` order, not completion order. Run the super-skill self-review + only after all leaves have finished. +6. Apply the DO composition, failure, deduplication, reference-integrity, and + outcome rules. Return strict JSON before rendering it for people or another + system. + +The runner must never inspect the diff to skip a review domain. A leaf decides +its own task-level applicability and reports `not-applicable` or +`no-knowledge`. + +## Runner-owned choices + +Keep these settings and behaviors outside BCQuality: + +- coordinator and leaf models; +- serial or concurrent scheduling and maximum concurrency; +- retries, timeouts, and rate-limit handling; +- token, cost, duration, and actual-concurrency telemetry; +- conversion of the findings report into Markdown, annotations, or PR + comments. + +Model selection and requested concurrency are deployment choices, not review +rules. Evaluate them against representative applications before making them a +default. Report actual usage and concurrency only when the host exposes native +evidence; do not infer them from the requested profile. + +## Failure and output checklist + +A compatible runner: + +- invokes every worklisted leaf exactly once unless a documented retry replaces + a failed attempt; +- keeps leaf contexts isolated and passes only the inputs they declare; +- preserves every leaf report, including failed reports, in `sub-results`; +- excludes unreliable findings from failed leaves and returns `partial` when + only part of the review is reliable; +- orders `sub-results` by the declared worklist and orders rendered findings + deterministically; +- calculates top-level severity counts from deduplicated top-level findings, + not by summing leaf counts; +- preserves knowledge paths verbatim and verifies references before publishing; +- records the BCQuality commit or release used for the run. + +BC-ALAgents, AL-Go, a Copilot custom agent, or a small host-native plugin can +all implement this runner contract. They remain optional consumers: +BCQuality's knowledge and skills stay independent of their orchestration +choices. diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index 43a2e20..3ba30e4 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -4,7 +4,7 @@ id: al-appsource-review version: 1 title: AL AppSource review description: Performs an AL AppSource review against source and app metadata guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source and app metadata changes against the `appsource` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). AppSource findings are narrow by design — they apply when the diff touches AppSourceCop configuration, AL object or extension-member names, or AppSource-facing `app.json` metadata. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply when the review scope contains AppSourceCop configuration, AL object or extension-member names, or AppSource-facing `app.json` metadata. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 82aeda0..767c9af 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-review.md @@ -4,7 +4,7 @@ id: al-breaking-changes-review version: 1 title: AL breaking changes review description: Reviews AL source changes against breaking-changes guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `breaking-changes` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 9b3f934..41f0f78 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -4,7 +4,7 @@ id: al-code-review version: 1 title: AL code review description: Reviews AL source changes by composing the AL review leaf skills, one per knowledge domain. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -35,7 +35,7 @@ Reviews AL source changes by composing the leaf AL review skills. This is the ca `al-code-review` does not evaluate knowledge files directly. It invokes each of its sub-skills against the same task input, collects their findings-reports, and then performs its own **self-review pass** over the diff using the agent's built-in BC and AL knowledge. BCQuality knowledge is an additive layer: anything the sub-skills found is cited from BCQuality, and anything the agent finds on its own is validated against BCQuality (cited if matched, suppressed if contradicted, surfaced as an **agent finding** otherwise). The result is a single rolled-up findings-report that mixes knowledge-backed and agent findings, each clearly tagged via `from-sub-skill`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract, extended with `sub-results` and — when applicable — `skipped-sub-skills`. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract, extended with `sub-results` and — when applicable — `skipped-sub-skills`. ## Source @@ -63,18 +63,18 @@ The worklist is the list of sub-skills judged relevant by the previous step. Eve ### Execution discipline (mandatory) -The Action step is a sequence of **discrete iterations**, not one combined generation. The contract requires the super-skill to invoke each sub-skill in turn and then perform a self-review pass. Concretely this means: +The Action step consists of **discrete leaf invocations**, not one combined generation. Invocation scheduling belongs to the orchestrator: independent leaves may run serially or concurrently, but their evaluation contexts and findings-reports remain isolated. Concretely this means: - **Isolate leaf invocations when the host supports it.** For fast/small models, each sub-skill SHOULD run in a fresh model call or child context containing only the task input, READ/DO contracts, the leaf instructions, a domain-filtered slice of the current knowledge index, and articles that leaf worklists. Preserve each index row's exact `path`; the leaf must copy references from that slice. The coordinator then collects the resulting JSON. This is the preferred fast-model profile: it bounds context, prevents later leaves from being skipped as attention is exhausted, and removes any reason to synthesize article paths. -- Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source → Relevance → Worklist → Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report before moving on. +- Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source → Relevance → Worklist → Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report independently. - Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches). - The agent self-review pass is its own final iteration. Begin it only after every sub-skill in the worklist has completed and its sub-result is recorded. -- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this — `sub-results` carries one entry per sub-skill, each a complete findings-report. +- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this — `sub-results` carries one entry per sub-skill, each a complete findings-report, in the frontmatter `sub-skills` order regardless of completion order. - When isolated calls are unavailable and the current model cannot finish every leaf within its budget, return `partial` with completed `sub-results` and name the first unevaluated sub-skill in `outcome-reason`. Never silently mark the remaining leaves clean. ### Roll up sub-skill findings -For each sub-skill in the worklist, executed one at a time per the discipline above: +For each sub-skill in the worklist: 1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`. 2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`. @@ -116,7 +116,7 @@ Sub-skills MAY also emit `suggested-code` when their knowledge file unambiguousl ### Summary and rollup -Aggregate `summary.counts` and `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. Agent findings emitted by the super-skill itself contribute to `summary.counts` but not to `summary.coverage` (coverage is a sub-skill worklist metric and is undefined for self-review). +Calculate `summary.counts` from the final top-level `findings[]`, after failed sub-results have been excluded and duplicates have been merged. Aggregate `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. Agent findings emitted by the super-skill itself contribute to `summary.counts` but not to `summary.coverage` (coverage is a sub-skill worklist metric and is undefined for self-review). `suppressed[]` at the super-skill level remains empty. Knowledge-file-level suppression is reported by each sub-skill within its own entry in `sub-results`. diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 2386613..01a983a 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -4,7 +4,7 @@ id: al-data-modeling-review version: 1 title: AL data-modeling review description: Performs an AL data-modeling review against guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Data-modeling findings are narrow by design — they apply when the diff touches setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 39851ac..bc4598a 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -4,7 +4,7 @@ id: al-error-handling-review version: 1 title: AL error handling review description: Reviews AL source changes against error-handling guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `error-handling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index c854f1c..7248a45 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -4,7 +4,7 @@ id: al-events-review version: 1 title: AL events review description: Reviews AL source changes against events-and-subscribers guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `events` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md index 0031e8f..885cd0b 100644 --- a/microsoft/skills/review/al-interfaces-review.md +++ b/microsoft/skills/review/al-interfaces-review.md @@ -4,7 +4,7 @@ id: al-interfaces-review version: 1 title: AL interfaces review description: Reviews AL source changes against interface and enum-with-implementation guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `interfaces` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index bf2f4e8..d4738ac 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -4,7 +4,7 @@ id: al-performance-review version: 1 title: AL performance review description: Reviews AL source changes against performance guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `performance` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 0bbd8ae..469000c 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -4,7 +4,7 @@ id: al-privacy-review version: 1 title: AL privacy review description: Reviews AL source changes against privacy and data-classification guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `privacy` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md index c4ea895..3681b23 100644 --- a/microsoft/skills/review/al-query-review.md +++ b/microsoft/skills/review/al-query-review.md @@ -4,7 +4,7 @@ id: al-query-review version: 1 title: AL Query review description: Reviews AL Query objects and Query instance usage against BCQuality guidance. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 8472afe..5d998c9 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -4,7 +4,7 @@ id: al-security-review version: 1 title: AL security review description: Reviews AL source changes against security guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `security` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index bffef4d..223fbbd 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -4,7 +4,7 @@ id: al-style-review version: 1 title: AL style review description: Reviews AL source changes against naming, labelling, and code-convention guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -18,7 +18,7 @@ Reviews AL source changes against the `style` knowledge domain in BCQuality and Style findings cover AL conventions that CodeCop and similar analyzers partially enforce — label suffixes, API page naming, temporary-variable prefixes, label properties, named invocations, `FieldCaption`/`TableCaption` in user messages, `OptionCaption` pairing, Error-parameter passing, `this` keyword, required parentheses, file-naming. Use together with a formal analyzer; this skill adds BCQuality's remedial-knowledge explanations of why each rule exists. -An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md index 4a758f0..4b50a9e 100644 --- a/microsoft/skills/review/al-telemetry-review.md +++ b/microsoft/skills/review/al-telemetry-review.md @@ -4,7 +4,7 @@ id: al-telemetry-review version: 1 title: AL telemetry review description: Performs an AL telemetry review against guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `telemetry` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Telemetry findings are narrow by design — they apply when the diff emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Telemetry findings are narrow by design — they apply when the review scope emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index 8bad734..ed50c46 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -4,7 +4,7 @@ id: al-testing-review version: 1 title: AL testing review description: Performs an AL testing review against guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `testing` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Testing findings are narrow by design — they apply when the diff touches test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Testing findings are narrow by design — they apply when the review scope contains test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index c0af5af..c04a30a 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -4,7 +4,7 @@ id: al-ui-review version: 1 title: AL UI and accessibility review description: Reviews AL page and control add-in UI files against UI text, caption, tooltip, and accessibility guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al, javascript] @@ -18,7 +18,7 @@ Reviews AL page source and control add-in UI files against the `ui` knowledge do UI findings apply to page files — files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention — and to JavaScript/CSS/HTML files that implement Business Central control add-ins, including their client-service communication. The skill returns `not-applicable` when the diff contains no page or control add-in changes. -An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 667ea32..2bb1877 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -4,7 +4,7 @@ id: al-upgrade-review version: 1 title: AL upgrade review description: Reviews AL source changes against upgrade-code and migration guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `upgrade` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Upgrade findings are narrow by design — they apply when the diff touches upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Upgrade findings are narrow by design — they apply when the review scope contains upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply. ## Source diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index cfa6fdd..e818e46 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -4,7 +4,7 @@ id: al-web-services-review version: 1 title: AL web services review description: Reviews AL API surfaces and webhook integration handlers against web-services guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al, javascript] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `web-services` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. ## Source diff --git a/skills/README.md b/skills/README.md index 3d8fdfb..b8d3fd3 100644 --- a/skills/README.md +++ b/skills/README.md @@ -57,4 +57,4 @@ each review domain to run in an isolated context. These contracts are stable. Changes require a PR approved by both maintainers. -For the end-to-end flow — from orchestrator trigger through to findings integration — see [`../agent-consumption.md`](../agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md). +For the end-to-end flow — from orchestrator trigger through to findings integration — see [How agents consume BCQuality](../docs/agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md). diff --git a/skills/al-code-review/SKILL.md b/skills/al-code-review/SKILL.md index 991a771..df54914 100644 --- a/skills/al-code-review/SKILL.md +++ b/skills/al-code-review/SKILL.md @@ -1,6 +1,6 @@ --- name: al-code-review -description: Review Business Central AL code changes using BCQuality's curated rules. Use for an AL pull request, working-tree diff, branch, or individual AL file when BCQuality is installed as a standalone plugin. +description: Review Business Central AL code using BCQuality's curated rules. Use for an AL app folder, pull request, working-tree diff, branch, or individual AL file when BCQuality is installed as a standalone plugin. --- # AL code review @@ -21,8 +21,11 @@ context and execute the resulting dispatch. - Copy the caller's actual request verbatim into `goal`; do not replace a focused request such as "review performance" with a generic full-review goal. - - Set `inputs-available` to the inputs actually available to the review, - normally `pr-diff` for changes or `file-path` for one file. + - Set `inputs-available` to the inputs actually available to the review: + `folder-path` for an app or source folder, `pr-diff` for changes, or + `file-path` for one file. Pass the caller's actual path with the selected + input type; for a whole-app request in the current working directory, use + that directory as the `folder-path`. - Set `technologies: [al]` when the input is known to be AL. - Pass `bc-version`, `countries`, and `application-area` only when supplied or reliably determined. @@ -66,4 +69,3 @@ where a consumer prunes its checkout to policy before the agent runs and the index is rebuilt over the pruned tree. Treat `BCQUALITY_ENABLED_LAYERS` as a selection filter, never as a security boundary. A host that needs a genuine deny mechanism must prune the installed tree itself. - diff --git a/skills/do.md b/skills/do.md index a79c5fc..5234437 100644 --- a/skills/do.md +++ b/skills/do.md @@ -56,7 +56,24 @@ application-area: [all] `bc-version`, `technologies`, `countries`, `application-area` are optional filters that let an orchestrator pre-select applicable skills for a task. They follow the same semantics as in READ. -`inputs` is a list of abstract input types the skill **accepts**. Standard values: `pr-diff`, `object-list`, `file-path`, `repository`, `telemetry-query`. Semantics are any-of: the orchestrator supplies whichever listed input types it has, and the skill is invoked with a non-empty subset of its declared `inputs`. A skill that cannot proceed with the supplied subset MUST return `outcome: "not-applicable"`. `outputs` is always a single-element list naming the output kind; today only `findings-report` is defined. +`inputs` is a list of abstract input types the skill **accepts**. Standard values: +`pr-diff`, `object-list`, `file-path`, `folder-path`, `repository`, and +`telemetry-query`. Semantics are any-of: the orchestrator supplies whichever +listed input types it has, and the skill is invoked with a non-empty subset of +its declared `inputs`. A skill that cannot proceed with the supplied subset +MUST return `outcome: "not-applicable"`. `outputs` is always a single-element +list naming the output kind; today only `findings-report` is defined. + +`file-path` is one file. `folder-path` is a directory whose recursively +contained files form the complete current-state input, such as a Business +Central app folder containing `app.json` and AL source. The input value is the +actual path, not merely the name of the input type. The agent MUST enumerate +the folder rather than reducing it to one representative file. + +Review skills use terms such as "diff", "changed files", and "changed code" as +shorthand for the supplied review scope. For `folder-path`, every relevant file +under the folder is in scope. A folder supplies no historical baseline: +comparison-only rules MUST NOT infer a prior state that was not provided. `sub-skills` is an optional field. When present and non-empty, the skill is a **super-skill** that composes other action skills; see *Composition* below. Values are repo-relative paths to action-skill files. @@ -231,7 +248,7 @@ Omit `suggested-code` only when the appropriate fix depends on context the skill - `reference` — the suppressed file (same object shape as `findings[].references`). - `reason` — `layer-precedence` when another layer won under READ's precedence rules; `configuration` when the consumer disabled the file's layer. -**`sub-results`** — super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Leaf skills MUST NOT emit `sub-results`. +**`sub-results`** — super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Entries MUST appear in the worklist's declared order, regardless of invocation or completion order. Leaf skills MUST NOT emit `sub-results`. **`skipped-sub-skills`** — super-skills only. Array of sub-skills that were declared in frontmatter but not invoked. `reason` is `configuration` when the orchestrator disabled the sub-skill, or `not-applicable` when the super-skill's Relevance step ruled it out. @@ -248,6 +265,20 @@ A **super-skill** is an action skill whose frontmatter declares a non-empty `sub Composition is flat: a super-skill MAY list only leaf skills (skills without their own `sub-skills`). Nested super-skills are not permitted in v1. +### Scheduling boundary + +The super-skill defines which leaves must run, the input and output contracts, +and how their results are composed. It does not prescribe a model, concurrency +limit, retry policy, or telemetry system. Those choices belong to the +orchestrator. + +Each leaf invocation MUST remain a discrete evaluation with its own complete +findings-report. An orchestrator MAY execute independent leaves serially or +concurrently, but MUST invoke every worklisted leaf, preserve `sub-results` in +the declared worklist order, and wait for every invocation to finish before +performing any super-skill self-review or final rollup. Scheduling MUST NOT +change relevance, coverage, failure, reference-integrity, or output semantics. + ### Section interpretation for super-skills The five required sections still apply. Their meaning shifts from knowledge files to sub-skills: @@ -274,7 +305,13 @@ When the worklist is empty (every sub-skill was skipped), `outcome` is `not-appl ### Rolled-up summary -`summary.counts` is the sum of sub-skill counts. `summary.coverage.worklist-size` and `items-evaluated` are the sums across invoked sub-skills. +`summary.counts` counts the findings in the super-skill's final top-level +`findings[]`, after failed sub-results have been excluded and duplicates have +been merged. It MUST NOT be calculated by summing sub-skill counts, because the +same concern may appear in more than one sub-result. + +`summary.coverage.worklist-size` and `items-evaluated` are the sums across +invoked sub-skills whose outcomes are not `failed`. ### Suppression scope diff --git a/skills/entry.md b/skills/entry.md index 0196c35..efa84a1 100644 --- a/skills/entry.md +++ b/skills/entry.md @@ -23,6 +23,7 @@ task-context: inputs-available: # values the orchestrator has ready to pass to a chosen skill - pr-diff - file-path + - folder-path technologies: [al] bc-version: 28 countries: [w1] From a21edfec460e7b8d066d5ca3aaba4875d787edb4 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 9 Sep 2026 17:07:11 +0200 Subject: [PATCH 03/51] Frame app review as a plugin example (#173) Remove historical naming and external orchestrator references, and present the app-folder review as one example of the broader host-native skill pattern. Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 27 +++++++++++++++------------ docs/README.md | 2 +- docs/agent-consumption.md | 9 ++++++--- docs/standalone-runner.md | 7 +++---- skills/README.md | 2 -- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f3ea476..823db24 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ A file that *prevents* a false positive — documenting why a pattern is legitim ## What's in this repo -BCQuality contains **knowledge** and **skills**. It does not contain agents. Agents that consume BCQuality ship with [AL-Go](https://github.com/microsoft/AL-Go) and other orchestrators. +BCQuality contains **knowledge** and **skills**. It does not contain agents. +Agents that consume BCQuality are supplied by the host or orchestrator. ### Knowledge files @@ -60,13 +61,17 @@ Skills define how agents consume knowledge. They come in three flavors: ### Agent bootstrapping -An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s) to invoke. The agent then invokes the dispatched skills, reading READ and DO on demand. No prior knowledge of BCQuality's structure is baked into the orchestrator — only the convention *"invoke `/skills/entry.md` first."* +A host or orchestrator points the agent at BCQuality and provides a task +context. The agent's first call is `/skills/entry.md`, which returns a dispatch +record naming the action skill(s) to invoke. The agent then invokes the +dispatched skills, reading READ and DO on demand. No prior knowledge of +BCQuality's structure is required beyond the convention *"invoke +`/skills/entry.md` first."* ### Standalone plugin installation -BCQuality can also be installed directly as a plugin to review a complete AL -app folder, a change set, or an individual file. The plugin registers one -host-native skill, +BCQuality can also be installed directly as a plugin so supported hosts can +discover and invoke its host-native skills. The plugin currently registers [`al-code-review`](skills/al-code-review/SKILL.md), which adapts the caller's request to the same Entry protocol used by orchestrators. @@ -76,7 +81,11 @@ For GitHub Copilot CLI: copilot plugin install microsoft/BCQuality ``` -#### Review a complete app folder +#### Example: Review a complete app folder + +This example demonstrates the walk-up pattern with the currently exposed +review skill. Future host-native skills follow the same discovery and +invocation pattern; they do not each require a dedicated README walkthrough. 1. Open the Business Central app folder in GitHub Copilot and start a fresh session after installing the plugin. @@ -94,12 +103,6 @@ a newer BCQuality release later, run: copilot plugin update bcquality ``` -Plugin version `0.2.0` renamed the former `bcquality-al-review` skill to -`al-code-review`; explicit invocations and allowlists using the old skill name -must be updated. The name remains distinct from BC-ALAgents' public -`al-review` skill because current hosts may load plugin skill names into one -shared inventory. - The adapter is intentionally not a second review implementation: ```text diff --git a/docs/README.md b/docs/README.md index c8e8290..466b98e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,6 @@ - [How agents consume BCQuality](agent-consumption.md) explains the operational flow from Entry dispatch through structured findings and integration. - [Build a lightweight standalone review runner](standalone-runner.md) explains - the walk-up app-folder flow and how an external runner can add model + one concrete walk-up skill flow and how an external runner can add model selection, concurrency, retries, and telemetry without moving orchestration into BCQuality. diff --git a/docs/agent-consumption.md b/docs/agent-consumption.md index aaa6772..2cb3761 100644 --- a/docs/agent-consumption.md +++ b/docs/agent-consumption.md @@ -1,13 +1,16 @@ # How agents consume BCQuality -BCQuality is content — knowledge files and skills. It is consumed by agents that live elsewhere (AL-Go, a VS Code extension, a GitHub Agent invocation, etc.). This document explains the end-to-end flow, so that skill authors, orchestrator maintainers, and contributors share one mental model. +BCQuality is content — knowledge files and skills. It is consumed by agents +supplied by a host or orchestrator. This document explains the end-to-end flow +so that skill authors, orchestrator maintainers, and contributors share one +mental model. For the high-level framing and repo structure, start with the [README](../README.md). This document is the operational view. ## The actors -- **Orchestrator** — the tool that triggers work (e.g. AL-Go on a pull request, or a VS Code extension on save). Lives *outside* BCQuality. Knows *when* to run something, not *what* to run. +- **Orchestrator** — the tool that triggers work. Lives *outside* BCQuality. Knows *when* to run something, not *what* to run. - **Agent** — an LLM-driven process spawned by the orchestrator. The agent has no built-in knowledge of BC or of BCQuality's conventions. It knows how to read instructions and call tools. - **BCQuality repo** — two kinds of content: - **Global skills** in `/skills/` — the `entry.md` entry-point skill plus the READ · DO · WRITE contracts that govern the rest of the repo. @@ -21,7 +24,7 @@ action skill: it creates the task context and enters the same flow at Entry. ```mermaid flowchart LR - O[Orchestrator
AL-Go] -->|1 trigger + task context| A[Agent] + O[Host or orchestrator] -->|1 trigger + task context| A[Agent] A -->|2 invoke entry.md| E[Entry
routing skill] E -->|3 dispatch record| A A -->|4 invoke dispatched skill| S[Action skill
e.g. al-code-review] diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 605ffcc..8bb1e67 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -97,7 +97,6 @@ A compatible runner: - preserves knowledge paths verbatim and verifies references before publishing; - records the BCQuality commit or release used for the run. -BC-ALAgents, AL-Go, a Copilot custom agent, or a small host-native plugin can -all implement this runner contract. They remain optional consumers: -BCQuality's knowledge and skills stay independent of their orchestration -choices. +A CI integration, custom agent, or small host-native plugin can implement this +runner contract. These remain optional consumers: BCQuality's knowledge and +skills stay independent of their orchestration choices. diff --git a/skills/README.md b/skills/README.md index b8d3fd3..766131f 100644 --- a/skills/README.md +++ b/skills/README.md @@ -48,8 +48,6 @@ This gives the two skill formats distinct roles: The host adapter and internal coordinator deliberately share the `al-code-review` name because they represent the same user-facing operation in their respective formats. Their locations distinguish their roles. The -adapter remains distinct from BC-ALAgents' separately installed `al-review` -skill, avoiding a collision in hosts that use one shared skill inventory. The reference from the adapter to Entry, and from a dispatched super-skill to its leaf skills, is intentional progressive disclosure. It avoids registering every internal BCQuality protocol file as an ambient host skill while allowing From 2b5550c3463017f498a47691f740c684471eaaf8 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Wed, 9 Sep 2026 17:31:03 +0200 Subject: [PATCH 04/51] Improve partner onboarding and documentation navigation (#174) Lead with a complete plugin quick start and add task-oriented usage, troubleshooting, customization, and contribution guides. Preserve the broader plugin framing, correct conflicting contract guidance, support Agents folder reviews, and align repository validation. Convert existing sample references to clickable links without changing knowledge rules. Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/custom-layer-autoclose.md | 36 +-- .github/new-top-level-flag.md | 2 +- .github/scripts/validate_frontmatter.py | 2 +- .github/workflows/flag-new-top-level.yml | 5 +- README.md | 277 ++++++------------ ...ent-permissions-intersect-with-assigner.md | 4 +- .../agent-profile-narrows-visible-ui.md | 4 +- ...gent-setup-page-is-configuration-dialog.md | 4 +- .../agent-setup-source-table-is-temporary.md | 4 +- ...t-setup-table-keyed-by-user-security-id.md | 4 +- ...ssage-error-stops-warning-forces-review.md | 4 +- ...agent-subscribers-only-in-agent-session.md | 4 +- ...ss-app-agent-calls-need-your-public-api.md | 4 +- ...agents-in-install-upgrade-or-background.md | 4 +- ...default-access-controls-least-privilege.md | 4 +- .../get-default-profile-lives-in-the-app.md | 4 +- ...struction-structure-is-role-rules-steps.md | 4 +- ...instructions-describe-work-not-tool-ids.md | 4 +- ...eapply-resource-instructions-on-upgrade.md | 4 +- ...gister-copilot-capability-for-the-agent.md | 4 +- .../agents/set-instructions-as-secrettext.md | 4 +- ...create-agent-does-not-block-code-create.md | 4 +- ...-incoming-review-only-for-trusted-input.md | 4 +- .../use-documented-instruction-keywords.md | 4 +- .../agents/wire-all-three-agent-interfaces.md | 4 +- community/skills/review/al-agents-review.md | 7 +- custom/README.md | 15 +- docs/README.md | 38 ++- docs/agent-consumption.md | 39 ++- docs/contributing.md | 137 +++++++++ docs/customizing-bcquality.md | 173 +++++++++++ docs/standalone-runner.md | 28 +- docs/troubleshooting.md | 59 ++++ docs/using-bcquality.md | 190 ++++++++++++ .../object-affixes-prevent-collisions.md | 4 +- ...ets-cover-setup-and-usage-without-super.md | 4 +- ...object-affix-not-extension-member-affix.md | 4 +- .../choose-access-modifiers-deliberately.md | 4 +- ...lic-members-with-the-obsolete-lifecycle.md | 4 +- ...t-change-published-procedure-signatures.md | 4 +- ...xpose-sensitive-data-through-public-api.md | 4 +- ...not-modify-code-already-marked-obsolete.md | 4 +- ...ce-is-part-of-published-object-identity.md | 4 +- ...e-table-fields-instead-of-deleting-them.md | 4 +- ...ocked-in-referencing-code-not-in-master.md | 4 +- ...table-no-from-number-series-in-oninsert.md | 4 +- ...able-must-delete-dependents-in-ondelete.md | 4 +- ...-date-modified-in-onmodify-and-onrename.md | 4 +- .../setup-table-is-a-singleton.md | 4 +- ...-items-with-insert-not-field-assignment.md | 4 +- ...on-extensions-are-additive-and-top-down.md | 4 +- ...fields-skip-type-mismatch-can-drop-data.md | 4 +- ...-series-codeunit-not-noseriesmanagement.md | 4 +- ...ion-false-suppresses-rename-propagation.md | 4 +- ...is-a-before-image-only-in-some-triggers.md | 4 +- ...ct-validation-errors-with-errorbehavior.md | 4 +- ...type-internal-vs-client-for-diagnostics.md | 4 +- .../fielderror-default-message-logic.md | 4 +- .../error-handling/fielderror-vs-testfield.md | 4 +- ...yfunction-return-disables-try-semantics.md | 4 +- .../prefer-errorinfo-for-actionable-errors.md | 4 +- .../add-new-event-parameters-at-the-end.md | 4 +- .../avoid-loosely-typed-event-parameters.md | 4 +- ...oid-raising-events-inside-try-functions.md | 4 +- ...atic-vs-manual-subscribers-deliberately.md | 4 +- ...lare-event-publishers-local-or-internal.md | 4 +- ...-not-add-ishandled-to-an-existing-event.md | 4 +- ...pass-critical-operations-with-ishandled.md | 4 +- ...ot-change-shipped-event-attribute-flags.md | 4 +- .../do-not-publish-events-inside-loops.md | 4 +- ...process-context-via-manually-bound-flag.md | 4 +- ...-event-parameters-without-abbreviations.md | 4 +- .../name-events-by-publisher-position.md | 4 +- ...er-reusing-or-extending-existing-events.md | 4 +- ...s-over-includesender-in-codeunit-events.md | 4 +- ...orary-record-event-parameters-with-temp.md | 4 +- ...execution-when-ishandled-skips-the-body.md | 4 +- ...hin-onbefore-onafter-integration-events.md | 4 +- ...dled-only-when-the-value-can-carry-over.md | 4 +- ...internal-events-as-subscriber-contracts.md | 4 +- ...dled-to-make-base-behaviour-overridable.md | 4 +- ...n-codeunit-to-interface-for-testability.md | 4 +- ...end-published-interfaces-dont-edit-them.md | 4 +- ...rdinals-with-unknownvalueimplementation.md | 4 +- .../prefer-interface-over-case-branching.md | 4 +- .../set-defaultimplementation-on-enum.md | 4 +- .../addloadfields-in-report-onpredataitem.md | 4 +- .../apply-filters-before-iterating.md | 4 +- .../performance/apply-guards-before-get.md | 4 +- ...g-records-before-modify-delete-in-loops.md | 4 +- .../performance/avoid-commit-inside-loops.md | 4 +- ...oid-currpage-update-in-onaftergetrecord.md | 4 +- .../avoid-get-inside-loop-on-large-table.md | 4 +- ...g-globals-in-singleinstance-subscribers.md | 4 +- .../avoid-recordref-in-hot-loop.md | 4 +- ...edundant-get-when-record-already-loaded.md | 4 +- .../avoid-user-prompts-inside-transactions.md | 4 +- ...ber-series-instead-of-getnextno-per-row.md | 4 +- .../boolean-operators-do-not-short-circuit.md | 4 +- .../calcsums-instead-of-calcfields-in-loop.md | 4 +- .../case-true-of-for-long-condition-chains.md | 4 +- .../changecompany-in-loop-drops-caches.md | 4 +- ...e-maintainsiftindex-by-read-write-ratio.md | 2 +- .../codeunit-run-as-atomic-sub-operation.md | 4 +- ...equires-prior-commit-inside-transaction.md | 4 +- ...ssintent-readonly-on-analytical-objects.md | 4 +- ...do-not-locktable-in-read-only-procedure.md | 4 +- .../do-not-modify-in-onaftergetrecord.md | 4 +- ...move-sourcetabletemporary-from-api-page.md | 4 +- .../findset-true-applies-updlock-on-read.md | 4 +- ...owfield-source-key-needs-sumindexfields.md | 4 +- .../guard-event-subscribers-before-db-call.md | 4 +- ...guiallowed-guard-on-pages-used-as-odata.md | 4 +- ...elds-still-calculate-before-bc26-opt-in.md | 4 +- ...nt-inside-write-transaction-holds-locks.md | 4 +- ...mpty-before-findset-is-extra-round-trip.md | 4 +- ...-common-fields-before-branching-on-case.md | 4 +- ...y-primary-key-fields-for-reference-work.md | 4 +- ...ainsqlindex-false-breaks-flowfield-sift.md | 2 +- ...ncompanyopen-subscribers-must-not-do-io.md | 4 +- .../order-case-branches-by-frequency.md | 4 +- ...age-background-tasks-for-expensive-cues.md | 4 +- .../pair-findset-with-next-loop.md | 4 +- ...false-to-insert-when-trigger-not-needed.md | 2 +- ...ord-to-preserve-partial-load-enumerator.md | 4 +- .../prefer-modifyall-over-per-row-modify.md | 4 +- ...-readisolation-over-locktable-for-reads.md | 4 +- ...ted-table-over-extension-on-hot-ledgers.md | 4 +- .../query-results-bypass-primary-key-cache.md | 4 +- .../reset-clears-partial-record-selection.md | 4 +- ...rrentkey-sets-sort-order-not-index-hint.md | 4 +- ...tloadfields-on-write-and-transferfields.md | 4 +- ...e-dedicated-lookup-pages-not-full-lists.md | 4 +- ...se-deleteall-for-filtered-bulk-deletion.md | 4 +- ...nstead-of-findfirst-on-full-primary-key.md | 4 +- .../use-isempty-for-existence-check.md | 4 +- ...etautocalcfields-for-per-row-flowfields.md | 4 +- .../use-setloadfields-for-partial-records.md | 4 +- ...unction-for-error-catching-not-rollback.md | 4 +- .../validate-on-partial-record-forces-jit.md | 4 +- .../avoid-strsubstno-prebuild-before-error.md | 4 +- ...a-classification-required-on-pii-fields.md | 4 +- ...-telemetry-classification-and-errortype.md | 4 +- ...eaturetelemetry-customdimensions-no-pii.md | 4 +- ...retelemetry-logerror-implicit-errortext.md | 4 +- ...lowfilter-classification-systemmetadata.md | 2 +- ...asterrortext-customer-content-in-errors.md | 4 +- .../no-pii-in-telemetry-message-string.md | 4 +- ...tice-consent-for-external-data-transfer.md | 4 +- ...gration-in-privacy-notice-registrations.md | 2 +- ...-logmessage-requires-dataclassification.md | 4 +- ...able-level-data-classification-cascades.md | 2 +- ...g-query-resets-cursor-but-keeps-filters.md | 4 +- .../query/set-query-filters-before-open.md | 4 +- .../security/al-has-no-built-in-htmlencode.md | 4 +- ...avior-attribute-scopes-explicit-commits.md | 4 +- ...pose-permission-sets-with-included-sets.md | 4 +- ...rortext-storage-is-privacy-not-security.md | 2 +- .../guard-bulk-operations-with-istemporary.md | 4 +- ...ndirect-permissions-for-elevated-access.md | 4 +- .../inherent-permissions-minimal-grant.md | 4 +- ...ntegrationevent-must-not-expose-secrets.md | 4 +- ...-var-parameter-bypasses-security-guards.md | 4 +- ...ernal-access-is-not-a-security-boundary.md | 4 +- ...torage-access-must-be-local-or-internal.md | 4 +- ...atedstorage-datascope-module-vs-company.md | 4 +- ...orage-setencrypted-for-sensitive-values.md | 4 +- ...ble-required-when-unwrapping-secrettext.md | 4 +- .../permission-set-avoid-wildcard-grants.md | 4 +- ...2-over-api-keys-for-external-http-calls.md | 4 +- ...tect-sensitive-data-in-temporary-tables.md | 4 +- ...en-with-caller-table-must-not-be-public.md | 4 +- .../security/secrets-isolated-storage.md | 4 +- .../secretstrsubstno-for-composing-secrets.md | 4 +- .../security/secrettext-for-credentials.md | 4 +- .../security/secrettext-with-httpclient.md | 4 +- .../validate-user-configurable-urls.md | 4 +- ...lidatetablerelation-false-on-user-input.md | 4 +- .../abouttitle-abouttext-teaching-tips.md | 4 +- .../style/api-page-camelcase-properties.md | 4 +- .../style/api-page-delayedinsert-true.md | 4 +- .../api-page-entity-naming-singular-plural.md | 4 +- .../style/api-page-version-format.md | 4 +- ...plicationarea-required-on-page-controls.md | 4 +- .../begin-on-same-line-as-then-else-do.md | 4 +- .../style/block-keywords-start-new-line.md | 4 +- .../style/caption-required-on-page-fields.md | 4 +- .../case-action-on-line-after-possibility.md | 4 +- ...sses-parameters-directly-not-strsubstno.md | 4 +- ...dcaption-not-fieldname-in-user-messages.md | 4 +- .../function-call-parentheses-required.md | 4 +- .../label-comment-explains-placeholders.md | 4 +- .../label-locked-for-non-translatable.md | 4 +- .../style/label-suffix-approved-list.md | 4 +- .../style/lowercase-reserved-keywords.md | 4 +- .../style/named-invocations-not-object-ids.md | 4 +- .../no-begin-end-around-single-statement.md | 4 +- .../no-else-after-terminating-statement.md | 4 +- .../no-space-before-method-parenthesis.md | 4 +- ...aption-required-and-matches-membercount.md | 4 +- .../style/single-space-after-not-operator.md | 4 +- .../single-space-around-binary-operators.md | 4 +- .../style/temporary-variable-temp-prefix.md | 4 +- .../style/this-keyword-in-codeunits.md | 4 +- .../style/tooltip-required-on-page-fields.md | 4 +- .../variable-declaration-order-by-type.md | 4 +- .../style/variable-name-must-not-shadow.md | 4 +- .../choose-telemetry-scope-by-audience.md | 4 +- .../feature-uptake-transitions-in-order.md | 4 +- .../feature-usage-only-after-success.md | 4 +- .../keep-custom-dimension-schema-stable.md | 4 +- .../match-verbosity-to-signal-severity.md | 4 +- ...ster-one-telemetry-logger-per-publisher.md | 4 +- .../telemetry-event-id-stable-unique.md | 4 +- ...sserterror-needs-expectederror-and-code.md | 4 +- ...-tests-must-lower-the-execution-context.md | 4 +- ...estisolation-belongs-on-the-test-runner.md | 4 +- ...del-attribute-governs-test-transactions.md | 4 +- .../knowledge/testing/ui-handlers-in-tests.md | 4 +- ...use-library-codeunits-for-test-fixtures.md | 4 +- ...lization-noun-phrase-vs-sentence-phrase.md | 2 +- ...age-resource-ajax-needs-withcredentials.md | 4 +- ...ddin-throttle-al-calls-and-payload-size.md | 4 +- ...ult-descending-sort-on-historical-pages.md | 4 +- .../knowledge/ui/grid-data-table-heuristic.md | 2 +- .../ui/group-labeled-first-child-exception.md | 4 +- microsoft/knowledge/ui/no-nested-grids.md | 2 +- .../semantic-style-in-cuegroup-exception.md | 2 +- ...styles-need-independent-textual-meaning.md | 4 +- .../ui/set-selection-filter-list-scope.md | 4 +- ...on-false-allowed-on-non-editable-fields.md | 2 +- ...how-caption-in-promptdialog-prompt-area.md | 2 +- .../ui/show-caption-in-repeater-allowed.md | 2 +- .../ui/show-caption-on-editable-fields.md | 4 +- ...wmandatory-on-code-required-page-fields.md | 4 +- .../ui/standalone-content-in-layout-table.md | 2 +- .../ui/style-expr-text-vs-boolean.md | 2 +- ...r-intent-requires-data-table-conditions.md | 2 +- ...-request-page-input-in-onqueryclosepage.md | 4 +- ...ing-changes-only-on-tables-without-data.md | 4 +- ...check-only-triggers-do-not-migrate-data.md | 4 +- .../upgrade/datatransfer-for-bulk-init.md | 4 +- ...transfer-skips-triggers-and-subscribers.md | 4 +- .../do-not-block-upgrade-on-data-errors.md | 4 +- .../upgrade/enum-values-additive-at-end.md | 4 +- .../first-install-dataversion-zero-check.md | 4 +- .../knowledge/upgrade/guard-database-reads.md | 4 +- ...initvalue-does-not-update-existing-rows.md | 4 +- ...ll-code-does-not-run-on-version-upgrade.md | 4 +- .../minimize-onvalidate-upgrade-triggers.md | 4 +- .../upgrade/no-external-calls-in-upgrade.md | 4 +- .../obsolete-pending-to-removed-staging.md | 4 +- .../obsoletion-requires-reason-and-tag.md | 4 +- .../register-upgrade-tags-with-subscribers.md | 4 +- ...nonessential-work-via-execution-context.md | 4 +- ...iggers-call-helpers-not-implementations.md | 4 +- .../upgrade/upgrade-codeunit-subtype.md | 4 +- .../use-upgrade-tags-not-version-checks.md | 4 +- ...lues-are-a-contract-by-name-not-ordinal.md | 4 +- ...write-operations-on-read-only-api-pages.md | 4 +- ...pose-only-committed-data-from-api-reads.md | 4 +- .../expose-operations-as-bound-actions.md | 4 +- .../expose-systemid-as-the-api-key.md | 4 +- ...-parts-on-systemid-and-set-multiplicity.md | 4 +- .../set-required-api-page-properties.md | 4 +- ...-adding-not-mutating-published-versions.md | 4 +- ...eligibility-and-validationtoken-renewal.md | 4 +- .../review/al-breaking-changes-review.md | 2 +- microsoft/skills/review/al-code-review.md | 4 +- .../skills/review/al-error-handling-review.md | 2 +- microsoft/skills/review/al-events-review.md | 2 +- .../skills/review/al-performance-review.md | 2 +- microsoft/skills/review/al-security-review.md | 2 +- skills/do.md | 23 +- skills/read.md | 6 +- skills/write.md | 14 +- 276 files changed, 1287 insertions(+), 756 deletions(-) create mode 100644 docs/contributing.md create mode 100644 docs/customizing-bcquality.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/using-bcquality.md diff --git a/.github/custom-layer-autoclose.md b/.github/custom-layer-autoclose.md index f0b059e..800d5fd 100644 --- a/.github/custom-layer-autoclose.md +++ b/.github/custom-layer-autoclose.md @@ -1,23 +1,19 @@ -Hey @{{AUTHOR}} 👋 +Thank you for contributing, @{{AUTHOR}}. -First off — thank you for jumping in and experimenting! It's awesome to see people pushing on the framework. 🎉 +This PR was closed automatically because it changes custom-layer content. +`custom/` is reserved for organization-specific knowledge and skills in +**your own fork**, not the shared upstream repository. -That said, let me gently redirect you, because I think there's a small but important misunderstanding about how the `custom` layer is meant to work: +Keep company-only rules in your fork and point your host at that copy. The +[customization guide](https://github.com/microsoft/BCQuality/blob/main/docs/customizing-bcquality.md) +shows the complete flow. -The `custom` layer in *this* repo isn't a destination for PRs — it's the designated sandbox inside **your own fork**. Think of it as the "your timeline" branch of the multiverse 🌌: this repo is canon, your fork is where you get to remix the lore without needing anyone's approval. That's the whole point of the layer existing — so you *don't* have to upstream your team-specific or experimental work. - -The intended workflow is: - -1. 🍴 **Fork** BCQuality to your own GitHub account -2. Clone *your fork* locally -3. Drop your custom agents and knowledge into the `custom` layer **there** -4. Commit and push to your fork — no PR back to upstream needed for custom stuff - -That way you get full control, your changes survive upstream updates cleanly, and you can pull in new core releases from this repo whenever you want. ✨ - -**Now — here's the fun part:** if while building out your fork you discover knowledge, patterns, or agents that you think would genuinely benefit *everyone* using BCQuality (not just your team), that's exactly what the `/community` layer is for! 🌟 PRs to `/community` here in the upstream repo are absolutely welcome and encouraged — it's how the collective hive mind 🧠 levels up. So please: tinker in your fork, and when you strike gold that's worth sharing, send it our way via `/community`. - -Going to close this PR for now (since it's targeting `custom` rather than `/community`), but please don't read it as a "no" — it's a "yes, but let's route it correctly." 🙏 Happy to help if you hit any snags spinning up your fork, and genuinely looking forward to seeing what you contribute to `/community` down the line. +If the guidance is useful to everyone, submit it to the layer that owns the +domain: Microsoft-owned domains belong under `microsoft/knowledge/`, even +when contributed by a partner; Community-owned domains belong under +`community/knowledge/`. See +[Contributing](https://github.com/microsoft/BCQuality/blob/main/docs/contributing.md). +BCQuality contains knowledge and skills, not agents.
Files in this PR that triggered the auto-close @@ -25,7 +21,5 @@ Going to close this PR for now (since it's targeting `custom` rather than `/comm {{FILES}}
-May your merges be conflict-free. 🚀 - ---- -🤖 This PR was closed automatically by the `Guard custom layer` workflow because it adds or changes content under `/custom/`. If you were only updating the template (`custom/README.md` or a `.gitkeep`), a maintainer can re-open it. If you think this was closed in error, just comment here. +Template changes to `custom/README.md` and `.gitkeep` files are allowed. If +you believe this closure was a mistake, comment here for maintainer review. diff --git a/.github/new-top-level-flag.md b/.github/new-top-level-flag.md index 3d297ea..9656922 100644 --- a/.github/new-top-level-flag.md +++ b/.github/new-top-level-flag.md @@ -3,7 +3,7 @@ {{ENTRIES}} -This isn't a block — just a flag. 🚩 New top-level folders and files are *usually* unintended (a stray export, a tool's scratch dir, or content that meant to land inside an existing layer like `/community/knowledge/`). BCQuality keeps a deliberately small root: `.github/`, `community/`, `custom/`, `microsoft/`, `skills/`, and `tools/`, plus a handful of root docs. +This isn't a block — just a flag. 🚩 New top-level folders and files are *usually* unintended (a stray export, a tool's scratch dir, or content that meant to land inside an existing layer). BCQuality keeps a deliberately small root: plugin metadata, `community/`, `custom/`, `microsoft/`, `skills/`, `tools/`, `docs/`, `evaluation/`, `.github/`, and a handful of root docs. Partner guides belong under `docs/`; shared knowledge belongs beside the skill that owns its domain. **If this was intentional** and the new entry genuinely belongs at the repo root, a maintainer can review and merge as normal — no action needed beyond a quick sanity check. **If it wasn't**, please move the content into the right existing layer (or drop it) and push an update. 🙏 diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index b0076cc..f422d53 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -45,7 +45,7 @@ ENTRY_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"} HOST_SKILL_REQUIRED_KEYS = {"name", "description"} STANDARD_INPUTS = { - "pr-diff", "object-list", "file-path", "repository", "telemetry-query", + "pr-diff", "object-list", "file-path", "folder-path", "repository", "telemetry-query", } ALLOWED_OUTPUTS = {"findings-report"} VALID_SAMPLE_KINDS = {"good", "bad"} diff --git a/.github/workflows/flag-new-top-level.yml b/.github/workflows/flag-new-top-level.yml index 31ab105..865a120 100644 --- a/.github/workflows/flag-new-top-level.yml +++ b/.github/workflows/flag-new-top-level.yml @@ -40,11 +40,12 @@ jobs: // Known, intended repository root. Anything else added at the root // is flagged for a human to eyeball. const ALLOWED_DIRS = new Set([ - '.claude-plugin', '.github', 'community', 'custom', 'microsoft', 'skills', 'tools', + '.claude-plugin', '.github', 'community', 'custom', 'docs', 'evaluation', + 'microsoft', 'skills', 'tools', ]); const ALLOWED_FILES = new Set([ '.gitignore', 'CODEOWNERS', 'LICENSE', 'README.md', - 'SECURITY.md', 'agent-consumption.md', + 'SECURITY.md', 'plugin.json', ]); const MARKER = ''; diff --git a/README.md b/README.md index 823db24..9eeee91 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,125 @@ # BCQuality -Quality skills and knowledge for Business Central development. +Quality skills and knowledge that help AI tools make better Business Central +development decisions: catch BC-specific defects, avoid misleading advice, +and explain findings with references you can read. -BCQuality is a curated knowledge base and skills library for Business Central. It provides structured, machine-readable guidance that development agents and tools can consume — establishing a consistent quality bar across tooling and teams. +BCQuality contains **knowledge and reusable skills**, not agents or a Business +Central extension. Your host supplies the agent. You can install the content +as a plugin, use it from another integration, or browse the knowledge directly. -## What belongs here +## Quick start -BCQuality is a remedial knowledge base. A file exists because a capable LLM **would get something wrong, or miss something, without it** — not because the topic is important. The admission test for a knowledge file is one question: - -> If this file did not exist, would a modern LLM reviewing or generating BC code make a mistake this file would have prevented? - -If the answer is no — the advice is generic software-engineering guidance, or the LLM already knows the BC mechanic in question — the file does not belong here, regardless of how sound the content is. A file earns its place by encoding something BC-specific that LLMs demonstrably get wrong: a CodeCop rule number, a platform API whose semantics the training data gets backwards, a non-obvious ordering rule, a BC property whose default is a footgun. - -Good fit: "`SetLoadFields` must be called before filters, not after" (non-obvious ordering rule). "`FindSet(true)` takes a LockTable and the two-parameter signature is obsolete" (subtle platform behaviour + outdated training data). "CodeCop AA0233 flags `FindFirst … Next` loops" (rule-specific). - -Poor fit: "Use HTTPS instead of HTTP." "Don't hardcode secrets." "Keep transactions short." These are true but any capable LLM already applies them without prompting. - -The practical consequence: when a code-review agent flags something it shouldn't have, or misses something it should have caught, the remedy is a new knowledge file. When it already behaves correctly on a topic, no file is needed. - -A file that *prevents* a false positive — documenting why a pattern is legitimate so the agent stops flagging it — is as valid as one that catches a defect: negative clarifications are first-class knowledge files. What never belongs is a BC fact hard-coded into a skill. Skills are finders and appliers; knowledge files are what the agent knows. See [`skills/do.md`](skills/do.md) and [`skills/write.md`](skills/write.md). - -## What's in this repo - -BCQuality contains **knowledge** and **skills**. It does not contain agents. -Agents that consume BCQuality are supplied by the host or orchestrator. - -### Knowledge files - -Atomic markdown files with YAML frontmatter. Each file covers one concern — one thing an agent would cite when reviewing or generating code. Knowledge files live in three layers: - -- **`/microsoft/`** — Microsoft-endorsed layer. - - `/microsoft/knowledge/` — Platform guardrails, official guidance. - - `/microsoft/skills/` — Microsoft-endorsed action skills. -- **`/community/`** — BC community layer. - - `/community/knowledge/` — Community patterns and shared guidance. - - `/community/skills/` — Community-contributed action skills. - -- **`/custom/`** — Partner- and customer-specific overrides. Empty by default; populated in forks. - - `/custom/knowledge/` — Organization-specific knowledge files. - - `/custom/skills/` — Organization-specific action skills. - -All three layers are enabled by default when an agent consumes BCQuality. In the shared upstream layers, an action skill and the canonical knowledge it owns should live together: knowledge used by a Microsoft-endorsed skill belongs in `/microsoft/`, while `/community/` holds community-owned skills and their related knowledge. A split is acceptable briefly while a skill or corpus is being promoted, but it should not be the steady state. The `/custom/` layer remains the intentional exception because it overrides shared content in consumer forks. - -Layer authority follows review and ownership, not the contributor's affiliation. Community contributions to a Microsoft-owned knowledge domain can therefore be accepted directly into `/microsoft/`; content can also be promoted from Community to Microsoft-endorsed once its owning skill is promoted. - -### Skills - -Skills define how agents consume knowledge. They come in three flavors: - -- **The entry-point skill** ([`skills/entry.md`](skills/entry.md)) — the first skill an agent invokes at runtime. Given a task context (goal, available inputs, technologies, BC version, etc.), it returns a **dispatch record** naming the action skill or skills to invoke next. Routing logic lives here, not in the orchestrator. - -- **Meta-skill contracts** (`/skills/`) — three stable references that define the rest of the repo: - 1. **Schema + Use** (READ, [`skills/read.md`](skills/read.md)) — how to read a knowledge file: interpret frontmatter, parse sections, understand layer precedence. Any agent or skill that reads knowledge files depends on it. - 2. **Action Skill** (DO, [`skills/do.md`](skills/do.md)) — the template every action skill follows. Defines the four-step pattern (Source → Relevance → Worklist → Action) and the structured output format that orchestrators expect. - 3. **New Knowledge** (WRITE, [`skills/write.md`](skills/write.md)) — how to author a valid knowledge file. References Schema + Use for the format specification and adds authoring rules (atomicity, section guidance). - - READ and DO are read on demand — typically when the first dispatched action skill runs. They are not prerequisites for invoking Entry. WRITE is only used when scaffolding new content. - -- **Action skills** — concrete skills that follow the Action Skill template to do real work (review code, audit telemetry, etc.). Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). An action skill is either a **leaf** that evaluates knowledge files directly, or a **super-skill** that composes other action skills (declared via `sub-skills` in frontmatter). The canonical reference is [`microsoft/skills/review/al-code-review.md`](microsoft/skills/review/al-code-review.md) (super-skill), which composes the AL review leaf skills under [`microsoft/skills/review/`](microsoft/skills/review/) — one per knowledge domain. - -### Agent bootstrapping - -A host or orchestrator points the agent at BCQuality and provides a task -context. The agent's first call is `/skills/entry.md`, which returns a dispatch -record naming the action skill(s) to invoke. The agent then invokes the -dispatched skills, reading READ and DO on demand. No prior knowledge of -BCQuality's structure is required beyond the convention *"invoke -`/skills/entry.md` first."* +The walkthrough below uses **GitHub Copilot CLI in a terminal**, not the +Copilot Chat panel in VS Code. First +[install Copilot CLI and sign in](https://docs.github.com/en/copilot/get-started/cli-quickstart). +Your account and organization policy must allow its use. You do not need to +clone BCQuality, build a runner, or deploy an app to Business Central for this +source-review example. ### Standalone plugin installation -BCQuality can also be installed directly as a plugin so supported hosts can -discover and invoke its host-native skills. The plugin currently registers -[`al-code-review`](skills/al-code-review/SKILL.md), which adapts the caller's -request to the same Entry protocol used by orchestrators. +Run these commands in your terminal: -For GitHub Copilot CLI: - -```shell +```powershell copilot plugin install microsoft/BCQuality +copilot plugin list ``` -#### Example: Review a complete app folder +The list should include `bcquality`. The plugin currently exposes the +[`al-code-review`](skills/al-code-review/SKILL.md) skill. Installation and skill +discovery are the general pattern; reviewing an app is one example of using it. -This example demonstrates the walk-up pattern with the currently exposed -review skill. Future host-native skills follow the same discovery and -invocation pattern; they do not each require a dedicated README walkthrough. +### Example: Review a complete app folder -1. Open the Business Central app folder in GitHub Copilot and start a fresh - session after installing the plugin. -2. Ask: +Start a **new** CLI session in your own app folder, replacing the example path: - > Use the installed `al-code-review` skill to review the complete Business - > Central app in this folder. Execute every dispatched review domain and - > return the complete BCQuality findings report. - -That is the complete walk-up flow. The folder does not need to be a Git -repository; BCQuality reviews `app.json` and the AL source below it. To pick up -a newer BCQuality release later, run: - -```shell -copilot plugin update bcquality +```powershell +cd "C:\Repos\MyBusinessCentralApp" +copilot ``` -The adapter is intentionally not a second review implementation: +Approve access only to a project you trust, then ask: -```text -standalone host skill: skills/al-code-review/SKILL.md - -> routing contract: skills/entry.md - -> review coordinator: microsoft/skills/review/al-code-review.md - -> domain review leaves -``` +> Use the installed al-code-review skill to review the complete Business Central +> app in this folder without changing my source files. Return the complete +> BCQuality findings report. -Only the first file follows the host's `SKILL.md` packaging format. The -remaining files are BCQuality's internal protocol and layered action skills. -Entry remains the single owner of routing and index preparation; -`al-code-review.md` remains the single owner of broad-review composition. This -separation keeps standalone installation available without duplicating those -policies in the plugin adapter. +The folder should contain `app.json` and your AL source; it does **not** need +to be a Git repository. On macOS or Linux, use your app's local path instead. -Note that a plugin install ships the entire tree, so `BCQUALITY_ENABLED_LAYERS` -narrows discovery without removing any files. Layer selection is a filter here, -not a deny mechanism — see [the adapter](skills/al-code-review/SKILL.md) for the -difference from the pruned-clone model. +Expect a report for each selected review, with findings, source locations, +severity, confidence, and references to the relevant guidance. Some hosts show +the structured JSON directly. `completed` with no findings means nothing was +flagged in that review's scope; `partial` or `failed` is **not** a clean result. +See [reading your results](docs/using-bcquality.md#reading-your-results). -The host adapter and internal action skill intentionally share the -`al-code-review` name: they expose the same operation in two different skill -formats. Their paths make the boundary explicit. The adapter lives under -`skills/al-code-review/SKILL.md`; the internal Microsoft-layer coordinator -lives at `microsoft/skills/review/al-code-review.md`. +[PowerShell 7](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) +(`pwsh`) is recommended for fast knowledge discovery. If it is unavailable, +the review can still discover knowledge by reading the folders. -Partners that want model selection, parallel leaf execution, retries, or usage -telemetry can add a thin runner outside BCQuality. See -[Build a lightweight standalone review runner](docs/standalone-runner.md) for the -integration contract and a minimal implementation checklist. Architecture and -partner guides are collected in the [documentation index](docs/README.md). +## Documentation -## Knowledge file format +| I want to... | Start here | +| --- | --- | +| Review a file, changes, a branch, or a particular concern | [Using BCQuality](docs/using-bcquality.md) | +| Resolve setup problems, incomplete reviews, or incorrect findings | [Troubleshooting and support](docs/troubleshooting.md) | +| Browse the available guidance | [Knowledge by domain](docs/using-bcquality.md#knowledge-by-domain) | +| Configure the plugin or use my organization's rules | [Customizing BCQuality](docs/customizing-bcquality.md) | +| Contribute knowledge or improve a rule | [Contributing](docs/contributing.md) | +| Connect a host, agent, or CI integration | [How agents consume BCQuality](docs/agent-consumption.md) | -Every knowledge file is a markdown file with mandatory YAML frontmatter. Files target under 100 lines (ideal under 50). If two ideas would share a file, split them. - -### Frontmatter schema (v1) - -```yaml ---- -bc-version: [all] # or [26..28], or [26..] for "26 and later" -domain: performance # security | performance | ux | telemetry | ... -keywords: [query, filtering, partial] # free-text tags for retrieval -technologies: [al] # al | javascript | powershell | ... -countries: [w1] # ISO codes, or [w1] -application-area: [all] # finance | manufacturing | jobs | [all] ---- -``` - -All six fields are required. The schema is locked — changes require a PR approved by both maintainers. - -### Sections - -Every knowledge file must contain a `## Description` section. The following sections are optional but recommended: - -- **`## Best Practice`** — the recommended approach -- **`## Anti Pattern`** — what to avoid and why - -Code examples belong in separate files, not in the knowledge file itself. Knowledge files must not contain fenced code blocks. +[All documentation and technical references](docs/README.md). ## Scope -The current curated corpus is focused on **technical AL code review**: Agents, AppSource and compatibility, data modeling, error handling, events, interfaces, performance, privacy, Query objects, security, style, telemetry, testing, UI, upgrade, and web services. These are the domains backed by knowledge files and registered review leaves today. +Today's curated content focuses on **technical AL code review**. It augments +the agent's judgment; it is not an exhaustive BC manual or a substitute for +compilation, analyzers, tests, or human review. See +[coverage and limits](docs/using-bcquality.md#coverage-and-limits) for the +available domains and the difference between a folder review and a comparison. -Business Central functional domains (Finance, Supply Chain Management, Manufacturing, Jobs, Warehousing, Service), PowerShell, pipelines, and Power Platform remain valid future repository scope, but they are **not current coverage claims** until corresponding knowledge and action skills exist. Consumers should derive supported review scope from the live knowledge index and dispatched skills, not from roadmap breadth. +Functional areas such as Finance, Supply Chain Management, Manufacturing, Jobs, +Warehousing, and Service, and technologies such as PowerShell, pipelines, and +Power Platform, remain valid future scope, **not current coverage claims**. -## How agents consume BCQuality +## What's in this repo -Action skills follow a four-step pattern: +Knowledge articles cover one concern each. Skills tell an agent how to find +and apply the relevant knowledge. Both live in three layers: -1. **Source** — which knowledge folders and tags to search -2. **Relevance** — filter by frontmatter (version, technology, country, area) -3. **Worklist** — narrow from N candidates to the M that apply to the current task -4. **Action** — apply the relevant knowledge and produce structured output +| Layer | Purpose | +| --- | --- | +| [Microsoft](microsoft/) | Microsoft-endorsed skills and their knowledge. | +| [Community](community/) | Community-owned skills and their knowledge. | +| [Custom](custom/) | Organization-specific additions and overrides in your own fork. | -Every action skill produces output in a common format that orchestrators can consume without skill-specific parsing. The format is JSON and includes an `outcome` (so a clean run, a not-applicable skill, and a partial failure are all distinguishable), `findings` (what the skill observed), structured `references` back to the knowledge files that informed each finding, per-finding `confidence`, and a `suppressed` list recording any knowledge files overridden by layer precedence. This contract is defined in the Action Skill meta-skill so that orchestrators and action skills remain independently evolvable. - -BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. Super-skills (such as `al-code-review`) run a self-review pass alongside their sub-skills and surface concerns the agent identified on its own, marked with `from-sub-skill: "agent"` and an empty `references: []` so consumers can render them distinctly from knowledge-backed findings. See [How agents consume BCQuality](docs/agent-consumption.md) and [`skills/do.md`](skills/do.md) for the full contract. - -The meta-skills in `/skills/` define this pattern. Every concrete action skill follows it. - -For the end-to-end flow — from orchestrator trigger through to how output reaches developers — see [How agents consume BCQuality](docs/agent-consumption.md). - -## Repository structure - -``` -├── /skills/ # Global: entry-point skill + meta-skill contracts (READ, DO, WRITE) -├── /docs/ # Architecture and partner integration guides -├── /evaluation/ # Neutral good/bad review fixtures and scoring contract -├── /.github/ # Actions and workflows -├── /microsoft/ # Microsoft-endorsed layer -│ ├── /knowledge/ # Knowledge files by domain -│ │ └── // # Each article: .md + optional .good.al / .bad.al -│ └── /skills/ # Microsoft-endorsed action skills -├── /community/ # BC community layer -│ ├── /knowledge/ # Knowledge files by domain -│ │ └── // # Article + sibling samples, same convention -│ └── /skills/ # Community action skills -├── /custom/ # Partner/customer-specific overrides (empty; populated in forks) -│ ├── /knowledge/ -│ └── /skills/ -``` +All three are enabled by default; Custom is empty upstream. You do not need +to configure layers to get started. ## Versioning -BCQuality content is released on demand — roughly monthly, not on every commit. A -release is a `major.minor` value derived from git tags, cut manually via the -`Release version` workflow: pick whether to bump the minor or the major, and it -computes the next version and tags the current `main` as `v{major}.{minor}`. +Update the installed plugin from your terminal, then start a new session: -- Bump the **minor** for the usual periodic content update; bump the **major** - only for a breaking change. -- The minor is a **monotonic counter** — it only ever increments and never - resets, even across a major bump — so it uniquely identifies a release. +```powershell +copilot plugin update bcquality +``` + +Plugin versions and content-release tags are different. For reproducible runs +and organization forks, see [updates and versions](docs/customizing-bcquality.md#updates-and-versions). + +## What belongs here + +Knowledge belongs here when it prevents a BC-specific mistake an otherwise +capable agent would make, including false-positive findings. BC facts belong +in knowledge articles, not skill instructions. See the +[admission test and examples](docs/contributing.md#what-belongs-here). ## Contributing -Contributions are welcome. Before submitting a PR: - -1. Read the knowledge file format above — frontmatter and sections are validated by CI. -2. Keep files atomic: one concern per file, under 100 lines. -3. Target your contribution to the layer that owns the action skill: use `/microsoft/knowledge/` for Microsoft-owned domains and `/community/knowledge/` for knowledge that accompanies a community-owned skill. -4. Adding a BC fact — or stopping the agent from flagging a false positive — is a knowledge file, not a skill edit. If a PR changes *what* a review skill flags, the change almost certainly belongs in a knowledge file. See [`skills/write.md`](skills/write.md). - -CI runs validation on every PR. If your knowledge file has schema violations, missing sections, code blocks, or exceeds 100 lines, the check will fail with a clear error message. - -Companion samples must be referenced by filename from their article, and every referenced sample must exist. The review evaluation corpus under [`evaluation/`](evaluation/) adds one positive and one clean control for every registered AL review leaf; see [`evaluation/README.md`](evaluation/README.md) for credential-free validation and optional fast-model scoring. +Partners are welcome to contribute to the layer that owns the domain, +regardless of affiliation. Start with the [contribution guide](docs/contributing.md). +To report a problem without authoring a rule, see [support](docs/troubleshooting.md#reporting-a-problem). ## License diff --git a/community/knowledge/agents/agent-permissions-intersect-with-assigner.md b/community/knowledge/agents/agent-permissions-intersect-with-assigner.md index 2259f66..7c85a5b 100644 --- a/community/knowledge/agents/agent-permissions-intersect-with-assigner.md +++ b/community/knowledge/agents/agent-permissions-intersect-with-assigner.md @@ -17,13 +17,13 @@ An agent is a user, but it cannot configure users or other agents, and it cannot Document that intersection. Give the agent only the table and page rights its tasks need. Do not add user-setup or permission-assignment pages to the agent profile or permission sets; those operations will fail by design. -See sample: `agent-permissions-intersect-with-assigner.good.al`. +See sample: [`agent-permissions-intersect-with-assigner.good.al`](agent-permissions-intersect-with-assigner.good.al). ## Anti Pattern Permission sets or profiles that include User card, Permission Set Assignment, or agent-admin pages, or comments that the agent runs as SUPER regardless of who assigned it. Detection signal: default access controls or profile including user-administration objects. -See sample: `agent-permissions-intersect-with-assigner.bad.al`. +See sample: [`agent-permissions-intersect-with-assigner.bad.al`](agent-permissions-intersect-with-assigner.bad.al). ## See also diff --git a/community/knowledge/agents/agent-profile-narrows-visible-ui.md b/community/knowledge/agents/agent-profile-narrows-visible-ui.md index 30d286a..914a777 100644 --- a/community/knowledge/agents/agent-profile-narrows-visible-ui.md +++ b/community/knowledge/agents/agent-profile-narrows-visible-ui.md @@ -17,13 +17,13 @@ The agent only sees what its profile shows. Extra actions, views, and Role Cente Ship an agent-specific profile and page customizations: hide unrelated actions, keep descriptive tooltips, add Role Center links to the few pages the agent should open. Prefer fewer navigation hops. -See sample: `agent-profile-narrows-visible-ui.good.al`. +See sample: [`agent-profile-narrows-visible-ui.good.al`](agent-profile-narrows-visible-ui.good.al). ## Anti Pattern Assigning `BUSINESS MANAGER` or `ORDER PROCESSOR` as `GetDefaultProfile` so the agent can do anything. Detection signal: default profile equal to a full-user role with no agent page customizations. -See sample: `agent-profile-narrows-visible-ui.bad.al`. +See sample: [`agent-profile-narrows-visible-ui.bad.al`](agent-profile-narrows-visible-ui.bad.al). ## See also diff --git a/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md index d844d33..083a83b 100644 --- a/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md +++ b/community/knowledge/agents/agent-setup-page-is-configuration-dialog.md @@ -17,10 +17,10 @@ Instance setup is not a Card or StandardDialog. The toolkit expects `PageType = Declare `PageType = ConfigurationDialog`, host `part(...; "Agent Setup Part")`, and put agent-specific fields in another group. Keep system OK/Cancel. Use a temporary source record and defer persistence until Update, as described in `agent-setup-source-table-is-temporary.md`. Following Microsoft's agent setup samples, set `Extensible = false`. -See sample: `agent-setup-page-is-configuration-dialog.good.al`. +See sample: [`agent-setup-page-is-configuration-dialog.good.al`](agent-setup-page-is-configuration-dialog.good.al). ## Anti Pattern A Card or StandardDialog setup page with no `Agent Setup Part`. Detection signal: setup page ID from `IAgentFactory` / `IAgentMetadata` whose page is not `ConfigurationDialog` or has no `Agent Setup Part`. -See sample: `agent-setup-page-is-configuration-dialog.bad.al`. +See sample: [`agent-setup-page-is-configuration-dialog.bad.al`](agent-setup-page-is-configuration-dialog.bad.al). diff --git a/community/knowledge/agents/agent-setup-source-table-is-temporary.md b/community/knowledge/agents/agent-setup-source-table-is-temporary.md index 8f31aa0..539bc0f 100644 --- a/community/knowledge/agents/agent-setup-source-table-is-temporary.md +++ b/community/knowledge/agents/agent-setup-source-table-is-temporary.md @@ -17,13 +17,13 @@ ConfigurationDialog setup is a draft: the user can Cancel without writing. That Mark the page `SourceTableTemporary = true`. Copy into the temp record on open. Persist the Agent Setup buffer and custom fields only from the close path when the action is not Cancel, using `Agent Setup.GetChangesMade` / `SaveChanges`. -See sample: `agent-setup-source-table-is-temporary.good.al`. +See sample: [`agent-setup-source-table-is-temporary.good.al`](agent-setup-source-table-is-temporary.good.al). ## Anti Pattern A non-temporary source table, or `Insert`/`Modify` on the persisted setup row from field OnValidate. Detection signal: agent `ConfigurationDialog` without `SourceTableTemporary = true`, or database writes before Update. -See sample: `agent-setup-source-table-is-temporary.bad.al`. +See sample: [`agent-setup-source-table-is-temporary.bad.al`](agent-setup-source-table-is-temporary.bad.al). ## See also diff --git a/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md index c59a9f1..c8a8671 100644 --- a/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md +++ b/community/knowledge/agents/agent-setup-table-keyed-by-user-security-id.md @@ -17,10 +17,10 @@ Each agent instance is a user. Instance-specific setup is keyed by that user's ` Give the setup table a Guid field `User Security ID` as the clustered primary key. Other settings are attributes of that key. When the page opens, `Get` or insert by the Guid the Agent Setup part already holds. -See sample: `agent-setup-table-keyed-by-user-security-id.good.al`. +See sample: [`agent-setup-table-keyed-by-user-security-id.good.al`](agent-setup-table-keyed-by-user-security-id.good.al). ## Anti Pattern A setup table keyed by Code, Integer, or with no Guid user key, then mapping one row to every instance. Detection signal: source table of the agent setup page whose primary key is not `User Security ID`. -See sample: `agent-setup-table-keyed-by-user-security-id.bad.al`. +See sample: [`agent-setup-table-keyed-by-user-security-id.bad.al`](agent-setup-table-keyed-by-user-security-id.bad.al). diff --git a/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md index f8c6eb5..9c465f4 100644 --- a/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md +++ b/community/knowledge/agents/analyze-message-error-stops-warning-forces-review.md @@ -17,10 +17,10 @@ application-area: [all] Validate inbound payloads in analysis: Error when the task must not run; Warning when a human must confirm. For outbound messages, adjust text in this method rather than in a later subscriber. Do not rely on skip-review to bypass warnings. -See sample: `analyze-message-error-stops-warning-forces-review.good.al`. +See sample: [`analyze-message-error-stops-warning-forces-review.good.al`](analyze-message-error-stops-warning-forces-review.good.al). ## Anti Pattern Ignoring analysis entirely, or emitting Warning while documenting that `SetRequiresReview(false)` means unattended run. Detection signal: empty `AnalyzeAgentTaskMessage` plus skip-review on external input. -See sample: `analyze-message-error-stops-warning-forces-review.bad.al`. +See sample: [`analyze-message-error-stops-warning-forces-review.bad.al`](analyze-message-error-stops-warning-forces-review.bad.al). diff --git a/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md index 3d18818..be227f4 100644 --- a/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md +++ b/community/knowledge/agents/bind-agent-subscribers-only-in-agent-session.md @@ -17,10 +17,10 @@ Page-filter tweaks, extra validation, and prompt dialogs for the agent should no On `OnAfterInitialization`, exit unless `Agent Session.IsAgentSession`. Then `BindSubscription` a single-instance codeunit that holds the current task id. Keep those subscribers internal. -See sample: `bind-agent-subscribers-only-in-agent-session.good.al`. +See sample: [`bind-agent-subscribers-only-in-agent-session.good.al`](bind-agent-subscribers-only-in-agent-session.good.al). ## Anti Pattern Event subscribers on `Sales Header` OnAfterInsert that always `Message` the agent, with no `IsAgentSession` guard. Detection signal: agent-only behaviour in a static subscriber that is not bind-gated. -See sample: `bind-agent-subscribers-only-in-agent-session.bad.al`. +See sample: [`bind-agent-subscribers-only-in-agent-session.bad.al`](bind-agent-subscribers-only-in-agent-session.bad.al). diff --git a/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md index 41c1c2f..7d44699 100644 --- a/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md +++ b/community/knowledge/agents/cross-app-agent-calls-need-your-public-api.md @@ -17,10 +17,10 @@ For isolation, `Agent`, `Agent Task Builder`, and related toolkit codeunits erro Expose a public codeunit in the agent app (`Access = Public`) whose procedures take `User Security ID` and forward to `Agent` / `Agent Task Builder`. Document that surface as the integration contract. Keep toolkit calls inside that app. -See sample: `cross-app-agent-calls-need-your-public-api.good.al`. +See sample: [`cross-app-agent-calls-need-your-public-api.good.al`](cross-app-agent-calls-need-your-public-api.good.al). ## Anti Pattern From app B, calling `Agent.SetDisplayName` or `Agent.Create` with app A's metadata provider. Detection signal: toolkit agent APIs used with an `Agent Metadata Provider` value not declared in the same app. -See sample: `cross-app-agent-calls-need-your-public-api.bad.al`. +See sample: [`cross-app-agent-calls-need-your-public-api.bad.al`](cross-app-agent-calls-need-your-public-api.bad.al). diff --git a/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md index 41d0573..2018de4 100644 --- a/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md +++ b/community/knowledge/agents/do-not-create-agents-in-install-upgrade-or-background.md @@ -17,10 +17,10 @@ application-area: [all] Create instances from a setup page, a wizard, or another UI-driven path after the user is in a client session. Apply instructions and `Activate` there. For existing companies after an upgrade, document that an admin must open setup; do not create from the upgrade codeunit. -See sample: `do-not-create-agents-in-install-upgrade-or-background.good.al`. +See sample: [`do-not-create-agents-in-install-upgrade-or-background.good.al`](do-not-create-agents-in-install-upgrade-or-background.good.al). ## Anti Pattern `Agent.Create` inside `OnInstallAppPerCompany`, `OnUpgradePerCompany`, or a job-queue codeunit. The call fails at runtime even if it compiles. Detection signal: `Agent.Create` in `Subtype = Install`, `Subtype = Upgrade`, or a non-UI session. -See sample: `do-not-create-agents-in-install-upgrade-or-background.bad.al`. +See sample: [`do-not-create-agents-in-install-upgrade-or-background.bad.al`](do-not-create-agents-in-install-upgrade-or-background.bad.al). diff --git a/community/knowledge/agents/get-default-access-controls-least-privilege.md b/community/knowledge/agents/get-default-access-controls-least-privilege.md index 87349af..c82f71a 100644 --- a/community/knowledge/agents/get-default-access-controls-least-privilege.md +++ b/community/knowledge/agents/get-default-access-controls-least-privilege.md @@ -17,13 +17,13 @@ application-area: [all] Insert only the permission sets the agent needs. For an AL `permissionset` object, use `Scope::System` and the ID of the app that defines it. Recreate permission sets that exist only as user-defined configuration in Business Central as AL objects first. Prefer a dedicated permission set over a full-user role. -See sample: `get-default-access-controls-least-privilege.good.al`. +See sample: [`get-default-access-controls-least-privilege.good.al`](get-default-access-controls-least-privilege.good.al). ## Anti Pattern Empty `GetDefaultAccessControls`, or inserting `SUPER` / `D365 BUS FULL ACCESS` because it made the demo work. Detection signal: Role ID on the default buffer that is a full-user role, or a set that is not in the app. -See sample: `get-default-access-controls-least-privilege.bad.al`. +See sample: [`get-default-access-controls-least-privilege.bad.al`](get-default-access-controls-least-privilege.bad.al). ## See also diff --git a/community/knowledge/agents/get-default-profile-lives-in-the-app.md b/community/knowledge/agents/get-default-profile-lives-in-the-app.md index 25103b5..89c19fb 100644 --- a/community/knowledge/agents/get-default-profile-lives-in-the-app.md +++ b/community/knowledge/agents/get-default-profile-lives-in-the-app.md @@ -17,13 +17,13 @@ application-area: [all] Ship a `profile` object (and page customizations) in the app. In `GetDefaultProfile`, call `Agent.PopulateDefaultProfile` with that profile ID and `NavApp.GetCurrentModuleInfo`. Include UI-exported customizations as AL. -See sample: `get-default-profile-lives-in-the-app.good.al`. +See sample: [`get-default-profile-lives-in-the-app.good.al`](get-default-profile-lives-in-the-app.good.al). ## Anti Pattern Setting `TempAllProfile."Profile ID"` to a client-only profile, or skipping `GetDefaultProfile`. Detection signal: factory default profile ID with no matching `profile` object in the app. -See sample: `get-default-profile-lives-in-the-app.bad.al`. +See sample: [`get-default-profile-lives-in-the-app.bad.al`](get-default-profile-lives-in-the-app.bad.al). ## See also diff --git a/community/knowledge/agents/instruction-structure-is-role-rules-steps.md b/community/knowledge/agents/instruction-structure-is-role-rules-steps.md index 1b56625..4a928fd 100644 --- a/community/knowledge/agents/instruction-structure-is-role-rules-steps.md +++ b/community/knowledge/agents/instruction-structure-is-role-rules-steps.md @@ -17,13 +17,13 @@ The runtime treats instructions as the agent's standing prompt. A one-line goal Store a document that states responsibilities, then non-negotiable guidelines (when to request a review, when not to post), then numbered steps for each task. Keep that text in the resource you pass to `SetInstructions`. -See sample: `instruction-structure-is-role-rules-steps.good.al`. +See sample: [`instruction-structure-is-role-rules-steps.good.al`](instruction-structure-is-role-rules-steps.good.al). ## Anti Pattern A single sentence such as Check customer credit for the sales order. Detection signal: instruction resource or `SetInstructions` payload with no responsibilities / guidelines / steps sections. -See sample: `instruction-structure-is-role-rules-steps.bad.al`. +See sample: [`instruction-structure-is-role-rules-steps.bad.al`](instruction-structure-is-role-rules-steps.bad.al). ## See also diff --git a/community/knowledge/agents/instructions-describe-work-not-tool-ids.md b/community/knowledge/agents/instructions-describe-work-not-tool-ids.md index a1a536a..1487364 100644 --- a/community/knowledge/agents/instructions-describe-work-not-tool-ids.md +++ b/community/knowledge/agents/instructions-describe-work-not-tool-ids.md @@ -17,13 +17,13 @@ Agent tools are the UI the profile exposes. Action names and tool ids change acr Write steps as business outcomes (release the order, set the hold reason). Tell the agent to memorize identifiers it must reuse. Do not hard-code action captions or tool ids. -See sample: `instructions-describe-work-not-tool-ids.good.al`. +See sample: [`instructions-describe-work-not-tool-ids.good.al`](instructions-describe-work-not-tool-ids.good.al). ## Anti Pattern Instructions that say invoke SalesOrder.Post_Promoted or use tool page-42-action-3. Detection signal: instruction text containing Promoted action names or tool identifiers. -See sample: `instructions-describe-work-not-tool-ids.bad.al`. +See sample: [`instructions-describe-work-not-tool-ids.bad.al`](instructions-describe-work-not-tool-ids.bad.al). ## See also diff --git a/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md index 5345d25..db95258 100644 --- a/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md +++ b/community/knowledge/agents/reapply-resource-instructions-on-upgrade.md @@ -17,10 +17,10 @@ Static instructions stored as an app resource are copied onto an instance only w In the upgrade codeunit, find existing instances of your metadata provider and call `SetInstructions` again with `NavApp.GetResourceAsText`. Guard with an upgrade tag so the rewrite runs once per version that changes the file. -See sample: `reapply-resource-instructions-on-upgrade.good.al`. +See sample: [`reapply-resource-instructions-on-upgrade.good.al`](reapply-resource-instructions-on-upgrade.good.al). ## Anti Pattern Editing only the resource file, or calling `SetInstructions` solely from the first-time setup path. Detection signal: instruction resource in `resourceFolders` with no upgrade procedure that re-applies it. -See sample: `reapply-resource-instructions-on-upgrade.bad.al`. +See sample: [`reapply-resource-instructions-on-upgrade.bad.al`](reapply-resource-instructions-on-upgrade.bad.al). diff --git a/community/knowledge/agents/register-copilot-capability-for-the-agent.md b/community/knowledge/agents/register-copilot-capability-for-the-agent.md index 79dfe44..59171cc 100644 --- a/community/knowledge/agents/register-copilot-capability-for-the-agent.md +++ b/community/knowledge/agents/register-copilot-capability-for-the-agent.md @@ -17,13 +17,13 @@ Each agent type needs a `Copilot Capability` enum value that the factory links a Extend `Copilot Capability` with a unique value. In `OnInstallAppPerDatabase`, call `Copilot Capability.IsCapabilityRegistered` and, if false, `RegisterCapability` with availability, billing type, and a learn-more URL. Point `IAgentFactory` at that capability. -See sample: `register-copilot-capability-for-the-agent.good.al`. +See sample: [`register-copilot-capability-for-the-agent.good.al`](register-copilot-capability-for-the-agent.good.al). ## Anti Pattern Shipping the agent enum without a `Copilot Capability` value, or adding the enum but never calling `RegisterCapability`. Duplicate ordinals across extensions also collide. Detection signal: agent metadata provider with no matching capability registration in an install codeunit. -See sample: `register-copilot-capability-for-the-agent.bad.al`. +See sample: [`register-copilot-capability-for-the-agent.bad.al`](register-copilot-capability-for-the-agent.bad.al). ## See also diff --git a/community/knowledge/agents/set-instructions-as-secrettext.md b/community/knowledge/agents/set-instructions-as-secrettext.md index 0f246f6..4c52863 100644 --- a/community/knowledge/agents/set-instructions-as-secrettext.md +++ b/community/knowledge/agents/set-instructions-as-secrettext.md @@ -17,10 +17,10 @@ Instructions are instance data, not an enum caption. `Agent.SetInstructions` tak Load instruction text from a resource or builder into a `SecretText` variable and call `Agent.SetInstructions(AgentUserSecurityId, Instructions)` after `Create`. Keep one instruction document per instance. -See sample: `set-instructions-as-secrettext.good.al`. +See sample: [`set-instructions-as-secrettext.good.al`](set-instructions-as-secrettext.good.al). ## Anti Pattern Passing a `Label` or `Text` to `SetInstructions`, storing instructions in a setup Text field without wrapping as `SecretText`, or putting the prompt only in a code comment. Detection signal: `SetInstructions` with a non-`SecretText` argument, or no `SetInstructions` after `Create`. -See sample: `set-instructions-as-secrettext.bad.al`. +See sample: [`set-instructions-as-secrettext.bad.al`](set-instructions-as-secrettext.bad.al). diff --git a/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md index 1eb151d..7a935e1 100644 --- a/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md +++ b/community/knowledge/agents/show-can-create-agent-does-not-block-code-create.md @@ -17,10 +17,10 @@ application-area: [all] Use `ShowCanCreateAgent` to decide discovery. If only agent administrators should see the type, return `Agent System Permissions.CurrentUserHasCanManageAllAgentsPermission`. Enforce extra policy inside your own create API. Never assume UI hiding blocks code. -See sample: `show-can-create-agent-does-not-block-code-create.good.al`. +See sample: [`show-can-create-agent-does-not-block-code-create.good.al`](show-can-create-agent-does-not-block-code-create.good.al). ## Anti Pattern Returning `exit(false)` from `ShowCanCreateAgent` and then documenting that instances cannot be created, while page actions or other apps still call `Agent.Create`. Detection signal: `ShowCanCreateAgent` always false with no matching guard on programmatic create. -See sample: `show-can-create-agent-does-not-block-code-create.bad.al`. +See sample: [`show-can-create-agent-does-not-block-code-create.bad.al`](show-can-create-agent-does-not-block-code-create.bad.al). diff --git a/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md index 449038f..ad91408 100644 --- a/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md +++ b/community/knowledge/agents/skip-incoming-review-only-for-trusted-input.md @@ -17,10 +17,10 @@ Incoming task messages default to requiring user approval before the agent runs. Leave the default review-on for anything that originated outside your extension. Call `SetRequiresReview(false)` only on messages you constructed from already-authorized BC data. -See sample: `skip-incoming-review-only-for-trusted-input.good.al`. +See sample: [`skip-incoming-review-only-for-trusted-input.good.al`](skip-incoming-review-only-for-trusted-input.good.al). ## Anti Pattern `SetRequiresReview(false)` on simulated email, incoming webhooks, or user-free text. Detection signal: `SetRequiresReview(false)` next to external content with no prior validation. -See sample: `skip-incoming-review-only-for-trusted-input.bad.al`. +See sample: [`skip-incoming-review-only-for-trusted-input.bad.al`](skip-incoming-review-only-for-trusted-input.bad.al). diff --git a/community/knowledge/agents/use-documented-instruction-keywords.md b/community/knowledge/agents/use-documented-instruction-keywords.md index 2150a5d..a65f240 100644 --- a/community/knowledge/agents/use-documented-instruction-keywords.md +++ b/community/knowledge/agents/use-documented-instruction-keywords.md @@ -17,13 +17,13 @@ The agent runtime looks for specific phrases: ask for assistance, request a revi In the instruction resource, use those keywords at the decision points: request a review before posting; write an email only after stating that outbound mail is reviewed; memorize values the later steps need. Pair `Reply` / `Write an email` with an explicit review sentence. -See sample: `use-documented-instruction-keywords.good.al`. +See sample: [`use-documented-instruction-keywords.good.al`](use-documented-instruction-keywords.good.al). ## Anti Pattern Inventing tool-like verbs (call Copilot, click Post_Promoted) or omitting request a review before posting. Detection signal: instruction text that says email the customer with no review keyword. -See sample: `use-documented-instruction-keywords.bad.al`. +See sample: [`use-documented-instruction-keywords.bad.al`](use-documented-instruction-keywords.bad.al). ## See also diff --git a/community/knowledge/agents/wire-all-three-agent-interfaces.md b/community/knowledge/agents/wire-all-three-agent-interfaces.md index d3ce4b2..87e859f 100644 --- a/community/knowledge/agents/wire-all-three-agent-interfaces.md +++ b/community/knowledge/agents/wire-all-three-agent-interfaces.md @@ -17,13 +17,13 @@ An AL agent type is registered by extending `Agent Metadata Provider`. The platf On the enum value, set `Implementation` for all three interfaces, each pointing at a dedicated codeunit. Keep factory (create, defaults, first-time setup), metadata (setup page, summary, annotations), and task execution (message analysis, intervention suggestions) in separate objects. -See sample: `wire-all-three-agent-interfaces.good.al`. +See sample: [`wire-all-three-agent-interfaces.good.al`](wire-all-three-agent-interfaces.good.al). ## Anti Pattern An `Agent Metadata Provider` value with no `Implementation`, only one interface mapped, or all three interfaces pointing at one catch-all codeunit that cannot satisfy the contracts. Detection signal: enumextension of `Agent Metadata Provider` whose value does not list `IAgentFactory`, `IAgentMetadata`, and `IAgentTaskExecution`. -See sample: `wire-all-three-agent-interfaces.bad.al`. +See sample: [`wire-all-three-agent-interfaces.bad.al`](wire-all-three-agent-interfaces.bad.al). ## See also diff --git a/community/skills/review/al-agents-review.md b/community/skills/review/al-agents-review.md index 4bc2a6b..80dadb0 100644 --- a/community/skills/review/al-agents-review.md +++ b/community/skills/review/al-agents-review.md @@ -4,7 +4,7 @@ id: al-agents-review version: 1 title: AL agents review description: Reviews AL source changes against agent guidance from BCQuality. -inputs: [pr-diff, file-path] +inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] technologies: [al] @@ -18,7 +18,10 @@ Reviews AL source changes against the `agents` knowledge domain in BCQuality and Agent findings apply to AL files that implement or invoke Agent SDK surfaces, including agent interfaces, setup, creation, task execution, capability registration, profiles, access controls, instructions, and session-bound subscribers. Return `not-applicable` when the diff contains no AL changes or no Agent SDK implementation or usage. -An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces one JSON document conforming to the DO output contract. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or +`folder-path`. For a folder, review all relevant source below it under DO's +current-state input semantics. The skill produces one JSON document +conforming to the DO output contract. ## Source diff --git a/custom/README.md b/custom/README.md index 28d7aa9..2719df8 100644 --- a/custom/README.md +++ b/custom/README.md @@ -12,6 +12,17 @@ custom/ ## How to use -Fork or clone BCQuality into your own repository and add your content here. Knowledge files in `/custom/knowledge/` follow the same frontmatter schema and section requirements as every other layer. Action skills in `/custom/skills/` follow the Action Skill template defined in `/skills/`. +Use a fork or organization-controlled copy of BCQuality, not the upstream +repository or your AL app's source folder. Confirm `git remote get-url origin` +points at your repository before adding custom content. Upstream does not +accept custom rules. -When agents consume BCQuality, the custom layer is loaded alongside Microsoft and Community — your overrides apply automatically. +Follow [Customizing BCQuality](../docs/customizing-bcquality.md) for a worked +rule, plugin configuration, installing your fork, and keeping it up to date. +Adding a rule here does not update an existing upstream plugin installation; +your host must consume your copy. + +Knowledge files follow [READ](../skills/read.md) and action skills follow +[DO](../skills/do.md). With the Custom layer enabled, applicable custom +knowledge overrides contradictory Community or Microsoft guidance. The report +records the displaced article; non-conflicting guidance remains additive. diff --git a/docs/README.md b/docs/README.md index 466b98e..1d7248b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,32 @@ -# Documentation +# BCQuality documentation -- [How agents consume BCQuality](agent-consumption.md) explains the operational - flow from Entry dispatch through structured findings and integration. -- [Build a lightweight standalone review runner](standalone-runner.md) explains - one concrete walk-up skill flow and how an external runner can add model - selection, concurrency, retries, and telemetry without moving orchestration - into BCQuality. +**New to BCQuality? Start with the [quick start](../README.md#quick-start).** +Install the plugin, discover its skills, and try an app review. No knowledge +of BCQuality's internal protocol is needed. + +## Partner guides + +| Goal | Guide | +| --- | --- | +| Review an app, file, changes, or branch | [Using BCQuality](using-bcquality.md) | +| Understand a report and its limitations | [Reading your results](using-bcquality.md#reading-your-results) | +| Find a particular rule or example | [Knowledge by domain](using-bcquality.md#knowledge-by-domain) | +| Fix setup problems or report an incorrect finding | [Troubleshooting and support](troubleshooting.md) | +| Select layers, add company rules, or maintain a fork | [Customizing BCQuality](customizing-bcquality.md) | +| Add or improve shared knowledge | [Contributing](contributing.md) | + +## Integration and technical reference + +These pages are for people building integrations or maintaining skills, not +prerequisites for using the plugin. + +| Reference | Purpose | +| --- | --- | +| [How agents consume BCQuality](agent-consumption.md) | Architecture, repository structure, routing, and delivery of findings. | +| [Standalone runner](standalone-runner.md) | Optional model selection, scheduling, retries, and telemetry. | +| [Global skills](../skills/README.md) | Host adapters versus internal protocol files. | +| [Entry](../skills/entry.md) | Task context and skill dispatch. | +| [READ](../skills/read.md) | Knowledge schema, applicability, and precedence. | +| [DO](../skills/do.md) | Action-skill format and structured output contract. | +| [WRITE](../skills/write.md) | Knowledge-authoring rules. | +| [Review evaluation](../evaluation/README.md) | Sample conventions, fixture preparation, and scoring. | diff --git a/docs/agent-consumption.md b/docs/agent-consumption.md index 2cb3761..1bf4284 100644 --- a/docs/agent-consumption.md +++ b/docs/agent-consumption.md @@ -5,13 +5,15 @@ supplied by a host or orchestrator. This document explains the end-to-end flow so that skill authors, orchestrator maintainers, and contributors share one mental model. -For the high-level framing and repo structure, start with the -[README](../README.md). This document is the operational view. +[Documentation](README.md) | [Partner quick start](../README.md#quick-start) | [Runner contract](standalone-runner.md) + +This is the operational reference for integration authors. Partners using +the installed plugin do not need to implement this flow themselves. ## The actors - **Orchestrator** — the tool that triggers work. Lives *outside* BCQuality. Knows *when* to run something, not *what* to run. -- **Agent** — an LLM-driven process spawned by the orchestrator. The agent has no built-in knowledge of BC or of BCQuality's conventions. It knows how to read instructions and call tools. +- **Agent** — an LLM-driven process supplied by the host. It brings its own coding knowledge and tools; BCQuality adds curated guidance and execution contracts. - **BCQuality repo** — two kinds of content: - **Global skills** in `/skills/` — the `entry.md` entry-point skill plus the READ · DO · WRITE contracts that govern the rest of the repo. - **Layer content** in `/microsoft/`, `/community/`, and `/custom/` — knowledge files and action skills grouped by authority. @@ -20,6 +22,25 @@ When BCQuality is installed as a standalone plugin, it additionally exposes `skills/al-code-review/SKILL.md`. This is a host-format adapter, not another action skill: it creates the task context and enters the same flow at Entry. +## Repository structure + +| Path | Purpose | +| --- | --- | +| `skills/entry.md` | Routes a task to action skills. | +| `skills/read.md`, `skills/do.md`, `skills/write.md` | Stable knowledge, action-skill, and authoring contracts. | +| `skills/al-code-review/SKILL.md` | Host-format plugin adapter. | +| `/knowledge//` | Atomic articles and optional sibling samples. | +| `/skills/` | Layer-owned action skills. | +| `docs/` | Partner guides and integration references. | +| `evaluation/` | Neutral review fixtures and scoring contract. | +| `tools/` | Knowledge-index and evaluation tooling. | +| `.github/` | Validation and repository workflows. | + +Layers are `microsoft`, `community`, and `custom`; Custom is a template for +consumer forks. An action skill either evaluates knowledge directly (a leaf) +or composes declared leaves (a super-skill). See [global skills](../skills/README.md) +for the distinction between host-native packaging and these internal formats. + ## The flow ```mermaid @@ -70,7 +91,17 @@ At this point the agent reads READ and DO on demand — it needs READ to interpr Discovering candidates at the Source step naively means opening every file under a domain folder just to read its frontmatter `keywords` — on a large corpus that is hundreds of file reads per review. To avoid this, BCQuality maintains a **knowledge index**: a single artifact (`knowledge-index.json`) that lists every article surviving the consumer's layer/allow-deny filtering and carries, per article, the exact inputs the Source/Worklist steps consume — `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint. -The index is **owned and produced by BCQuality**, not by each consumer: its generator (`tools/Build-KnowledgeIndex.ps1`) ships here, next to the skills and knowledge it derives from, so the index schema stays in lockstep with the Source contract and every consumer gets the same faithful index for free instead of re-implementing the parser. The consuming orchestrator does **not** build or invoke the index — it only prunes its clone to policy as it already does. The index is then (re)generated by BCQuality itself: **Entry's preparation step runs `Build-KnowledgeIndex.ps1` over the live, already-pruned clone** at the start of every run (see `skills/entry.md`), and BCQuality CI (`.github/workflows/knowledge-index.yml`) validates that the generator is healthy and deterministic. Building over the *pruned* clone — rather than shipping a committed full-corpus index that consumers trust — keeps the index exact for any consumer policy: it can never list an article the consumer denied, so policy-excluded rules cannot leak into discovery. +The index is **owned and produced by BCQuality**, not reimplemented by each +consumer. Its generator, `tools/Build-KnowledgeIndex.ps1`, ships here alongside +the content. Entry ensures the index reflects the live tree before routing +and regenerates it when absent or not known to be current. BCQuality CI +validates that the generator is healthy and deterministic. + +Consumers with allow/deny policy must prune their content copy **before** +Entry runs. Building over that pruned tree prevents removed articles from +entering discovery. A standalone plugin normally ships the whole tree: +`enabled-layers` filters discovery but does not remove files or enforce a +security boundary. See [layer selection](customizing-bcquality.md#select-layers-or-disable-a-review). The index changes only *how candidates are discovered*, never *which are selected*. The Worklist predicate is unchanged — `keywords` still drive selection — and the agent still opens each worklisted article **in full** to read its `## Best Practice` / `## Anti Pattern` rule bodies; the index is discovery metadata only and never substitutes for the article body. When no index is present, skills fall back to path-based discovery (collect by domain folder), so review still works. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..b4cbb0b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,137 @@ +# Contributing to BCQuality + +[Documentation](README.md) | [Knowledge by domain](using-bcquality.md#knowledge-by-domain) | [Authoring reference](../skills/write.md) + +Partners are welcome to contribute shared knowledge, examples, skills, and +documentation. To report an incorrect finding without preparing a change, +use the [support guide](troubleshooting.md#reporting-a-problem). + +## What belongs here + +BCQuality is a remedial knowledge base. A knowledge file exists because a +capable LLM **would get something wrong, or miss something, without it**, not +simply because the topic is important. Apply this admission test: + +> If this file did not exist, would a modern LLM reviewing or generating BC +> code make a mistake this file would have prevented? + +Good candidates encode a BC-specific mechanic that models get wrong, a +version-dependent behavior, or a misleading interpretation of an analyzer +rule. For example: + +- [SetLoadFields and filters can be called in either order](../microsoft/knowledge/performance/use-setloadfields-for-partial-records.md): their relative order does not change the projection. This prevents an incorrect performance finding. +- [Boolean page record triggers default to true](../microsoft/knowledge/error-handling/page-boolean-triggers-default-to-true.md): omitting an explicit `exit(true)` is not itself a defect. +- [Page fields can inherit captions](../microsoft/knowledge/style/caption-required-on-page-fields.md): an omitted page-level property is not sufficient evidence that a caption is missing. + +Generic advice such as "use HTTPS," "do not hardcode secrets," or "keep +transactions short" does not earn a separate knowledge file merely by being +sound advice. Negative clarifications that prevent false positives are as +valuable as rules that catch defects. + +**Skills hold discovery and execution mechanics; knowledge files hold BC +facts.** Correct or extend a knowledge article when a BC fact is missing or +wrong. Do not hide that fact in a skill's instructions. A genuine routing, +input, or output-contract problem belongs in the skill instead. + +## Choose the right destination + +| Change | Destination | +| --- | --- | +| Knowledge in a Microsoft-owned review domain | `microsoft/knowledge//` | +| Knowledge accompanying a Community-owned skill | `community/knowledge//` | +| Company-specific policy or an override | `custom/` in your own fork; never an upstream contribution | +| Partner instructions or how-to guidance | `docs/`, linked from the documentation index | + +Layer ownership follows the skill and domain, **not your employer**. For +example, a partner's performance clarification belongs beside the Microsoft +performance skill's corpus. Do not use Community as a staging area for an +already Microsoft-owned domain. A split may exist briefly during promotion, +but the skill and its canonical corpus should move together. + +Upstream automatically closes PRs adding custom content. Follow +[Customizing BCQuality](customizing-bcquality.md) for organization-only rules. +Do not introduce a new shared domain without the action skill that consumes +it and the matching evaluation samples. + +## Author a knowledge article + +Read [READ](../skills/read.md) for the schema and +[WRITE](../skills/write.md) for the authoring rules. Use an existing article +in the same domain as a starting point, then remove unrelated guidance. + +Every article has six required frontmatter fields: `bc-version`, `domain`, +`keywords`, `technologies`, `countries`, and `application-area`. +`domain` must match its containing directory. Keep one concern per file, +ideally under 50 lines and no more than 100. + +`Description` is required. Put recommendations in `Best Practice` and mistakes +to catch in `Anti Pattern`; those are the normative sections. Explain +legitimate exceptions so a reviewer does not turn a useful rule into a false +positive. Code fences are not allowed in knowledge articles. + +### Sources and examples + +When adding or changing a platform claim, link the authoritative source that +supports it, preferably the specific Microsoft Learn API/property page or a +public source definition. State version constraints when they matter. Avoid +"upstream guidance says" without a link. If the source is unavailable or the +guidance is organization policy or empirical observation, say so explicitly +rather than presenting it as an official platform guarantee. + +Place source links in a short `References` section or beside the relevant +claim. References do not replace the rule: keep all load-bearing guidance in +the normative sections. This adds traceability without adding frontmatter +fields or changing the schema. + +Put demonstration code in sibling files: + +```text +.md +.good.al +.bad.al +``` + +Reference each sample with a clickable link whose label retains the filename, +for example `` [`.good.al`](.good.al) `` with your actual slug. +One or both samples are optional for an individual article; every review +domain must have at least one complete good/bad pair for evaluation. Samples +are self-contained demonstrations, not copied Base Application source and +not a deployable or compiled application. + +## Before opening a PR + +From your BCQuality checkout, use the existing validators. The Python +validator needs Python and PyYAML; the fixture harness needs PowerShell 7. +If PyYAML is not installed in your development environment, install it with +`python -m pip install pyyaml`. + +```powershell +python .github\scripts\validate_frontmatter.py --root . +pwsh .\tools\Test-ReviewFixtures.ps1 -Root . +``` + +The first command checks schema, sections, naming, sample references, and +skill registration. The second checks that every review leaf has a valid +positive/clean sample pair. Neither proves a model will find every defect. +See [evaluation](../evaluation/README.md) for optional model-based scoring. + +In the PR description, explain the mistake being prevented, supporting +evidence, applicable BC versions, and why the chosen domain owns it. For a +false positive, include the valid pattern and the incorrect finding being +prevented. Check that links and samples open from the rendered article. + +Schema and stable protocol changes require approval from both maintainers. +Avoid repeating schema or contract definitions in new guides: link the +canonical READ, DO, WRITE, or Entry section instead. + +## Content releases + +Maintainers cut content releases on demand, roughly monthly, using the +`Release version` workflow on `main`. It tags the selected commit as +`v{major}.{minor}`; it does not update the plugin manifest. + +Use a minor bump for normal content updates and a major bump for breaking +changes. The minor is a monotonic counter: it increments across releases and +does **not** reset on a major bump. See +[updates and versions](customizing-bcquality.md#updates-and-versions) for the +separate plugin, content, and skill version identifiers. diff --git a/docs/customizing-bcquality.md b/docs/customizing-bcquality.md new file mode 100644 index 0000000..d3d9148 --- /dev/null +++ b/docs/customizing-bcquality.md @@ -0,0 +1,173 @@ +# Customizing BCQuality + +[Documentation](README.md) | [Using BCQuality](using-bcquality.md) | [Contributing](contributing.md) + +**No customization is required to get started.** Use the upstream plugin +unless you need a different review selection or organization-specific rules. +Model choice, concurrency, retries, and billing belong to your host, not +BCQuality. A [standalone runner](standalone-runner.md) is an advanced option. + +## Select layers or disable a review + +The standalone adapter reads these environment variables from the process +that starts your host: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `BCQUALITY_ENABLED_LAYERS` | `microsoft,community,custom` | Comma-separated layer names to discover. | +| `BCQUALITY_DISABLED_SKILLS` | None | Comma-separated **BCQuality repo-relative skill paths** to exclude, not display names or knowledge-article paths. | + +For example, in PowerShell, enable only Microsoft knowledge and omit the +dedicated style review: + +```powershell +$env:BCQUALITY_ENABLED_LAYERS = "microsoft" +$env:BCQUALITY_DISABLED_SKILLS = "microsoft/skills/review/al-style-review.md" +copilot +``` + +Set the variables **before** starting a new session. They apply to that +terminal and its child processes; use your host's environment configuration +if it starts elsewhere. Review selection is not a guarantee that another +domain or the agent will never mention a related concern. + +To return to defaults, remove those variables from the environment before +starting the host again (or use a fresh terminal if you only set them there). +Do not use an empty comma-separated value as a substitute for the default. + +All layers are enabled by default. Where relevant articles have overlapping +applicability and **contradictory guidance**, precedence is: + +**Custom > Community > Microsoft.** + +Otherwise the layers are additive. A matching filename alone does not suppress +an article; the [READ contract](../skills/read.md#layer-precedence) governs +knowledge conflicts. Review reports record displaced knowledge in `suppressed`. + +Layer selection is **not an access-control boundary**. A plugin installation +still contains excluded layers on disk. An integration requiring genuine +exclusion must remove denied files from its own content copy before the agent +reads it; the [adapter](../skills/al-code-review/SKILL.md#layer-selection-is-not-a-deny-mechanism) +explains this distinction. + +## Add an organization-specific rule + +Keep custom content in a fork or organization-controlled copy of BCQuality, +not in your AL app's `custom` folder and not in the installed plugin cache. +Editing the cache is not durable across updates. + +1. Fork BCQuality into a repository your organization controls, or create an + organization-controlled copy if a public fork is unsuitable for your policy. +2. Clone that repository and run `git remote get-url origin`. Confirm it is + your repository, **not** `microsoft/BCQuality`. +3. Add the article under `custom/knowledge//`, using the + [knowledge format](../skills/read.md). Keep your company's content out of + upstream pull requests. + +For example, suppose your company deliberately names one page "ACME Inventory +Workbench" while showing stockkeeping units, and already makes the row type +clear in its UI. You want a narrow exception to the shared page-naming rule. +Create `custom/knowledge/style/page-name-must-match-source-table.md` in your +copy with this content: + +```markdown +--- +bc-version: [all] +domain: style +keywords: [page-name, source-table, inventory, workbench] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Allow the ACME Inventory Workbench task name + +## Description + +Our approved page "ACME Inventory Workbench" shows stockkeeping units. Its UI +identifies the row type explicitly; its task-oriented name is company policy. + +## Best Practice + +Do not report that page solely because its name differs from its source-table +entity. Keep the shared naming guidance for other pages. + +## Anti Pattern + +Renaming the approved page solely to repeat the source-table entity, or +applying this exception to an unrelated page. +``` + +This is an **illustrative company policy**, not a new Microsoft recommendation. +Choose your actual domain, applicability, and policy; do not broaden an +exception merely to silence a valid defect. The shared rule is +[page-name-must-match-source-table.md](../microsoft/knowledge/style/page-name-must-match-source-table.md). +Guidance in `Best Practice` and `Anti Pattern` drives conflict resolution, so +do not put the exception only in a non-normative notes section. + +Follow the [contribution checks](contributing.md#before-opening-a-pr) locally, +then commit your change in your repository. A new knowledge domain also needs +an action skill that discovers it; adding an arbitrary folder does not create +a review. + +## Use your fork + +Adding custom content does not change the upstream plugin you already +installed. Point the host at your copy. + +For a pushed fork, replace `YOUR-ORG` with its owner. These commands replace +the upstream installation, since both manifests use the name `bcquality`: + +```powershell +copilot plugin uninstall bcquality +copilot plugin install YOUR-ORG/BCQuality +copilot plugin list +``` + +For local development, install your copy's absolute path instead: + +```powershell +copilot plugin install "C:\Repos\CompanyBCQuality" +``` + +Direct local installs are cached by the CLI; reinstall that path after edits, +then start a new session. See the host's +[local-plugin instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-creating). +Do not assume the currently running session has reloaded the content. + +Confirm the plugin list points at the intended source and that the Custom +layer is enabled. Review a small example relevant to your rule. Ask the host +which custom article it read and inspect `suppressed` for an actual conflict. +The negative-rule example should produce no naming finding for the approved +page; do not add an information-only finding just to prove the article was +loaded. The absence of a finding alone does not prove your fork was used. + +An external runner should likewise read from your fork or local copy rather +than the upstream URL. It must still start at [Entry](../skills/entry.md). + +## Updates and versions + +| Identifier | What it identifies | +| --- | --- | +| Plugin `version` in `plugin.json` | The host-facing package version. It is separate from content-release tags. `copilot plugin list` shows the installed plugin; inspect the resolved source when reproducing a run. | +| Content tag such as `v1.6` | A release of the repository's knowledge and skills. Available tags are listed on [GitHub](https://github.com/microsoft/BCQuality/tags). | +| Skill `version` in frontmatter | That skill's contract version, carried in reports. It does not identify the complete knowledge snapshot. | +| Git commit SHA | The exact repository snapshot. Record this for reproducibility when using a checkout. | + +For the upstream plugin, run `copilot plugin update bcquality`, then start a +new session. The unpinned installation command does not promise a particular +content-release tag. For a fork, updating the plugin reads your fork; it does +not merge upstream changes into it. + +To maintain a fork, commit your custom work first, add an `upstream` remote +pointing to `https://github.com/microsoft/BCQuality.git` once, fetch upstream, +and merge the desired upstream branch or content tag. Resolve conflicts and +review the resulting policy before publishing or reinstalling your fork. +Do not overwrite the fork wholesale with an upstream download. + +For repeatable CI or runner use, select a tag or commit in a dedicated clean +checkout and record `git rev-parse HEAD`. Upgrade deliberately, compare the +old and new content, and rerun representative reviews. To roll back, select +the previously recorded snapshot in that checkout and reinstall it if your +host caches local plugins. Retain organization-specific rules in the chosen +snapshot rather than reverting to an upstream-only tag. diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 8bb1e67..31ab1dd 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -1,5 +1,7 @@ # Build a lightweight standalone review runner +[Documentation](README.md) | [Architecture](agent-consumption.md) + BCQuality provides review knowledge, routing, execution instructions, and structured output contracts. It intentionally does not choose models, schedule agents, retry failures, or collect usage telemetry. A standalone runner can add @@ -12,12 +14,9 @@ concurrency, or integration with another review surface. ## Keep BCQuality current -Install or update the plugin with GitHub Copilot CLI: - -```shell -copilot plugin install microsoft/BCQuality -copilot plugin update bcquality -``` +For plugin installation, use the [quick start](../README.md#quick-start). +For version identifiers, forks, and reproducible snapshots, see +[updates and versions](customizing-bcquality.md#updates-and-versions). A runner that reads BCQuality from a checkout should pin a commit or release and upgrade it deliberately. Do not copy knowledge files or action-skill prose @@ -30,16 +29,13 @@ diff, supply the app's root directory as `folder-path`. The review scope is every relevant file below that directory, including `app.json` and AL source. The folder does not need to be a Git repository. -With the standalone plugin installed, start a fresh Copilot session in the app -folder and ask: - -> Use the installed `al-code-review` skill to review the complete Business -> Central app in this folder. Execute every dispatched review domain and return -> the complete BCQuality findings report. - -The adapter maps this request to `folder-path`; Entry routes it to the broad -review super-skill. Because a folder is a current-state snapshot, the review -must not invent a previous app version when evaluating comparison-only rules. +The [app-review example](../README.md#example-review-a-complete-app-folder) +uses this input through the standalone adapter. Because a folder is a +current-state snapshot, the review must not invent a previous app version +when evaluating comparison-only rules. Entry can return more than one +top-level skill; preserve all reports, including separately dispatched +Community reviews, rather than assuming the Microsoft coordinator is the +only result. ## Minimal runner flow diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..bdfcd72 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,59 @@ +# Troubleshooting and support + +[Documentation](README.md) | [Quick start](../README.md#quick-start) | [Using BCQuality](using-bcquality.md) + +## Setup and skill discovery + +Run terminal commands outside the interactive Copilot prompt unless they +start with `/`. + +| Symptom | What to do | +| --- | --- | +| `copilot` is not recognized | [Install Copilot CLI](https://docs.github.com/en/copilot/get-started/cli-quickstart), then open a new terminal. Installing Copilot Chat in an editor is not the same step. | +| The CLI has no `plugin` command | Update Copilot CLI using its installation method. Confirm `copilot plugin --help` works. | +| Sign-in, entitlement, or organization-policy error | Start `copilot`, use `/login`, and confirm your account is allowed to use Copilot CLI. Ask your administrator about organization restrictions; BCQuality cannot override them. | +| Plugin installation cannot reach the repository | Confirm access to `https://github.com/microsoft/BCQuality` and follow your organization's proxy/network guidance. Do not disable certificate checks. | +| The plugin installed, but the skill is missing | Run `copilot plugin list` in the terminal. Enable it with `copilot plugin enable bcquality` if disabled, then start a new session. In the session, use `/skills list` and look for `al-code-review`. | +| The agent performs a generic review | Name the **installed `al-code-review` skill** explicitly, as in the quick start. Ask which skill and BCQuality source it used. Another plugin or host may expose a similarly named operation. | +| Installation works in the terminal, but not in the editor | Plugin discovery is host-specific. Follow the editor's installation instructions; a CLI installation is not proof that another host loaded the plugin. | +| `pwsh` is missing, or index generation fails | The index is an accelerator, not required knowledge. The review can fall back to discovery from folders. For faster discovery, install [PowerShell 7](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell), or resolve the reported filesystem error. | +| An update or local edit is not visible | Run `copilot plugin update bcquality` for a repository-installed plugin, then start a fresh session. For a directly installed local folder, reinstall that folder to refresh the cached copy; see [customizing](customizing-bcquality.md#use-your-fork). | + +## Review results + +| Symptom | What to do | +| --- | --- | +| `partial`, a timeout, or an unfinished review | Read `outcome-reason` and domain reports. Retry the incomplete scope in a fresh session, use smaller app folders or a focused review, or select a host/model with sufficient capacity. Keep the limited scope visible; do not relabel it a complete app review. | +| `failed` | Resolve the stated problem, such as inaccessible input, a failed invocation, or an unverifiable reference, before using that report. A failed domain's findings are not reliable. | +| `no-match` or `not-applicable` | Confirm you supplied AL source, the intended folder/file/diff, and an appropriate goal. Check [disabled skills and layers](customizing-bcquality.md#select-layers-or-disable-a-review). | +| `no-knowledge` | Check the target BC version, selected domain, enabled layers, and whether the relevant knowledge files are present. No applicable rules is different from no defects. | +| `completed` with no findings | This can be a valid clean result for the selected scope. Confirm the intended files and domain reports are included. If you have a concrete missed defect, report it with a minimal example. | +| JSON rather than a readable summary | JSON is the shared output format. Ask the host to summarize the existing reports, preserving outcomes, locations, severity, confidence, and references. | +| A surprising finding | Open its guidance and samples, inspect surrounding code, and confirm version/localization assumptions. Ask the agent to explain the evidence; do not apply a suggestion solely because it has high confidence. | +| The Agents domain is absent | Agents is a separate Community review, not a child of the Microsoft broad review. Explicitly request an Agent SDK review and confirm the Community layer is enabled. | +| A missing base branch or unavailable source definition | Supply the real baseline or dependency definition. Without it, do not accept claims that rely on invented history or assumed dependency behavior. | +| Slow or expensive review | A broad review makes separate passes over multiple domains. Verify index generation succeeded, use a focused task when appropriate, and inspect usage in your host. BCQuality does not choose models, promise runtimes, or meter charges. | + +## Reporting a problem + +For incorrect BC guidance, missed findings, documentation gaps, or skill +behavior, [search existing issues](https://github.com/microsoft/BCQuality/issues) +and [open a BCQuality issue](https://github.com/microsoft/BCQuality/issues/new/choose) +if needed. You do not have to author a knowledge file before asking for help. +Host installation, authentication, billing, or policy problems belong with the +host's support channel or your organization administrator. + +Include: + +- The host and version, selected model if known, and BCQuality source/version + or commit. See [version identifiers](customizing-bcquality.md#updates-and-versions). +- The prompt, scope (folder/file/diff and comparison base), target BC version, + and relevant layer/skill settings. +- Expected versus actual behavior, the outcome/reason, and the exact rule + reference for a disputed finding. +- A **minimal, sanitized** AL example or report excerpt that reproduces the + problem. Remove secrets, customer data, and proprietary content you cannot share. + +For security vulnerabilities, follow [SECURITY.md](../SECURITY.md) instead of +opening a public issue. To contribute a correction yourself, follow the +[contribution guide](contributing.md). diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md new file mode 100644 index 0000000..bb1d955 --- /dev/null +++ b/docs/using-bcquality.md @@ -0,0 +1,190 @@ +# Using BCQuality + +[Documentation](README.md) | [Quick start](../README.md#quick-start) | [Troubleshooting](troubleshooting.md) + +BCQuality supplies knowledge and reusable skills to your AI host. The plugin +currently exposes `al-code-review`; the examples below use that skill. The +host supplies authentication, model access, tools, permissions, and rendering. +Installing BCQuality does not install a Business Central extension or an agent. + +## Hosts and prerequisites + +The [quick start](../README.md#quick-start) documents GitHub Copilot CLI. Use a +current CLI release with plugin support and sign in to an account allowed to +use it. In an interactive CLI session, `/skills list` should include +`al-code-review`; in the terminal, `copilot plugin list` should include +`bcquality`. + +Do not assume a CLI installation also installs the plugin into VS Code, +another editor, or another agent host. Follow that host's plugin instructions +and confirm it discovers `skills/al-code-review/SKILL.md`. Hosts without +compatible plugin discovery need an [integration](agent-consumption.md). + +Source review needs access to your files, not a running BC environment. +Include `app.json` and any relevant surrounding source. Dependency symbols or +a historical baseline may be needed to substantiate particular findings; a +review must not invent missing definitions or an earlier version of your app. +PowerShell 7 (`pwsh`) accelerates discovery by generating the knowledge index. +Without it, folder-based discovery is available and may take longer. + +## Common review requests + +Start a new host session after installing or updating the plugin. Use the +skill name explicitly and say what is in scope. Replace example paths and +branch names with ones in your project. + +| Task | Example prompt | +| --- | --- | +| Complete app | Use the installed al-code-review skill to review the complete Business Central app in this folder without changing my source files. Return the complete BCQuality findings report. | +| One file | Use the installed al-code-review skill to review `src\CustomerMgt.Codeunit.al` without changing it. Return the complete BCQuality findings report. | +| Uncommitted changes | Use the installed al-code-review skill to review my staged and unstaged tracked changes against HEAD, without changing files. Identify any untracked AL files not included in that diff. | +| Branch changes | Use the installed al-code-review skill to review changes on this branch since its merge base with `origin/main`. Exclude uncommitted changes and do not edit files. | +| Focused review | Use the installed al-code-review skill to review performance in the app in this folder, without changing files. Return the complete performance findings report. | +| Agent SDK code | Use the installed al-code-review skill to review Agent SDK implementation and usage in this app folder, without changing files. Return the complete Agents findings report. | + +For Git comparisons, the named base ref must exist locally. If it is missing, +fetch the intended branch first. A PR review also requires the host to have +the PR's changes and repository access; installing the plugin does not +automatically connect it to your PR workflow. + +A complete-folder review considers relevant files recursively, not just +modified files. Start in a single app's root for the clearest scope. For a +repository containing several apps, name each app folder and review them +separately when their target versions or dependencies differ. + +If known, add the target BC major version and localization to the request. +Do not use your extension's own `version` as the BC version. Missing +applicability context can reduce a finding's confidence or leave a rule out. + +## Reading your results + +The skill returns structured reports. A host may render them as text, a table, +or annotations, or show the JSON directly. You can ask the host to explain the +returned report without rerunning the review or changing files. + +For example, a performance report could contain this finding: + +| Field | Illustrative value | +| --- | --- | +| Outcome | `completed` | +| Location | `src\CustomerExport.Codeunit.al`, line 42 | +| Severity / confidence | `major` / `high` | +| Finding | A country filter is evaluated inside the customer loop, so rows that will be discarded are still read. Apply the filter before iterating. | +| Guidance | [Apply filters before iterating](../microsoft/knowledge/performance/apply-filters-before-iterating.md), with linked good/bad samples. | + +This illustrates a report, not a guaranteed finding or host screen. Read the +referenced article and the surrounding source before accepting a fix. + +### Outcomes + +| Outcome | Meaning and action | +| --- | --- | +| `completed` | The selected review finished. An empty `findings` list means it found nothing to flag in that scope, not that the app is certified defect-free. | +| `not-applicable` | The review did not apply to the supplied input. It is not a clean-review result. | +| `no-knowledge` | No applicable knowledge was available. Check scope, target context, and enabled layers. | +| `partial` | Some work did not finish. Read `outcome-reason` and the individual reports; do not treat the result as a full pass. | +| `failed` | No reliable result from that review. Resolve the reported error before relying on it. | +| `no-match` | Routing found no suitable skill. Check the request, input type, and disabled skills. | + +A broad review includes individual domain reports in `sub-results`. Separately +dispatched skills return separate reports, so do not mistake the first report +for the whole run. Coverage counts describe selected knowledge items evaluated, +not a percentage of all possible defects or every rule in the repository. + +For example, this completed **domain** report evaluated one selected knowledge +item and found nothing to flag: + +```json +{ + "skill": { "id": "al-performance-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [], + "suppressed": [] +} +``` + +This is not evidence that other domains ran; their reports must also be present +when requested. + +### Severity, confidence, and references + +| Severity | Meaning | +| --- | --- | +| `blocker` | A platform-level guarantee is violated; the work cannot proceed as-is. | +| `major` | A significant defect that should be addressed before merge. | +| `minor` | A quality concern; advisory rather than a gate. | +| `info` | Concrete context or an observation, not an instruction to change code. | + +Confidence (`high`, `medium`, or `low`) describes the strength of the evidence, +not the impact. Missing version or localization context must be disclosed in +the finding when conditionally applicable knowledge is used. + +Knowledge-backed findings link to the articles that informed them. Findings +from the agent's own reasoning have no knowledge reference (`references: []`); +these are advisory, with severity capped at `minor` and confidence at `medium`. +The display domain `Agents` means Agent SDK guidance; it is different from +`Agent`, the label for the broad coordinator's own cross-cutting observations. +Any `suppressed` entries explain knowledge overridden by configuration or +layer precedence. + +A report can include a code suggestion. **A suggestion is not an applied +change.** Review the explanation first, then request any edits explicitly, +for example: "Apply only the filter fix at line 42 from this report." Continue +using your normal compilation, analyzer, test, and human-review workflow. + +## Coverage and limits + +The Microsoft broad review composes the 16 Microsoft domains listed below. +The Community Agents review is a separate skill selected by the request, not +a nested part of that coordinator. All current review leaves accept app +folders, files, and diffs; request an Agent SDK review explicitly when that +coverage matters and look for its separate report. + +Available knowledge is **not** a promise that every rule will run. Selection +depends on the task, target context, enabled layers, and source evidence. +A whole-folder review is a current-state snapshot: detecting a published API +removal or another comparison-only regression requires an actual baseline. +The corpus is technical AL guidance, not exhaustive functional validation or +AppSource certification. + +### Knowledge by domain + +Each article describes one concern. Where samples exist, use its linked +`.good.al` and `.bad.al` files. Samples are demonstrations, not a deployable app. + +| Domain | Browse knowledge | +| --- | --- | +| Agent SDK | [Agents (Community)](../community/knowledge/agents/) | +| AppSource | [AppSource](../microsoft/knowledge/appsource/) | +| Compatibility | [Breaking changes](../microsoft/knowledge/breaking-changes/) | +| Data modeling | [Data modeling](../microsoft/knowledge/data-modeling/) | +| Error handling | [Error handling](../microsoft/knowledge/error-handling/) | +| Events | [Events](../microsoft/knowledge/events/) | +| Interfaces | [Interfaces](../microsoft/knowledge/interfaces/) | +| Performance | [Performance](../microsoft/knowledge/performance/) | +| Privacy | [Privacy](../microsoft/knowledge/privacy/) | +| Query objects | [Query](../microsoft/knowledge/query/) | +| Security | [Security](../microsoft/knowledge/security/) | +| Style | [Style](../microsoft/knowledge/style/) | +| Telemetry | [Telemetry](../microsoft/knowledge/telemetry/) | +| Testing | [Testing](../microsoft/knowledge/testing/) | +| User interface | [UI](../microsoft/knowledge/ui/) | +| Upgrades | [Upgrade](../microsoft/knowledge/upgrade/) | +| APIs and web services | [Web services](../microsoft/knowledge/web-services/) | + +## Permissions and data + +The review instructions produce findings, not source edits or deployment. +The host still controls tool permissions: keep approval prompts enabled and +do not grant blanket write or deployment access just to run a review. +BCQuality may write its generated `knowledge-index.json` into its own installed +directory; that is separate from your app's source. + +BCQuality is content, not an AI service. Your chosen host and model determine +where source code is processed, what usage is billed, and which data policies +apply. Review those policies before supplying proprietary or customer code. +Installing the plugin does not make an online host run locally or offline. diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md index a3542a1..a53e584 100644 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md +++ b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md @@ -21,10 +21,10 @@ This rule scopes to Marketplace ISV extensions, which is what AppSourceCop valid Own objects use the registered affix (for example `ABC Loyalty Tier`) or, when targeting BC23 or later, a qualifying namespace. Every field or action added to a standard object remains individually affixed (for example `Loyalty Points ABC` on a `Customer` tableextension). -See sample: `object-affixes-prevent-collisions.good.al`. +See sample: [`object-affixes-prevent-collisions.good.al`](object-affixes-prevent-collisions.good.al). ## Anti Pattern An owned object with neither a qualifying namespace nor an affix, an unaffixed extension member, or the common half-measure where the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing collision protection and the field can still collide with another app. -See sample: `object-affixes-prevent-collisions.bad.al`. +See sample: [`object-affixes-prevent-collisions.bad.al`](object-affixes-prevent-collisions.bad.al). diff --git a/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md index ca89003..1b81fb4 100644 --- a/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md +++ b/microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md @@ -17,10 +17,10 @@ An AppSource app must provide permission sets that let assigned users complete t Trace every setup page, normal page, report, codeunit, and tabledata operation exposed by the app and cover it through assignable role permission sets composed from focused non-assignable sets. Validate setup and representative workflows as a user assigned only those app roles. Grant the minimum required operations; completeness is not a reason to use wildcards. -See sample: `permission-sets-cover-setup-and-usage-without-super.good.al`. +See sample: [`permission-sets-cover-setup-and-usage-without-super.good.al`](permission-sets-cover-setup-and-usage-without-super.good.al). ## Anti Pattern Shipping no permission set, omitting a tabledata or execute grant used by the app's own UI, or instructing users and validators to assign `SUPER` when setup fails. Do not flag a permission-set name that differs from the app name; no such naming requirement exists. -See sample: `permission-sets-cover-setup-and-usage-without-super.bad.al`. +See sample: [`permission-sets-cover-setup-and-usage-without-super.bad.al`](permission-sets-cover-setup-and-usage-without-super.bad.al). diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md index 8e46049..fef6d6c 100644 --- a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md +++ b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md @@ -19,10 +19,10 @@ The requirement comes from AppSourceCop rule AS0011, which only runs when the ap Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. Do not raise a missing member affix against an app that does not enable AppSourceCop with a mandatory affix; there AS0011 never fires, and the app's namespace is not the reason — the absent configuration is. -See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`. +See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al). ## Anti Pattern Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer` in an app that does configure a mandatory affix. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. The mirror-image mistake is reporting an unaffixed extension member in an app that enables no mandatory affix at all — AS0011 does not apply there, and the finding is a false positive. -See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`. +See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al). diff --git a/microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md b/microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md index 835312d..6786631 100644 --- a/microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md +++ b/microsoft/knowledge/breaking-changes/choose-access-modifiers-deliberately.md @@ -17,10 +17,10 @@ Access is a decision about what you are willing to support forever. The moment a Start everything `local` or `internal` and promote a member to `public` only when you have decided to support it as a stable contract. Expose a small, intentional surface — the supported entry point — and keep validation, posting, and helper routines `internal` for in-app reuse or `local` when single-object. Do not drop `[Scope('OnPrem')]` without intent, since that too widens the contract. Every public member is a maintenance commitment; spend them deliberately. -See sample: `choose-access-modifiers-deliberately.good.al`. +See sample: [`choose-access-modifiers-deliberately.good.al`](choose-access-modifiers-deliberately.good.al). ## Anti Pattern Declaring every procedure `public` by default, so internal helpers like `ValidateOrder` and `PostOrder` become a de-facto API that consumers bind to and that can no longer be changed freely. Detection: an object where implementation-detail procedures carry no access modifier or are `public` without a reason to support them externally. Default them to `internal`/`local` and make only the intended entry point public. -See sample: `choose-access-modifiers-deliberately.bad.al`. +See sample: [`choose-access-modifiers-deliberately.bad.al`](choose-access-modifiers-deliberately.bad.al). diff --git a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md index 35a342e..f7d05d5 100644 --- a/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md +++ b/microsoft/knowledge/breaking-changes/deprecate-public-members-with-the-obsolete-lifecycle.md @@ -17,10 +17,10 @@ Deleting or renaming a published procedure (or object) in a single release is a When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records when the method became obsolete. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed should a later release delete the method. For an object or field, use `Pending` during the warning window and `Removed` afterward. -See sample: `deprecate-public-members-with-the-obsolete-lifecycle.good.al`. +See sample: [`deprecate-public-members-with-the-obsolete-lifecycle.good.al`](deprecate-public-members-with-the-obsolete-lifecycle.good.al). ## Anti Pattern Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind during a prior warning window. Do not suggest `ObsoleteState = Removed` for a method; that property belongs to supported object and element types. -See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`. +See sample: [`deprecate-public-members-with-the-obsolete-lifecycle.bad.al`](deprecate-public-members-with-the-obsolete-lifecycle.bad.al). diff --git a/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md b/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md index 75fa6c1..5fa0ae5 100644 --- a/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md +++ b/microsoft/knowledge/breaking-changes/do-not-change-published-procedure-signatures.md @@ -19,10 +19,10 @@ This rule governs procedures that dependents *call*. An event publisher — a pr Treat a published signature as frozen. When new behavior needs more inputs, add a new procedure or overload alongside the original — for example a `CalculateDiscountWithRate(Amount; Rate)` next to the unchanged `CalculateDiscount(Amount)` — and let the old one delegate to the new one. Existing callers keep compiling; new callers opt into the richer entry point. Naming an unnamed return value is the one in-place change that is always safe. -See sample: `do-not-change-published-procedure-signatures.good.al`. +See sample: [`do-not-change-published-procedure-signatures.good.al`](do-not-change-published-procedure-signatures.good.al). ## Anti Pattern Editing the existing public procedure's parameter list — here, adding a `Rate` parameter to `CalculateDiscount` — so every dependent extension that called the old form fails to compile. Detection: a parameter added, removed, reordered, retyped, or flipped to/from `var`, or a changed return type, on any non-`local` procedure that already shipped. Add a new overload instead. Exclude event publishers whose only change is an added parameter: subscribers bind by parameter name, not position, so that edit is additive and reporting it here is a false positive. -See sample: `do-not-change-published-procedure-signatures.bad.al`. +See sample: [`do-not-change-published-procedure-signatures.bad.al`](do-not-change-published-procedure-signatures.bad.al). diff --git a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md index bd32d2d..cb3f772 100644 --- a/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md +++ b/microsoft/knowledge/breaking-changes/do-not-expose-sensitive-data-through-public-api.md @@ -17,10 +17,10 @@ Every member you make publicly reachable becomes a contract you must keep — an Keep secrets in `internal` or `local` members, and prefer the `SecretText` type so the value cannot be read back or logged. Where callers genuinely need a credential, pass it inward (a setter) rather than handing it outward (a getter). Public API should return only non-sensitive data — a masked reference, a status, a business identifier — never the raw secret. Treat each public member as a lasting commitment and keep the security-sensitive surface as small as possible. -See sample: `do-not-expose-sensitive-data-through-public-api.good.al`. +See sample: [`do-not-expose-sensitive-data-through-public-api.good.al`](do-not-expose-sensitive-data-through-public-api.good.al). ## Anti Pattern A public `GetAccessToken()` that returns the raw token (or an event parameter carrying a credential to all subscribers), turning a secret into a de-facto public API any dependent can consume. Detection: a non-`local` procedure, event parameter, or global variable that surfaces a token, password, key, or other credential. Keep the secret internal and expose only non-sensitive data. -See sample: `do-not-expose-sensitive-data-through-public-api.bad.al`. +See sample: [`do-not-expose-sensitive-data-through-public-api.bad.al`](do-not-expose-sensitive-data-through-public-api.bad.al). diff --git a/microsoft/knowledge/breaking-changes/do-not-modify-code-already-marked-obsolete.md b/microsoft/knowledge/breaking-changes/do-not-modify-code-already-marked-obsolete.md index 781810b..4609ae3 100644 --- a/microsoft/knowledge/breaking-changes/do-not-modify-code-already-marked-obsolete.md +++ b/microsoft/knowledge/breaking-changes/do-not-modify-code-already-marked-obsolete.md @@ -17,10 +17,10 @@ A member carrying `[Obsolete]`, or wrapped in a `#if not CLEANxx` conditional-co Leave obsolete members exactly as they are and implement against the current, supported replacement. New logic — a surcharge calculation, an event publisher, a hook — belongs on the live API (`GetUnitPrice`), never inside the deprecated `GetPrice` or behind a `#if not CLEAN25` guard. If the replacement does not yet exist, create it as a first-class member and build there. The obsolete code should only shrink over time, not accrete new behavior. -See sample: `do-not-modify-code-already-marked-obsolete.good.al`. +See sample: [`do-not-modify-code-already-marked-obsolete.good.al`](do-not-modify-code-already-marked-obsolete.good.al). ## Anti Pattern Adding a surcharge calculation inside the `[Obsolete]` `GetPrice` procedure, or behind a `#if not CLEAN25` block, so the new behavior is wired to code that will be removed when `CLEAN25` is enabled. Detection: new statements, event declarations, or dependencies introduced inside an `[Obsolete]`-marked member or a `#if not CLEANxx` region. Move the logic onto the supported replacement instead. -See sample: `do-not-modify-code-already-marked-obsolete.bad.al`. +See sample: [`do-not-modify-code-already-marked-obsolete.bad.al`](do-not-modify-code-already-marked-obsolete.bad.al). diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md index fde0f54..6c345ee 100644 --- a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md +++ b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md @@ -17,10 +17,10 @@ AL resolves an object by namespace and name. Once an app ships and dependent ext Choose a globally meaningful namespace before first publication and keep it stable. Add new functional areas beneath that structure without moving existing published objects. If an identity must move, use the platform's supported move/obsoletion lifecycle rather than a source-only namespace rename. -See sample: `namespace-is-part-of-published-object-identity.good.al`. +See sample: [`namespace-is-part-of-published-object-identity.good.al`](namespace-is-part-of-published-object-identity.good.al). ## Anti Pattern Changing `namespace Contoso.Rentals;` to `namespace Contoso.RentalManagement;` as a cleanup while leaving the object name and ID untouched. Every dependent `using` directive and qualified reference targets the old identity and stops compiling. -See sample: `namespace-is-part-of-published-object-identity.bad.al`. +See sample: [`namespace-is-part-of-published-object-identity.bad.al`](namespace-is-part-of-published-object-identity.bad.al). diff --git a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md index 3ff6474..bf62fac 100644 --- a/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md +++ b/microsoft/knowledge/breaking-changes/obsolete-table-fields-instead-of-deleting-them.md @@ -17,10 +17,10 @@ A shipped table field carries both a source-level contract and persisted data. R Keep the old field's ID, name, and type unchanged. Add the replacement as a separate field under an unused ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated. -See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`. +See sample: [`obsolete-table-fields-instead-of-deleting-them.good.al`](obsolete-table-fields-instead-of-deleting-them.good.al). ## Anti Pattern Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or changing its ID additionally risks losing its stored values. Detection: any previously shipped field whose name changes at the same ID, or whose original ID disappears without the unchanged field being retained as `Pending` and its data migrated to a separate replacement field. -See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`. +See sample: [`obsolete-table-fields-instead-of-deleting-them.bad.al`](obsolete-table-fields-instead-of-deleting-them.bad.al). diff --git a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md index e324d45..698980f 100644 --- a/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md +++ b/microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md @@ -19,10 +19,10 @@ Putting the block check inside the master's own `OnInsert`/`OnModify` does nothi The referencing line validates `Master.TestField(Blocked, false)` in `OnValidate` of the reference field and re-checks before posting. The master table stays logic-free on `Blocked`. -See sample: `check-blocked-in-referencing-code-not-in-master.good.al`. +See sample: [`check-blocked-in-referencing-code-not-in-master.good.al`](check-blocked-in-referencing-code-not-in-master.good.al). ## Anti Pattern The block check sits in the master's own `OnModify`/`OnInsert` (so referencing and posting proceed unchecked), or there is no check at all on the referencing side. -See sample: `check-blocked-in-referencing-code-not-in-master.bad.al`. +See sample: [`check-blocked-in-referencing-code-not-in-master.bad.al`](check-blocked-in-referencing-code-not-in-master.bad.al). diff --git a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md index f4c6a15..69476ff 100644 --- a/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md +++ b/microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md @@ -19,10 +19,10 @@ This is not an `Integer` `AutoIncrement` key, a GUID, or the `SystemId`. Those a `No.` `Code[20]` is the sole primary key; a non-editable `No. Series` `Code[20]` field records the source series. `OnInsert` checks `if "No." = ''`, reads the setup table, `TestField`s the configured series, stores it in `No. Series`, and assigns `No.` from the series. -See sample: `master-table-no-from-number-series-in-oninsert.good.al`. +See sample: [`master-table-no-from-number-series-in-oninsert.good.al`](master-table-no-from-number-series-in-oninsert.good.al). ## Anti Pattern An `Integer` `AutoIncrement` (or GUID / `SystemId`) primary key used as the business key, with no `OnInsert` number assignment. Records get an opaque identifier no user can reference, and the master no longer participates in the standard numbering and manual-entry behavior every other BC master follows. -See sample: `master-table-no-from-number-series-in-oninsert.bad.al`. +See sample: [`master-table-no-from-number-series-in-oninsert.bad.al`](master-table-no-from-number-series-in-oninsert.bad.al). diff --git a/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md b/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md index 82bf81d..5ded169 100644 --- a/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md +++ b/microsoft/knowledge/data-modeling/owning-table-must-delete-dependents-in-ondelete.md @@ -25,7 +25,7 @@ See also `validate-table-relation-false-suppresses-rename-propagation.md` for th The owning table implements `OnDelete` and deletes its dependents there, filtered on the foreign key. Declare `Permissions = tabledata = rd` on the owning table — granting delete rights only on the parent is a common miss that makes the trigger fail for a non-`SUPER` user. This mirrors the base application, where every header table deletes its own lines. -See sample: `owning-table-must-delete-dependents-in-ondelete.good.al`. +See sample: [`owning-table-must-delete-dependents-in-ondelete.good.al`](owning-table-must-delete-dependents-in-ondelete.good.al). ## Anti Pattern @@ -33,4 +33,4 @@ A parent table with dependent rows and no `OnDelete` trigger, where the dependen Detection signal: a table declares `TableRelation` to table X, and table X has no `OnDelete` trigger. Whether a delete path currently exists in the UI is irrelevant to the finding. -See sample: `owning-table-must-delete-dependents-in-ondelete.bad.al`. +See sample: [`owning-table-must-delete-dependents-in-ondelete.bad.al`](owning-table-must-delete-dependents-in-ondelete.bad.al). diff --git a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md index dbc0a64..f8b2a0d 100644 --- a/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md +++ b/microsoft/knowledge/data-modeling/set-last-date-modified-in-onmodify-and-onrename.md @@ -19,10 +19,10 @@ The reason is a BC-specific trap: renaming a record changes its primary key and Both `OnModify` and `OnRename` set `"Last Date Modified" := Today();`, and the field is declared `Editable = false` so only the triggers maintain it. -See sample: `set-last-date-modified-in-onmodify-and-onrename.good.al`. +See sample: [`set-last-date-modified-in-onmodify-and-onrename.good.al`](set-last-date-modified-in-onmodify-and-onrename.good.al). ## Anti Pattern Only `OnModify` assigns `Last Date Modified`. After a rename the value is stale, and any process that trusts it to detect changes misses the record. -See sample: `set-last-date-modified-in-onmodify-and-onrename.bad.al`. +See sample: [`set-last-date-modified-in-onmodify-and-onrename.bad.al`](set-last-date-modified-in-onmodify-and-onrename.bad.al). diff --git a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md index 774963f..0a7ac8b 100644 --- a/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md +++ b/microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md @@ -19,10 +19,10 @@ The setup **card** page enforces the singleton: `InsertAllowed = false` and `Del `Primary Key` `Code[10]` is the sole key; the setup is surfaced through a Card page with `InsertAllowed = false`, `DeleteAllowed = false`, and an open-time guard that inserts the blank row if it is missing. -See sample: `setup-table-is-a-singleton.good.al`. +See sample: [`setup-table-is-a-singleton.good.al`](setup-table-is-a-singleton.good.al). ## Anti Pattern An `Integer` / `AutoIncrement` key, a page that allows insert or delete, or a List page over the setup table. Any of these lets the table hold zero or many rows, so "the setup" becomes ambiguous and `Get()` may fail or read the wrong record. -See sample: `setup-table-is-a-singleton.bad.al`. +See sample: [`setup-table-is-a-singleton.bad.al`](setup-table-is-a-singleton.bad.al). diff --git a/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md index 10946a5..0921227 100644 --- a/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md +++ b/microsoft/knowledge/data-modeling/share-mediaset-items-with-insert-not-field-assignment.md @@ -17,10 +17,10 @@ application-area: [all] When sharing media between different tables, iterate the source `MediaSet` and call `Target.MediaSetField.Insert(Source.MediaSetField.Item(Index))`, then modify the target record. Direct field assignment is safe only when source and target are the same record subtype and use the same field ID. This concern is about reference/delete integrity, not the separate performance cost of `ModifyAll` on tables with media fields. -See sample: `share-mediaset-items-with-insert-not-field-assignment.good.al`. +See sample: [`share-mediaset-items-with-insert-not-field-assignment.good.al`](share-mediaset-items-with-insert-not-field-assignment.good.al). ## Anti Pattern `Target.Picture := Source.Picture;` where the two variables refer to different table types or different media-field IDs. The code copies an opaque ID, but the platform does not know that two independent fields now share the media object. -See sample: `share-mediaset-items-with-insert-not-field-assignment.bad.al`. +See sample: [`share-mediaset-items-with-insert-not-field-assignment.bad.al`](share-mediaset-items-with-insert-not-field-assignment.bad.al). diff --git a/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md index 1908f82..84bdc0b 100644 --- a/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md +++ b/microsoft/knowledge/data-modeling/table-relation-extensions-are-additive-and-top-down.md @@ -17,10 +17,10 @@ A `tableextension` can add to an existing `TableRelation`, but the combined rela When a relation is designed to follow an extensible enum, express the base cases as conditional branches and leave no unconditional catch-all ahead of future extension branches. An enum extension can then append a condition for its new value. When extending a field you do not own, inspect the original `TableRelation`; do not claim that an appended condition overrides an unconditional relation. -See sample: `table-relation-extensions-are-additive-and-top-down.good.al`. +See sample: [`table-relation-extensions-are-additive-and-top-down.good.al`](table-relation-extensions-are-additive-and-top-down.good.al). ## Anti Pattern A base field has an unconditional `TableRelation = Customer;` and a `tableextension` adds `if (Type = const(Resource)) Resource`. The original unconditional branch always wins, so the new enum value still validates and looks up against Customer. The concern is evaluation order, not `ValidateTableRelation`; free-form input is covered separately by security guidance. -See sample: `table-relation-extensions-are-additive-and-top-down.bad.al`. +See sample: [`table-relation-extensions-are-additive-and-top-down.bad.al`](table-relation-extensions-are-additive-and-top-down.bad.al). diff --git a/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md b/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md index 7d1ea77..7f288b2 100644 --- a/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md +++ b/microsoft/knowledge/data-modeling/transferfields-skip-type-mismatch-can-drop-data.md @@ -17,10 +17,10 @@ application-area: [all] Use `TransferFields(Source)` only when every field the destination requires, including primary key fields, is guaranteed to share a matching field number and type with the source; this form defaults `InitPrimaryKeyFields` to `true`. Fields with no matching field number, and fields whose types differ across extensions, are skipped regardless of `SkipFieldsNotMatchingType` — that parameter only governs same-extension type mismatches. If the destination depends on a field that falls into either case, map and validate it explicitly in code rather than relying on `TransferFields` to catch the gap. Use `SkipFieldsNotMatchingType = true` only when skipping same-extension type mismatches is an intentional, documented part of the transfer contract. -See sample: `transferfields-skip-type-mismatch-can-drop-data.good.al`. +See sample: [`transferfields-skip-type-mismatch-can-drop-data.good.al`](transferfields-skip-type-mismatch-can-drop-data.good.al). ## Anti Pattern Using `TransferFields(Source, InitPrimaryKeyFields, true)` as a generic way to make two evolving table schemas transfer without errors, when the destination depends on every required source field being copied. A type change on either table can turn a previously transferred field into a silently skipped one without making the transfer itself fail. -See sample: `transferfields-skip-type-mismatch-can-drop-data.bad.al`. \ No newline at end of file +See sample: [`transferfields-skip-type-mismatch-can-drop-data.bad.al`](transferfields-skip-type-mismatch-can-drop-data.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md index b4d19b9..f80e35d 100644 --- a/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md +++ b/microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md @@ -19,10 +19,10 @@ LLMs reproduce the legacy `NoSeriesManagement` pattern because it dominates pre- `OnInsert` assigns the number with `NoSeries.GetNextNo("No. Series")` where `NoSeries` is `Codeunit "No. Series"`. The `No.` field's `OnValidate` guards manual entry by calling `NoSeries.IsManual(...)` (or `TestManual`) before clearing `No. Series`. -See sample: `use-no-series-codeunit-not-noseriesmanagement.good.al`. +See sample: [`use-no-series-codeunit-not-noseriesmanagement.good.al`](use-no-series-codeunit-not-noseriesmanagement.good.al). ## Anti Pattern `NoSeriesMgt.InitSeries(...)` for assignment and `NoSeriesMgt.TestManual(...)` for the manual check, where `NoSeriesMgt` is `Codeunit NoSeriesManagement`. Both are obsolete-pending and emit compiler warnings. -See sample: `use-no-series-codeunit-not-noseriesmanagement.bad.al`. +See sample: [`use-no-series-codeunit-not-noseriesmanagement.bad.al`](use-no-series-codeunit-not-noseriesmanagement.bad.al). diff --git a/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md b/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md index ddd4856..0131edb 100644 --- a/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md +++ b/microsoft/knowledge/data-modeling/validate-table-relation-false-suppresses-rename-propagation.md @@ -27,7 +27,7 @@ See also `owning-table-must-delete-dependents-in-ondelete.md` for the delete hal Leave `ValidateTableRelation` at its default wherever the stored value must stay correct across a rename. When it must be disabled, or when the relationship cannot be expressed as a `TableRelation` at all, the table owning the referenced key carries an explicit `OnRename` that repoints the dependents itself. -See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al`. +See sample: [`validate-table-relation-false-suppresses-rename-propagation.good.al`](validate-table-relation-false-suppresses-rename-propagation.good.al). ## Anti Pattern @@ -35,4 +35,4 @@ See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al Detection signal: any `ValidateTableRelation = false` on a field that also declares a `TableRelation`. Ask what repoints the value when the target is renamed; if the answer is "the platform", the finding stands. -See sample: `validate-table-relation-false-suppresses-rename-propagation.bad.al`. +See sample: [`validate-table-relation-false-suppresses-rename-propagation.bad.al`](validate-table-relation-false-suppresses-rename-propagation.bad.al). diff --git a/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md b/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md index 52c06bd..8c90095 100644 --- a/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md +++ b/microsoft/knowledge/data-modeling/xrec-is-a-before-image-only-in-some-triggers.md @@ -25,7 +25,7 @@ See also `validate-table-relation-false-suppresses-rename-propagation.md`, which Use `xRec` for the previous key in `OnRename`, and for the record being removed in `OnDelete`. In `OnModify`, obtain the before-image by re-reading the stored row rather than trusting `xRec`, so the logic behaves identically whether a page, a job queue or an API drove the write. -See sample: `xrec-is-a-before-image-only-in-some-triggers.good.al`. +See sample: [`xrec-is-a-before-image-only-in-some-triggers.good.al`](xrec-is-a-before-image-only-in-some-triggers.good.al). ## Anti Pattern @@ -33,4 +33,4 @@ Comparing `Rec` against `xRec` inside `OnModify` (or `OnInsert`) to detect a cha Detection signal: any read of `xRec` inside `OnModify` or `OnInsert`. Treat "but it works when I test it on the page" as confirmation of the defect rather than a refutation. -See sample: `xrec-is-a-before-image-only-in-some-triggers.bad.al`. +See sample: [`xrec-is-a-before-image-only-in-some-triggers.bad.al`](xrec-is-a-before-image-only-in-some-triggers.bad.al). diff --git a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md index b464fec..cdb87b6 100644 --- a/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md +++ b/microsoft/knowledge/error-handling/collect-validation-errors-with-errorbehavior.md @@ -17,10 +17,10 @@ By default a procedure stops on the first `Error`, so a user fixing ten bad rows Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()`, retrieve and clear the list with `GetCollectedErrors(true)`, and fail the operation with the collected messages. The sample intentionally produces a text aggregate and does not claim to retain record/field metadata in the final error. If that metadata is needed, map each `ErrorInfo` to a custom error UI before clearing, following the Microsoft Learn pattern. Do not replace validation failure with `Message`: clearing collected errors suppresses the platform failure, so the custom handler must still block the invalid operation. -See sample: `collect-validation-errors-with-errorbehavior.good.al`. +See sample: [`collect-validation-errors-with-errorbehavior.good.al`](collect-validation-errors-with-errorbehavior.good.al). ## Anti Pattern Three shapes signal trouble. Hand-rolled accumulation reimplements collection and prevents the handler from receiving individual `ErrorInfo` values. A `Collect` procedure that never handles the collection falls back to the concatenated platform dialog. Finally, code that calls parameterless `GetCollectedErrors()`, assumes it cleared the list, and only shows a `Message` can both leave the errors collected and allow invalid processing to continue. -See sample: `collect-validation-errors-with-errorbehavior.bad.al`. +See sample: [`collect-validation-errors-with-errorbehavior.bad.al`](collect-validation-errors-with-errorbehavior.bad.al). diff --git a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md index 127fa50..ece3baf 100644 --- a/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md +++ b/microsoft/knowledge/error-handling/errortype-internal-vs-client-for-diagnostics.md @@ -17,10 +17,10 @@ application-area: [all] Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve — validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`. -See sample: `errortype-internal-vs-client-for-diagnostics.good.al`. +See sample: [`errortype-internal-vs-client-for-diagnostics.good.al`](errortype-internal-vs-client-for-diagnostics.good.al). ## Anti Pattern Raising an internal failure with a plain `Error('Unexpected state: ledger bucket %1 not initialized', BucketId)`. The user is shown a technical message they can do nothing about, and the signal is buried in a generic error rather than carried as structured telemetry detail. Detection: an `Error` whose wording targets a developer ("unexpected", "should not happen", raw internal identifiers) raised with default `Client` visibility instead of an `ErrorInfo` marked `ErrorType::Internal`. -See sample: `errortype-internal-vs-client-for-diagnostics.bad.al`. +See sample: [`errortype-internal-vs-client-for-diagnostics.bad.al`](errortype-internal-vs-client-for-diagnostics.bad.al). diff --git a/microsoft/knowledge/error-handling/fielderror-default-message-logic.md b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md index c02bb4f..51c116a 100644 --- a/microsoft/knowledge/error-handling/fielderror-default-message-logic.md +++ b/microsoft/knowledge/error-handling/fielderror-default-message-logic.md @@ -14,9 +14,9 @@ application-area: [all] ## Best Practice For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you. -See sample: `fielderror-default-message-logic.good.al`. +See sample: [`fielderror-default-message-logic.good.al`](fielderror-default-message-logic.good.al). ## Anti Pattern Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context. -See sample: `fielderror-default-message-logic.bad.al`. \ No newline at end of file +See sample: [`fielderror-default-message-logic.bad.al`](fielderror-default-message-logic.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/error-handling/fielderror-vs-testfield.md b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md index 9b58b32..845a0db 100644 --- a/microsoft/knowledge/error-handling/fielderror-vs-testfield.md +++ b/microsoft/knowledge/error-handling/fielderror-vs-testfield.md @@ -16,9 +16,9 @@ Use `TestField` when the condition is a simple presence-or-equality check on a s A page action's `OnAction` trigger is a different case: a page action is only invocable through its own UI control, so when the action's `Enabled` property is already bound to the same condition the trigger would otherwise `TestField`, the control cannot be clicked while the field is blank and the field can never reach the trigger empty. Adding a `TestField` there is redundant defensive code, not a missing check — flag it only when the trigger can run through a path `Enabled` does not cover (a shared procedure, an API, or a condition broader than what gates the action). -See sample: `fielderror-vs-testfield.good.al`. +See sample: [`fielderror-vs-testfield.good.al`](fielderror-vs-testfield.good.al). ## Anti Pattern Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality. -See sample: `fielderror-vs-testfield.bad.al`. +See sample: [`fielderror-vs-testfield.bad.al`](fielderror-vs-testfield.bad.al). diff --git a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md index 52e3e40..902528b 100644 --- a/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md +++ b/microsoft/knowledge/error-handling/ignored-tryfunction-return-disables-try-semantics.md @@ -17,13 +17,13 @@ A procedure marked `[TryFunction]` catches errors only when the caller uses its Consume the result directly: assign it to a Boolean or use the call in an `if` condition. Handle `false` immediately while the last-error state still describes that failure. -See sample: `ignored-tryfunction-return-disables-try-semantics.good.al`. +See sample: [`ignored-tryfunction-return-disables-try-semantics.good.al`](ignored-tryfunction-return-disables-try-semantics.good.al). ## Anti Pattern Calling a `[TryFunction]` procedure as a standalone statement and assuming the attribute suppresses its errors. The call has ordinary error semantics because its Boolean result is ignored. -See sample: `ignored-tryfunction-return-disables-try-semantics.bad.al`. +See sample: [`ignored-tryfunction-return-disables-try-semantics.bad.al`](ignored-tryfunction-return-disables-try-semantics.bad.al). ## See also diff --git a/microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md b/microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md index f774cc8..082e118 100644 --- a/microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md +++ b/microsoft/knowledge/error-handling/prefer-errorinfo-for-actionable-errors.md @@ -17,10 +17,10 @@ A plain `Error('text')` ends the operation with a dead-end dialog: the user read Build an `ErrorInfo`, set `Title`, `Message`, and `DetailedMessage`, then attach the action that matches the situation. For a Fix-it, call `AddAction(Caption, Codeunit::Handler, 'MethodName')` where the handler method (which receives the `ErrorInfo`) applies the known-good value; phrase the caption as "Set value to …". For a Show-it, set `PageNo := Page::"…"`, set `RecordId` so navigation opens the right record, and call `AddNavigationAction('Show …')`. Raise it with `Error(ErrorInfo)`. Reserve recommended actions for cases where the solution is genuinely known and the user has permission to apply it. -See sample: `prefer-errorinfo-for-actionable-errors.good.al`. +See sample: [`prefer-errorinfo-for-actionable-errors.good.al`](prefer-errorinfo-for-actionable-errors.good.al). ## Anti Pattern Surfacing a recoverable validation failure with `Error('You cannot invoice more than %1 units.', MaxQty)` and nothing else. The user is blocked with no offered remedy even though the code knows the maximum and could set it. The detection signal: an `Error` call in a validation or posting path whose message names a specific correct value or a specific related page, with no surrounding `ErrorInfo`, `AddAction`, or `AddNavigationAction`. Replace it with an `ErrorInfo` that carries the corresponding Fix-it or Show-it action. -See sample: `prefer-errorinfo-for-actionable-errors.bad.al`. +See sample: [`prefer-errorinfo-for-actionable-errors.bad.al`](prefer-errorinfo-for-actionable-errors.bad.al). diff --git a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md index b05b020..418a50f 100644 --- a/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md +++ b/microsoft/knowledge/events/add-new-event-parameters-at-the-end.md @@ -17,10 +17,10 @@ Event subscribers bind publisher parameters by name and can omit parameters they Add a parameter directly only when the shipped event publisher is `local` or `internal`. Place it where the signature is clearest; existing subscribers continue binding the parameters they name. For a public event, keep the original publisher unchanged and introduce a new event with the expanded contract. -See sample: `add-new-event-parameters-at-the-end.good.al`. +See sample: [`add-new-event-parameters-at-the-end.good.al`](add-new-event-parameters-at-the-end.good.al). ## Anti Pattern Appending a parameter to a public event and assuming its position makes the change compatible. Existing external callers still lack the new required argument. Conversely, do not flag a parameter inserted among existing parameters on a `local` or `internal` Business or Integration event merely because it was not appended. -See sample: `add-new-event-parameters-at-the-end.bad.al`. +See sample: [`add-new-event-parameters-at-the-end.bad.al`](add-new-event-parameters-at-the-end.bad.al). diff --git a/microsoft/knowledge/events/avoid-loosely-typed-event-parameters.md b/microsoft/knowledge/events/avoid-loosely-typed-event-parameters.md index 6c1e004..894b1ad 100644 --- a/microsoft/knowledge/events/avoid-loosely-typed-event-parameters.md +++ b/microsoft/knowledge/events/avoid-loosely-typed-event-parameters.md @@ -17,10 +17,10 @@ Passing `RecordRef` or `xRec` as event parameters weakens the contract. A `Recor Give events concrete record types and explicit values, such as `(SalesLine: Record "Sales Line"; PreviousQuantity: Decimal)`, instead of a `RecordRef` or an `xRec` parameter. Subscribers then get type safety, field access, and an unambiguous contract. -See sample: `avoid-loosely-typed-event-parameters.good.al`. +See sample: [`avoid-loosely-typed-event-parameters.good.al`](avoid-loosely-typed-event-parameters.good.al). ## Anti Pattern Event parameters typed as `RecordRef` (no table type) or an `xRec`-style "previous record" (ambiguous, possibly stale) without strong justification. Detection: an event signature containing a `RecordRef` parameter, or a passed-through `xRec` record, where a concrete typed record and explicit values would serve. -See sample: `avoid-loosely-typed-event-parameters.bad.al`. +See sample: [`avoid-loosely-typed-event-parameters.bad.al`](avoid-loosely-typed-event-parameters.bad.al). diff --git a/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md index 7e791fe..80b3b34 100644 --- a/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md +++ b/microsoft/knowledge/events/avoid-raising-events-inside-try-functions.md @@ -17,10 +17,10 @@ A `TryFunction` catches all errors — including errors thrown by event subscrib Raise the integration event before entering the TryFunction scope. The event and its subscribers execute outside the error boundary, so subscriber errors propagate normally to the caller. Move only the operation that genuinely needs error isolation (such as an HTTP call or a posting step) inside the TryFunction. -See sample: `avoid-raising-events-inside-try-functions.good.al`. +See sample: [`avoid-raising-events-inside-try-functions.good.al`](avoid-raising-events-inside-try-functions.good.al). ## Anti Pattern Raising an integration event inside a TryFunction body. Subscriber failures are caught and discarded by the TryFunction. The subscriber contract — that a subscriber can signal failure to the caller — is silently broken. -See sample: `avoid-raising-events-inside-try-functions.bad.al`. +See sample: [`avoid-raising-events-inside-try-functions.bad.al`](avoid-raising-events-inside-try-functions.bad.al). diff --git a/microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md b/microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md index 9fe7312..68c0dc3 100644 --- a/microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md +++ b/microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md @@ -17,10 +17,10 @@ An `[EventSubscriber]` codeunit is static by default (`EventSubscriberInstance = Use a static subscriber for behaviour that genuinely applies all the time. For anything scoped, mark the codeunit `EventSubscriberInstance = Manual`, call `BindSubscription(SubscriberInstance)` at the start of the scope and `UnbindSubscription(SubscriberInstance)` at the end. A manual subscriber held only in a local variable unbinds automatically when that variable leaves scope, which suits test setup/teardown; a binding you intend to outlive a single call must be unbound explicitly. Keep subscriber methods `local` per CodeCop AA0207. -See sample: `choose-static-vs-manual-subscribers-deliberately.good.al`. +See sample: [`choose-static-vs-manual-subscribers-deliberately.good.al`](choose-static-vs-manual-subscribers-deliberately.good.al). ## Anti Pattern Two shapes. First, a static subscriber used for behaviour that should be scoped — an always-on side effect (sending mail, writing extra records) that now fires for every event in every session and test with no way to disable it. Second, a manual subscriber that is bound with `BindSubscription` and never unbound: when the instance is held beyond the intended scope (for example on a `SingleInstance` codeunit), the binding leaks for the whole session and later unrelated operations keep hitting it. Detection: scoped side effects on a static subscriber, or a `BindSubscription` call with no matching `UnbindSubscription` and no scope that releases the instance. -See sample: `choose-static-vs-manual-subscribers-deliberately.bad.al`. +See sample: [`choose-static-vs-manual-subscribers-deliberately.bad.al`](choose-static-vs-manual-subscribers-deliberately.bad.al). diff --git a/microsoft/knowledge/events/declare-event-publishers-local-or-internal.md b/microsoft/knowledge/events/declare-event-publishers-local-or-internal.md index 9d097b4..ceca2ca 100644 --- a/microsoft/knowledge/events/declare-event-publishers-local-or-internal.md +++ b/microsoft/knowledge/events/declare-event-publishers-local-or-internal.md @@ -29,7 +29,7 @@ Give an event publisher the narrowest access modifier that still lets the code o Subscribers are unaffected by any of these choices. A non-public publisher also keeps the freedom to add a parameter later, which a public publisher gives up — see `add-new-event-parameters-at-the-end`. -See sample: `declare-event-publishers-local-or-internal.good.al`. +See sample: [`declare-event-publishers-local-or-internal.good.al`](declare-event-publishers-local-or-internal.good.al). ## Anti Pattern @@ -39,4 +39,4 @@ Detection: an `[IntegrationEvent]` or `[BusinessEvent]` publisher that is public The mirror-image anti-pattern belongs to the reviewer, human or agent: recommending that a publisher be made public so extensions can subscribe, or reporting a `local`/`internal` publisher as unreachable dead code. Both readings mistake raising for subscribing. Neither should be raised as a finding. -See sample: `declare-event-publishers-local-or-internal.bad.al`. +See sample: [`declare-event-publishers-local-or-internal.bad.al`](declare-event-publishers-local-or-internal.bad.al). diff --git a/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.md b/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.md index bf563b4..df0c5e2 100644 --- a/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.md +++ b/microsoft/knowledge/events/do-not-add-ishandled-to-an-existing-event.md @@ -17,10 +17,10 @@ Adding a `var IsHandled: Boolean` parameter to an event that already shipped wit Keep the existing event as-is and add a separate `OnBeforeX(…; var IsHandled: Boolean)` before the logic you want to make overridable. Two events with distinct, stable contracts are safer than one event whose meaning and signature were changed under its subscribers. -See sample: `do-not-add-ishandled-to-an-existing-event.good.al`. +See sample: [`do-not-add-ishandled-to-an-existing-event.good.al`](do-not-add-ishandled-to-an-existing-event.good.al). ## Anti Pattern Mutating a shipped event — for example adding `var IsHandled` to `OnAfterCalculateTotal` — to retrofit override behaviour, which overloads the event's meaning and undermines existing subscribers. Detection: an `IsHandled` parameter added to a pre-existing event signature rather than introduced through a new dedicated `OnBefore` publisher. -See sample: `do-not-add-ishandled-to-an-existing-event.bad.al`. +See sample: [`do-not-add-ishandled-to-an-existing-event.bad.al`](do-not-add-ishandled-to-an-existing-event.bad.al). diff --git a/microsoft/knowledge/events/do-not-bypass-critical-operations-with-ishandled.md b/microsoft/knowledge/events/do-not-bypass-critical-operations-with-ishandled.md index e6941ce..7eff65f 100644 --- a/microsoft/knowledge/events/do-not-bypass-critical-operations-with-ishandled.md +++ b/microsoft/knowledge/events/do-not-bypass-critical-operations-with-ishandled.md @@ -17,10 +17,10 @@ The IsHandled override pattern lets a subscriber skip the guarded code entirely. Scope IsHandled to a safe value-calculation block and run the critical operations unconditionally afterwards; or expose a positive `OnAfter…` event for subscribers to adjust results, rather than a bypass around the commit. -See sample: `do-not-bypass-critical-operations-with-ishandled.good.al`. +See sample: [`do-not-bypass-critical-operations-with-ishandled.good.al`](do-not-bypass-critical-operations-with-ishandled.good.al). ## Anti Pattern An `OnBefore…` IsHandled guard wrapping a posting or ledger routine — `if IsHandled then exit;` around the code that creates ledger entries and updates document status — letting subscribers skip the commit. Detection: an `if IsHandled then exit;` whose skipped body performs posting, ledger writes, number-series consumption, or integrity and permission validation. -See sample: `do-not-bypass-critical-operations-with-ishandled.bad.al`. +See sample: [`do-not-bypass-critical-operations-with-ishandled.bad.al`](do-not-bypass-critical-operations-with-ishandled.bad.al). diff --git a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md index 98e0ca7..f613f3a 100644 --- a/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md +++ b/microsoft/knowledge/events/do-not-change-shipped-event-attribute-flags.md @@ -17,10 +17,10 @@ application-area: [all] Keep every available attribute argument exactly as shipped. If new subscribers need different sender/global exposure, publish a new event with the desired flags. Apply the same rule to `Isolated` only on BC20 or later, where that argument exists. Raise both events while the original contract is supported, and choose preferred flags only when designing a new event. -See sample: `do-not-change-shipped-event-attribute-flags.good.al`. +See sample: [`do-not-change-shipped-event-attribute-flags.good.al`](do-not-change-shipped-event-attribute-flags.good.al). ## Anti Pattern Changing a shipped event's `IncludeSender` or `GlobalVarAccess` to modernize its design, including replacing `IncludeSender` with an explicit parameter. On BC20 or later, adding, removing, or toggling `Isolated` is equally contract-significant. Even a change that leaves old subscribers compiling can alter observable execution or exposure; version the event instead. -See sample: `do-not-change-shipped-event-attribute-flags.bad.al`. +See sample: [`do-not-change-shipped-event-attribute-flags.bad.al`](do-not-change-shipped-event-attribute-flags.bad.al). diff --git a/microsoft/knowledge/events/do-not-publish-events-inside-loops.md b/microsoft/knowledge/events/do-not-publish-events-inside-loops.md index badbf28..ecebd19 100644 --- a/microsoft/knowledge/events/do-not-publish-events-inside-loops.md +++ b/microsoft/knowledge/events/do-not-publish-events-inside-loops.md @@ -17,10 +17,10 @@ Raising an event on every iteration of a loop multiplies the cost of every subsc Raise `OnBeforeProcessLines` before the loop and `OnAfterProcessLines` after it, outside the `repeat … until`, so each subscriber runs once per batch rather than once per row. Give those events the record or filters they need to operate on the whole set. -See sample: `do-not-publish-events-inside-loops.good.al`. +See sample: [`do-not-publish-events-inside-loops.good.al`](do-not-publish-events-inside-loops.good.al). ## Anti Pattern An event raised inside the loop body, fired once per iteration, so subscriber cost scales with the row count and large batches slow down or time out. Detection: an `OnBefore…`/`OnAfter…`/`On…` raise located between `repeat` and `until` in a record loop. -See sample: `do-not-publish-events-inside-loops.bad.al`. +See sample: [`do-not-publish-events-inside-loops.bad.al`](do-not-publish-events-inside-loops.bad.al). diff --git a/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md b/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md index a8e72be..802b7b0 100644 --- a/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md +++ b/microsoft/knowledge/events/expose-process-context-via-manually-bound-flag.md @@ -25,7 +25,7 @@ Publish the context as a query and let the binding itself be the state. One proc Bind a fresh instance per run rather than reusing one: the platform refuses to bind the same instance twice but accepts several instances of the same codeunit, so nesting and re-entrancy need no counter. The binding is session-scoped, so work the process starts in another session — a background session, a page background task, a job queue entry — cannot see it; pass the context explicitly there. -See sample: `expose-process-context-via-manually-bound-flag.good.al`. +See sample: [`expose-process-context-via-manually-bound-flag.good.al`](expose-process-context-via-manually-bound-flag.good.al). ## Anti Pattern @@ -37,4 +37,4 @@ Second, the context kept private: the driving app arranges its own marker — ty The mirror-image anti-pattern belongs to the reviewer: flagging the `BindSubscription` here as a leaked binding because no `UnbindSubscription` follows it. Scope release is the mechanism, not an omission — see `microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md`, whose leak case is an instance parked on a `SingleInstance` global that never leaves scope. -See sample: `expose-process-context-via-manually-bound-flag.bad.al`. +See sample: [`expose-process-context-via-manually-bound-flag.bad.al`](expose-process-context-via-manually-bound-flag.bad.al). diff --git a/microsoft/knowledge/events/name-event-parameters-without-abbreviations.md b/microsoft/knowledge/events/name-event-parameters-without-abbreviations.md index 539dd06..9985ae2 100644 --- a/microsoft/knowledge/events/name-event-parameters-without-abbreviations.md +++ b/microsoft/knowledge/events/name-event-parameters-without-abbreviations.md @@ -17,10 +17,10 @@ Event parameter names are part of the public contract a subscriber codes against Use full, unabbreviated names: `(SalesHeader: Record "Sales Header"; DocumentNo: Code[20]; Amount: Decimal)`. Record parameters mirror the table name without spaces, and value parameters read as whole words so the contract is unambiguous. -See sample: `name-event-parameters-without-abbreviations.good.al`. +See sample: [`name-event-parameters-without-abbreviations.good.al`](name-event-parameters-without-abbreviations.good.al). ## Anti Pattern Abbreviated parameter names (`SalesHdr`, `DocNo`, `Amt`) that obscure meaning and vary across publishers, so subscribers must guess what each one holds. Detection: event parameters whose names are truncated forms of the table name or contracted words rather than the full term. -See sample: `name-event-parameters-without-abbreviations.bad.al`. +See sample: [`name-event-parameters-without-abbreviations.bad.al`](name-event-parameters-without-abbreviations.bad.al). diff --git a/microsoft/knowledge/events/name-events-by-publisher-position.md b/microsoft/knowledge/events/name-events-by-publisher-position.md index cd8ac65..fc94098 100644 --- a/microsoft/knowledge/events/name-events-by-publisher-position.md +++ b/microsoft/knowledge/events/name-events-by-publisher-position.md @@ -17,10 +17,10 @@ An event name should tell a subscriber where in the publisher the event fires. T Name by position: `OnBeforePostSalesLine` and `OnAfterPostSalesLine` at the routine boundaries, and `OnPostSalesLineOnAfterCalcAmounts` for an event raised partway through `PostSalesLine` after an amount calculation. The name alone then tells a subscriber both the host routine and the exact point it runs. -See sample: `name-events-by-publisher-position.good.al`. +See sample: [`name-events-by-publisher-position.good.al`](name-events-by-publisher-position.good.al). ## Anti Pattern Ad-hoc event names that omit the host routine or the before/after position (`MyCustomSalesEvent`, `BeforePost`, `SalesLineEvent`), leaving subscribers unable to tell when the event fires relative to the publisher's logic. Detection: publisher names that do not follow the `OnBefore`/`OnAfter` or `OnOnBefore`/`OnAfter` patterns. -See sample: `name-events-by-publisher-position.bad.al`. +See sample: [`name-events-by-publisher-position.bad.al`](name-events-by-publisher-position.bad.al). diff --git a/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.md b/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.md index 3136023..d2e272b 100644 --- a/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.md +++ b/microsoft/knowledge/events/prefer-reusing-or-extending-existing-events.md @@ -17,10 +17,10 @@ Before adding a publisher, check whether an event already fires at that point in When the data you need is already exposed at an existing event, subscribe to it. When the event lacks a parameter, extend that event by appending the parameter at the end — one publisher, one raise — rather than adding a second event beside it. -See sample: `prefer-reusing-or-extending-existing-events.good.al`. +See sample: [`prefer-reusing-or-extending-existing-events.good.al`](prefer-reusing-or-extending-existing-events.good.al). ## Anti Pattern Adding a second event raise immediately after an existing one, or creating `OnBeforeProcessOrderWithCustomer` next to `OnBeforeProcessOrder` just to add a single parameter. Detection: two consecutive `OnBefore…`/`OnAfter…` raises with no logic between them, or near-duplicate event names differing only by a parameter-describing suffix. -See sample: `prefer-reusing-or-extending-existing-events.bad.al`. +See sample: [`prefer-reusing-or-extending-existing-events.bad.al`](prefer-reusing-or-extending-existing-events.bad.al). diff --git a/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md b/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md index 35d75dc..2af8daa 100644 --- a/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md +++ b/microsoft/knowledge/events/prefer-this-over-includesender-in-codeunit-events.md @@ -17,10 +17,10 @@ When designing a new publisher, setting `IncludeSender` to `true` on `[Integrati For a new event, declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly. -See sample: `prefer-this-over-includesender-in-codeunit-events.good.al`. +See sample: [`prefer-this-over-includesender-in-codeunit-events.good.al`](prefer-this-over-includesender-in-codeunit-events.good.al). ## Anti Pattern Designing a new codeunit event with `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where `this` could be passed explicitly as a typed parameter. Do not apply this rule by mutating a shipped event's attribute flags. -See sample: `prefer-this-over-includesender-in-codeunit-events.bad.al`. +See sample: [`prefer-this-over-includesender-in-codeunit-events.bad.al`](prefer-this-over-includesender-in-codeunit-events.bad.al). diff --git a/microsoft/knowledge/events/prefix-temporary-record-event-parameters-with-temp.md b/microsoft/knowledge/events/prefix-temporary-record-event-parameters-with-temp.md index 0a952a5..08370bf 100644 --- a/microsoft/knowledge/events/prefix-temporary-record-event-parameters-with-temp.md +++ b/microsoft/knowledge/events/prefix-temporary-record-event-parameters-with-temp.md @@ -17,10 +17,10 @@ When a record passed to an event is a temporary record — an in-memory buffer n Name temporary record parameters with a `Temp` prefix, for example `var TempSalesLineBuffer: Record "Sales Line" temporary`, so every subscriber sees immediately that the record is an in-memory buffer and treats writes accordingly. -See sample: `prefix-temporary-record-event-parameters-with-temp.good.al`. +See sample: [`prefix-temporary-record-event-parameters-with-temp.good.al`](prefix-temporary-record-event-parameters-with-temp.good.al). ## Anti Pattern A temporary record parameter named without the `Temp` prefix (`var SalesLineBuffer: Record "Sales Line" temporary`), so subscribers cannot tell the record is non-persistent and may rely on writes that are silently discarded. Detection: an event parameter declared `temporary` whose name does not start with `Temp`. -See sample: `prefix-temporary-record-event-parameters-with-temp.bad.al`. +See sample: [`prefix-temporary-record-event-parameters-with-temp.bad.al`](prefix-temporary-record-event-parameters-with-temp.bad.al). diff --git a/microsoft/knowledge/events/preserve-onafter-execution-when-ishandled-skips-the-body.md b/microsoft/knowledge/events/preserve-onafter-execution-when-ishandled-skips-the-body.md index 4ff958c..10b7fd1 100644 --- a/microsoft/knowledge/events/preserve-onafter-execution-when-ishandled-skips-the-body.md +++ b/microsoft/knowledge/events/preserve-onafter-execution-when-ishandled-skips-the-body.md @@ -17,10 +17,10 @@ A routine that exposes both an `OnBefore…` event (with `var IsHandled`) and a Wrap only the default work in `if not IsHandled then begin … end;` and keep the `OnAfterX(…)` raise after that block, outside the guard, so it always fires regardless of whether a subscriber handled the OnBefore. This keeps the override seam and the after-notification independent, which is what subscribers expect. -See sample: `preserve-onafter-execution-when-ishandled-skips-the-body.good.al`. +See sample: [`preserve-onafter-execution-when-ishandled-skips-the-body.good.al`](preserve-onafter-execution-when-ishandled-skips-the-body.good.al). ## Anti Pattern Guarding with `if IsHandled then exit;` and placing the `OnAfterX` raise later in the same routine, so handling the OnBefore short-circuits the whole procedure and the OnAfter event is skipped along with the body. Detection: an `if IsHandled then exit;` in a routine that also raises a paired `OnAfter…` event after that point. -See sample: `preserve-onafter-execution-when-ishandled-skips-the-body.bad.al`. +See sample: [`preserve-onafter-execution-when-ishandled-skips-the-body.bad.al`](preserve-onafter-execution-when-ishandled-skips-the-body.bad.al). diff --git a/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md b/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md index 30c94df..526f136 100644 --- a/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md +++ b/microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md @@ -17,10 +17,10 @@ A key operation — a posting, release, or validation routine — becomes a hard Wrap the operation's core with events: raise `OnBeforeX(var Rec, var IsHandled)` before the default work and `OnAfterX(var Rec)` once it succeeds, at the natural boundaries of the routine. Declare each publisher `[IntegrationEvent(false, false)] local procedure` with an empty body and let the calling routine — never the publisher — own the logic. Pass records by `var` so subscribers can read and adjust them, and include the parameters a subscriber would need to act. This gives partners a stable seam without touching base code. -See sample: `publish-thin-onbefore-onafter-integration-events.good.al`. +See sample: [`publish-thin-onbefore-onafter-integration-events.good.al`](publish-thin-onbefore-onafter-integration-events.good.al). ## Anti Pattern Business logic placed inside an `[IntegrationEvent]` publisher method, so the "event" actually mutates state every time it is raised — defeating the hook and surprising every reader — or a core operation that exposes no extension points at all, forcing partners to overwrite or duplicate it. Detection: an `[IntegrationEvent]`/`[BusinessEvent]` method whose body contains statements rather than being empty, or a posting/validation routine with no surrounding `OnBefore`/`OnAfter` publishers. -See sample: `publish-thin-onbefore-onafter-integration-events.bad.al`. +See sample: [`publish-thin-onbefore-onafter-integration-events.bad.al`](publish-thin-onbefore-onafter-integration-events.bad.al). diff --git a/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md index fba378b..67d5111 100644 --- a/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md +++ b/microsoft/knowledge/events/reset-ishandled-only-when-the-value-can-carry-over.md @@ -17,10 +17,10 @@ A routine that raises an `OnBefore…` integration event with a `var IsHandled: Reset `IsHandled := false;` before a raise only when the value might otherwise carry over as `true`: the same variable is reused after an earlier raise without a control-flow proof that it is false, a raise is re-entered by a loop, the value comes from an input parameter, field, or global, or earlier code seeds it. Prefer separate fresh locals when independent event seams need independent handled state. A reset on a guaranteed-false fresh local used by one non-looping raise, or before a later raise reached only after a semantically valid `if IsHandled then exit;`, can be retained for readability, but its absence is not a correctness finding. -See sample: `reset-ishandled-only-when-the-value-can-carry-over.good.al`. +See sample: [`reset-ishandled-only-when-the-value-can-carry-over.good.al`](reset-ishandled-only-when-the-value-can-carry-over.good.al). ## Anti Pattern Raising `OnBeforeX(…, IsHandled)` when the variable can still be `true` from an earlier raise, an earlier loop iteration, or another source, so the publisher call starts with stale state. Do not match a single non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false. -See sample: `reset-ishandled-only-when-the-value-can-carry-over.bad.al`. +See sample: [`reset-ishandled-only-when-the-value-can-carry-over.bad.al`](reset-ishandled-only-when-the-value-can-carry-over.bad.al). diff --git a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md index 95a2617..004819b 100644 --- a/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md +++ b/microsoft/knowledge/events/treat-local-and-internal-events-as-subscriber-contracts.md @@ -17,10 +17,10 @@ The `local` and `internal` access modifiers on Business and Integration event pu Preserve a shipped Business or Integration event's identity and every existing parameter's name, type/subtype, and passing mode regardless of the procedure access modifier. AS0025 protects names and types, while AS0063 and AS0077 protect removal and addition of `var`. New parameters may be added at any position on a `local` or `internal` event because subscribers can omit them; public event procedures follow the stricter caller contract described by `add-new-event-parameters-at-the-end`. -See sample: `treat-local-and-internal-events-as-subscriber-contracts.good.al`. +See sample: [`treat-local-and-internal-events-as-subscriber-contracts.good.al`](treat-local-and-internal-events-as-subscriber-contracts.good.al). ## Anti Pattern Renaming or removing an existing parameter, changing its type/subtype, or adding/removing its `var` modifier because the event publisher procedure is `local` or `internal`. AppSourceCop checks these subscriber-breaking changes because dependent event subscribers can still bind to the event. Reordering unchanged parameters, or inserting a new parameter among them, is not this anti-pattern. -See sample: `treat-local-and-internal-events-as-subscriber-contracts.bad.al`. +See sample: [`treat-local-and-internal-events-as-subscriber-contracts.bad.al`](treat-local-and-internal-events-as-subscriber-contracts.bad.al). diff --git a/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md b/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md index d273589..6a6d6ab 100644 --- a/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md +++ b/microsoft/knowledge/events/use-ishandled-to-make-base-behaviour-overridable.md @@ -17,10 +17,10 @@ AL has no method overriding, so a `procedure` that runs its body unconditionally Raise `OnBeforeX(…, IsHandled)` as the first step of the routine and guard with `if IsHandled then exit;` before any default logic runs. Declare the publisher `[IntegrationEvent(false, false)] local procedure OnBeforeX(…; var IsHandled: Boolean)` with an empty body, and keep `IsHandled` a `var` parameter so a subscriber can write to it. A subscriber that replaces the behaviour does its work and sets `IsHandled := true`; one that only augments leaves it untouched and guards with `if IsHandled then exit;` itself. Reserve the override hook for cases where a partner genuinely needs to replace logic — when the goal is only to react, a positive `OnAfter` event is the better seam. -See sample: `use-ishandled-to-make-base-behaviour-overridable.good.al`. +See sample: [`use-ishandled-to-make-base-behaviour-overridable.good.al`](use-ishandled-to-make-base-behaviour-overridable.good.al). ## Anti Pattern Two shapes. First, a routine whose default logic always runs because there is no `OnBefore…`/`IsHandled` hook at all — extensions cannot change it without overwriting base code. Second, a routine that raises `OnBeforeX(IsHandled)` but omits the `if IsHandled then exit;` guard, so the default logic still executes after a subscriber set `IsHandled := true`, duplicating work and side effects. Detection: an `OnBefore` publisher with a `var IsHandled: Boolean` parameter whose caller never tests `IsHandled`, or a public routine doing non-trivial work with no overridable seam. -See sample: `use-ishandled-to-make-base-behaviour-overridable.bad.al`. +See sample: [`use-ishandled-to-make-base-behaviour-overridable.bad.al`](use-ishandled-to-make-base-behaviour-overridable.bad.al). diff --git a/microsoft/knowledge/interfaces/assign-codeunit-to-interface-for-testability.md b/microsoft/knowledge/interfaces/assign-codeunit-to-interface-for-testability.md index e0d50d7..8d3e7ce 100644 --- a/microsoft/knowledge/interfaces/assign-codeunit-to-interface-for-testability.md +++ b/microsoft/knowledge/interfaces/assign-codeunit-to-interface-for-testability.md @@ -17,10 +17,10 @@ An interface variable can hold any codeunit that `implements` the interface, ass Declare the dependency as an `Interface` variable on the consumer and supply the implementation from outside — typically setter injection through a procedure that takes an `Interface` parameter, or a parameter on the entry method. Production passes the real implementation codeunit; a test passes a test-double codeunit that implements the same interface with deterministic behaviour. Because a codeunit assigns to an interface variable directly, no enum or factory is needed for the injectable case. The consumer's logic is then verifiable in isolation. -See sample: `assign-codeunit-to-interface-for-testability.good.al`. +See sample: [`assign-codeunit-to-interface-for-testability.good.al`](assign-codeunit-to-interface-for-testability.good.al). ## Anti Pattern A consumer that declares its dependency as a concrete `Codeunit "..."` variable and calls it directly. The collaborator cannot be substituted, so a unit test either runs the production side effects or cannot cover the consumer at all. Detection signal: a `var` of type `Codeunit ""` used for a collaborator that has — or could have — an interface, especially one that performs I/O, posting, or external calls. Extract an interface, depend on the interface variable, and inject the implementation. -See sample: `assign-codeunit-to-interface-for-testability.bad.al`. +See sample: [`assign-codeunit-to-interface-for-testability.bad.al`](assign-codeunit-to-interface-for-testability.bad.al). diff --git a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md index 2aceec4..4f04d67 100644 --- a/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md +++ b/microsoft/knowledge/interfaces/extend-published-interfaces-dont-edit-them.md @@ -17,10 +17,10 @@ Adding a method to a shipped interface changes the contract every implementing c On BC25 or later, declare a new interface that `extends` the published interface and add the new method there. Existing implementers remain valid for the original contract, while new implementers opt in to the extended contract. For targets BC16 through BC24, where interface inheritance is unavailable, publish a new or versioned sibling interface instead. -See sample: `extend-published-interfaces-dont-edit-them.good.al`. +See sample: [`extend-published-interfaces-dont-edit-them.good.al`](extend-published-interfaces-dont-edit-them.good.al). ## Anti Pattern Adding a procedure directly to an interface that has already shipped. Every dependent implementation must immediately add that procedure, so an otherwise compatible app update breaks its implementers. -See sample: `extend-published-interfaces-dont-edit-them.bad.al`. +See sample: [`extend-published-interfaces-dont-edit-them.bad.al`](extend-published-interfaces-dont-edit-them.bad.al). diff --git a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md index d3715e6..de50810 100644 --- a/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md +++ b/microsoft/knowledge/interfaces/handle-unknown-enum-ordinals-with-unknownvalueimplementation.md @@ -17,10 +17,10 @@ An enum ordinal can remain in persisted data after the enum extension that decla On BC18 or later, set `UnknownValueImplementation = = ;` on an enum that implements an interface and can be persisted. Use an implementation that reports a clear domain error or safely contains the unknown state. Keep `DefaultImplementation` separately when declared but unmapped values also need a fallback. -See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`. +See sample: [`handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`](handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al). ## Anti Pattern Defining only `DefaultImplementation` and assuming it also handles a stored ordinal whose enum value has disappeared. After an enum extension is uninstalled, converting that unknown ordinal to the interface can produce a technical runtime error instead of controlled handling. -See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`. +See sample: [`handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`](handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al). diff --git a/microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md b/microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md index 337e0dc..b96b5e8 100644 --- a/microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md +++ b/microsoft/knowledge/interfaces/prefer-interface-over-case-branching.md @@ -17,10 +17,10 @@ When behaviour varies by a discrete "type" — a shipping method, a posting stra Declare an `interface` with the method signatures only (no bodies). Define an `enum` that `implements` the interface and set `Implementation = = ;` on each value, pointing at a codeunit that `implements` the same interface. In the consumer, declare a variable of the interface type, assign the enum value to it, and call the method — the platform dispatches to the codeunit mapped to that value. New variants plug in by adding an enum value and its implementation; existing call sites are untouched. The open/closed boundary lives at the enum, not scattered across `case` blocks. -See sample: `prefer-interface-over-case-branching.good.al`. +See sample: [`prefer-interface-over-case-branching.good.al`](prefer-interface-over-case-branching.good.al). ## Anti Pattern A `case "Shipping Method" of` block that selects behaviour inline, duplicated across the call sites that need it. Each new method forces a synchronized edit to every block, and a missed branch is a silent gap. Detection signal: a `case` statement over an enum value whose branches choose between variant computations or strategies, especially when the same shape appears in more than one procedure. Replace the enum with one that `implements` an interface, move each branch body into an implementation codeunit, and let dispatch happen through an interface variable. -See sample: `prefer-interface-over-case-branching.bad.al`. +See sample: [`prefer-interface-over-case-branching.bad.al`](prefer-interface-over-case-branching.bad.al). diff --git a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md index 7d2523e..a5c3bb6 100644 --- a/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md +++ b/microsoft/knowledge/interfaces/set-defaultimplementation-on-enum.md @@ -17,10 +17,10 @@ An `enum` that `implements` an interface maps each declared value to a codeunit On any extensible enum that implements an interface, set `DefaultImplementation = = ;` at the enum level, pointing at a safe implementation. Values with their own `Implementation` keep using it; declared values without one resolve to the default. Do not rely on this property for persisted ordinals that match no declared enum value. -See sample: `set-defaultimplementation-on-enum.good.al`. +See sample: [`set-defaultimplementation-on-enum.good.al`](set-defaultimplementation-on-enum.good.al). ## Anti Pattern An extensible `enum ... implements ` where at least one value sets no `Implementation` and the enum declares no `DefaultImplementation`. Code that assigns that value to an interface variable and invokes a method throws at the call site, and because the enum is extensible the failing value can be introduced by a third party long after the consumer ships. Detection signal: an enum that implements an interface, has a `value(...)` with no `Implementation`, and no enum-level `DefaultImplementation`. Add a `DefaultImplementation` mapping to close the gap. -See sample: `set-defaultimplementation-on-enum.bad.al`. +See sample: [`set-defaultimplementation-on-enum.bad.al`](set-defaultimplementation-on-enum.bad.al). diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md index 683a8a0..b72fc41 100644 --- a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md @@ -17,10 +17,10 @@ Report dataitem field selection is calculated at compile time and once per datai When a dataitem trigger needs an extra field, add that field in `OnPreDataItem` before iteration starts. This supplements the compiler-selected fields and avoids the first just-in-time load and enumerator update when the trigger reads the extra field. -See sample: `addloadfields-in-report-onpredataitem.good.al`. +See sample: [`addloadfields-in-report-onpredataitem.good.al`](addloadfields-in-report-onpredataitem.good.al). ## Anti Pattern Listing every dataset column in `AddLoadFields`, or omitting a known trigger-only field because the dataset already uses other fields. The former is redundant; the latter causes a just-in-time load on first access and can cause repeated loads when the record is copied or passed by value. -See sample: `addloadfields-in-report-onpredataitem.bad.al`. +See sample: [`addloadfields-in-report-onpredataitem.bad.al`](addloadfields-in-report-onpredataitem.bad.al). diff --git a/microsoft/knowledge/performance/apply-filters-before-iterating.md b/microsoft/knowledge/performance/apply-filters-before-iterating.md index 76d78ec..4c64be2 100644 --- a/microsoft/knowledge/performance/apply-filters-before-iterating.md +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.md @@ -17,10 +17,10 @@ A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at th Move every predicate that can be expressed as an equality or range filter into a `SetRange` or `SetFilter` ahead of the find. Make sure a key (index) exists whose leading fields cover the filter so the optimizer can seek; note that `SetCurrentKey` only sets sort order and is not an index hint (see `setcurrentkey-sets-sort-order-not-index-hint.md`). The loop body should then contain only the work that depends on per-row state. -See sample: `apply-filters-before-iterating.good.al`. +See sample: [`apply-filters-before-iterating.good.al`](apply-filters-before-iterating.good.al). ## Anti Pattern `if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then ProcessCustomer(Customer); until Customer.Next() = 0;` — the loop pays for every row in the table and discards the non-matching ones in AL. The intent is the same as a `SetRange("Country/Region Code", 'US')` ahead of the find, but the cost is not. -See sample: `apply-filters-before-iterating.bad.al`. +See sample: [`apply-filters-before-iterating.bad.al`](apply-filters-before-iterating.bad.al). diff --git a/microsoft/knowledge/performance/apply-guards-before-get.md b/microsoft/knowledge/performance/apply-guards-before-get.md index 9e77411..8d4a876 100644 --- a/microsoft/knowledge/performance/apply-guards-before-get.md +++ b/microsoft/knowledge/performance/apply-guards-before-get.md @@ -17,10 +17,10 @@ A `Get` (or any other database call) executed before a guard that may exit the p Read the procedure top-to-bottom and place every condition that can short-circuit ahead of every database call. The check `if SomeNo = '' then exit;` belongs above `Header.Get(...)`, not below. Each guard moved upward saves one wasted query on the path that exits. -See sample: `apply-guards-before-get.good.al`. +See sample: [`apply-guards-before-get.good.al`](apply-guards-before-get.good.al). ## Anti Pattern `Record.Get(...)` at the top of a procedure followed by `if SomeField = '' then exit;`. The code reads top-down as "load the record, then decide whether we needed it" — exactly the order that wastes the query. The pattern is easy to introduce when guards are added later, defensively, without re-checking call ordering. -See sample: `apply-guards-before-get.bad.al`. +See sample: [`apply-guards-before-get.bad.al`](apply-guards-before-get.bad.al). diff --git a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md index 66ee684..20f27ee 100644 --- a/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md +++ b/microsoft/knowledge/performance/avoid-cloning-records-before-modify-delete-in-loops.md @@ -17,10 +17,10 @@ Microsoft's [AL database-method performance guidance](https://learn.microsoft.co Use `FindSet(true)` when the loop writes the traversed rows, and call `Modify` or `Delete` on that iterating record variable. If generic code is required, open and iterate the `RecordRef` directly instead of calling `GetTable` for each typed record. Keep a per-row loop when validation or row-specific behavior is required; this rule does not imply that `ModifyAll` or `DeleteAll` is equivalent. -See sample: `avoid-cloning-records-before-modify-delete-in-loops.good.al`. +See sample: [`avoid-cloning-records-before-modify-delete-in-loops.good.al`](avoid-cloning-records-before-modify-delete-in-loops.good.al). ## Anti Pattern Inside an active traversal, copy the current row, convert it with `RecordRef.GetTable`, or pass it without `var` to a helper, then call `Modify` or `Delete` on that clone. Do not flag read-only snapshots, temporary records, or copies used to write a different target table; the documented extra-statement concern is clone-before-write on the traversed table. -See sample: `avoid-cloning-records-before-modify-delete-in-loops.bad.al`. +See sample: [`avoid-cloning-records-before-modify-delete-in-loops.bad.al`](avoid-cloning-records-before-modify-delete-in-loops.bad.al). diff --git a/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/microsoft/knowledge/performance/avoid-commit-inside-loops.md index 25ad958..011fd39 100644 --- a/microsoft/knowledge/performance/avoid-commit-inside-loops.md +++ b/microsoft/knowledge/performance/avoid-commit-inside-loops.md @@ -21,10 +21,10 @@ A durability checkpoint inside an outer batch loop can be valid only when the sa If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Persist the last selected key in the same transaction as the completed chunk, then commit after the bounded helper returns. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. Let errors escape so failed work is not recorded as complete. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`. -See sample: `avoid-commit-inside-loops.good.al`. +See sample: [`avoid-commit-inside-loops.good.al`](avoid-commit-inside-loops.good.al). ## Anti Pattern Placing Commit inside `repeat ... until Next() = 0` without persisted progress is almost always a mistake: retries re-enter already committed work, while the cost of starting a transaction on every row dominates the operation. A progress variable held only in memory is not restart-safe. A full-tail `FindSet` with a commit every N rows is not bounded retrieval, even if a persisted watermark makes it restart-safe. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint. -See sample: `avoid-commit-inside-loops.bad.al`. +See sample: [`avoid-commit-inside-loops.bad.al`](avoid-commit-inside-loops.bad.al). diff --git a/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md b/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md index 20e6968..5790a6c 100644 --- a/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md +++ b/microsoft/knowledge/performance/avoid-currpage-update-in-onaftergetrecord.md @@ -19,10 +19,10 @@ application-area: [all] Put display-only results in page variables assigned in `OnAfterGetRecord` without calling `Update`. If the page must refresh after an action, call `CurrPage.Update(false)` from `OnAction` once, not per row. -See sample: `avoid-currpage-update-in-onaftergetrecord.good.al`. +See sample: [`avoid-currpage-update-in-onaftergetrecord.good.al`](avoid-currpage-update-in-onaftergetrecord.good.al). ## Anti Pattern `trigger OnAfterGetRecord() begin ... CurrPage.Update(); end;` on a list. The signal is `CurrPage.Update` inside `OnAfterGetRecord` or `OnAfterGetCurrRecord` without an explicit user action. -See sample: `avoid-currpage-update-in-onaftergetrecord.bad.al`. +See sample: [`avoid-currpage-update-in-onaftergetrecord.bad.al`](avoid-currpage-update-in-onaftergetrecord.bad.al). diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md index f77fbfe..db4f5e1 100644 --- a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md @@ -17,10 +17,10 @@ A `Get` or `FindFirst` against another persistent table inside a loop can produc Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If keys repeat, a dictionary cache can reduce lookups to one per distinct key. `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so. -See sample: `avoid-get-inside-loop-on-large-table.good.al`. +See sample: [`avoid-get-inside-loop-on-large-table.good.al`](avoid-get-inside-loop-on-large-table.good.al). ## Anti Pattern Iterating production BOM lines and calling `Item.Get(BOMLine."No.")` for each line when the same result can be produced by a query joining Production BOM Line to Item. Partial loading alone is only a payload mitigation for this pattern. -See sample: `avoid-get-inside-loop-on-large-table.bad.al`. +See sample: [`avoid-get-inside-loop-on-large-table.bad.al`](avoid-get-inside-loop-on-large-table.bad.al). diff --git a/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md index 966e0dd..82a3bb7 100644 --- a/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md +++ b/microsoft/knowledge/performance/avoid-growing-globals-in-singleinstance-subscribers.md @@ -19,10 +19,10 @@ A codeunit with `SingleInstance = true` is allocated once per session and lives Keep the global footprint on a SingleInstance subscriber bounded and intentional: a handful of flags, a setup record, a bounded cache with a maximum size. When cross-event state is genuinely needed, define an explicit reset point — end of a business process, arrival of a specific terminal event — that clears the growing collection. -See sample: `avoid-growing-globals-in-singleinstance-subscribers.good.al`. +See sample: [`avoid-growing-globals-in-singleinstance-subscribers.good.al`](avoid-growing-globals-in-singleinstance-subscribers.good.al). ## Anti Pattern A SingleInstance subscriber that appends each event's payload to a global list, dictionary, or temporary record without a cap or cleanup trigger. The list grows for hours, memory pressure builds quietly, and debugging the root cause on a live environment is substantially harder than noticing the unbounded append in code review. -See sample: `avoid-growing-globals-in-singleinstance-subscribers.bad.al`. +See sample: [`avoid-growing-globals-in-singleinstance-subscribers.bad.al`](avoid-growing-globals-in-singleinstance-subscribers.bad.al). diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md index b718449..6aae177 100644 --- a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md @@ -17,10 +17,10 @@ application-area: [all] Use `RecordRef`/`FieldRef` for genuinely generic code — permission checks, field copying, table-agnostic export. When the loop target is known at compile time and the loop iterates a large number of rows, declare the typed record and access fields directly; the saved per-iteration overhead is measurable at the volumes the rule targets. -See sample: `avoid-recordref-in-hot-loop.good.al`. +See sample: [`avoid-recordref-in-hot-loop.good.al`](avoid-recordref-in-hot-loop.good.al). ## Anti Pattern `RecRef.Open(Database::Customer); if RecRef.FindSet() then repeat FldRef := RecRef.Field(Customer.FieldNo(Name)); ProcessName(FldRef.Value); until RecRef.Next() = 0;` — the table is fixed at compile time, the field is fixed at compile time, and the loop pays the dynamic-resolution cost on every iteration. The direct `Customer.Name` form does the same work without the lookup. -See sample: `avoid-recordref-in-hot-loop.bad.al`. +See sample: [`avoid-recordref-in-hot-loop.bad.al`](avoid-recordref-in-hot-loop.bad.al). diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md index 9684769..3859b7c 100644 --- a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md @@ -17,10 +17,10 @@ A list or card page's `OnAfterGetRecord` trigger fires *because* the platform ha Inside page triggers — `OnAfterGetRecord`, `OnAfterGetCurrRecord`, validation triggers — read from `Rec` (or the trigger's record parameter). The platform exposes the freshly loaded record there for exactly this purpose. Reach for `Get` only when the trigger needs a *different* record than the one being displayed. -See sample: `avoid-redundant-get-when-record-already-loaded.good.al`. +See sample: [`avoid-redundant-get-when-record-already-loaded.good.al`](avoid-redundant-get-when-record-already-loaded.good.al). ## Anti Pattern `AssemblyLineRec.Get("Document Type", "Document No.", "Line No.");` at the top of `OnAfterGetRecord`, when the trigger is on the `Assembly Line` page itself and `Rec` already holds that row. The pattern often appears when a helper that expects a record parameter is invoked from a page trigger and the author writes a `Get` to "freshen" `Rec` rather than passing `Rec` through. -See sample: `avoid-redundant-get-when-record-already-loaded.bad.al`. +See sample: [`avoid-redundant-get-when-record-already-loaded.bad.al`](avoid-redundant-get-when-record-already-loaded.bad.al). diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md index 834641a..bb03f41 100644 --- a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md @@ -17,10 +17,10 @@ A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a wr Sequence the operation so user confirmation happens *before* any database write that takes a lock the prompt holds open. The shape is: ask the user → if confirmed, acquire locks and post. `if Confirm(...) then begin SalesHeader.LockTable(); SalesHeader.Get(DocNo); PostSalesOrder(SalesHeader); end;` keeps the lock window down to the work itself. -See sample: `avoid-user-prompts-inside-transactions.good.al`. +See sample: [`avoid-user-prompts-inside-transactions.good.al`](avoid-user-prompts-inside-transactions.good.al). ## Anti Pattern `SalesHeader.LockTable(); SalesHeader.Get(DocNo); if Confirm('Post this order?') then ...;` — the lock is held for as long as the dialog is up. A user who steps away to lunch holds the lock for an hour, and every other session that touches that row blocks for the duration. -See sample: `avoid-user-prompts-inside-transactions.bad.al`. +See sample: [`avoid-user-prompts-inside-transactions.bad.al`](avoid-user-prompts-inside-transactions.bad.al). diff --git a/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md b/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md index 616c789..cdb7405 100644 --- a/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md +++ b/microsoft/knowledge/performance/batch-number-series-instead-of-getnextno-per-row.md @@ -19,10 +19,10 @@ application-area: [all] Inside a multi-row insert, call `"No. Series - Batch".GetNextNo` per row and `SaveState` once after the loop when the series must remain gapless. Use `NumberSequence.Next` when holes are allowed. Do not replace a single `OnInsert` `GetNextNo` for one master record; that path is not the hotspot. -See sample: `batch-number-series-instead-of-getnextno-per-row.good.al`. +See sample: [`batch-number-series-instead-of-getnextno-per-row.good.al`](batch-number-series-instead-of-getnextno-per-row.good.al). ## Anti Pattern `NoSeries.GetNextNo(...)` inside `repeat ... Insert ... until Next() = 0` where the series is **gapless** (Allow Gaps = false). Each iteration takes the series-line lock. The signal is `"No. Series"` (not `"No. Series - Batch"`) in a loop that inserts more than one row; do not flag the same pattern when the series has Allow Gaps enabled, as the `NumberSequence` path already avoids the lock. -See sample: `batch-number-series-instead-of-getnextno-per-row.bad.al`. +See sample: [`batch-number-series-instead-of-getnextno-per-row.bad.al`](batch-number-series-instead-of-getnextno-per-row.bad.al). diff --git a/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md b/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md index 7c4073b..9c840a7 100644 --- a/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md +++ b/microsoft/knowledge/performance/boolean-operators-do-not-short-circuit.md @@ -23,13 +23,13 @@ For an `or`-shaped condition, do not nest: nesting `if A then if B then Action` Where a chain of `and`-guards runs past about three conditions, stop nesting and use a `case` statement instead — see `case-true-of-for-long-condition-chains.md`. Keep `and` and `or` for operands that are independently safe and cheap — in-memory field comparisons, enum tests, bound checks — where combining them reads better and costs nothing. -See sample: `boolean-operators-do-not-short-circuit.good.al`. +See sample: [`boolean-operators-do-not-short-circuit.good.al`](boolean-operators-do-not-short-circuit.good.al). ## Anti Pattern A single condition that joins a guard with an operand depending on that guard, or with an expensive operand, using `and` or `or`. The consequence is either wasted work on every evaluation — a database call or validation procedure invoked even when the outcome is already decided — or a runtime error or silently wrong result that the guard was written to prevent. Applying the `and` fix to an `or` condition is a distinct mistake: rewriting `A or B` as nested `if`s drops the `A`-true/`B`-false case instead of preserving it. Detection signals: an operand that indexes an array or list with a variable whose bounds are checked in a sibling operand; `Record.Get(...)` or a `Find`/`IsEmpty` call as one operand of `and` with a field read of the same record as another; an expensive or unsafe operand combined with `or` next to a condition that alone already makes the result true; a boolean-returning procedure call combined with a cheap field test. The pattern is common in code ported from a language that does short-circuit, and in conditions grown by appending a clause to an existing `if`. -See sample: `boolean-operators-do-not-short-circuit.bad.al`. +See sample: [`boolean-operators-do-not-short-circuit.bad.al`](boolean-operators-do-not-short-circuit.bad.al). ## See also diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md index 7d0752f..f4d7ad9 100644 --- a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md @@ -17,10 +17,10 @@ application-area: [all] When the procedure totals a FlowField (or several) across a filtered set, set the filters, then call `CalcSums("Field 1", "Field 2", ...)`. The platform issues one query; the result is read off the record's FlowField slot. Single `CalcFields` outside loops is fine, and `CalcFields` on the current row in a page's `OnAfterGetRecord` or in `OnValidate` is the standard pattern — those are per-action, not per-row over a large set. -See sample: `calcsums-instead-of-calcfields-in-loop.good.al`. +See sample: [`calcsums-instead-of-calcfields-in-loop.good.al`](calcsums-instead-of-calcfields-in-loop.good.al). ## Anti Pattern `if CustLedgerEntry.FindSet() then repeat CustLedgerEntry.CalcFields("Remaining Amount"); Total += CustLedgerEntry."Remaining Amount"; until CustLedgerEntry.Next() = 0;` — exactly the upstream-flagged shape. The iteration is the cheap part; the per-row `CalcFields` is what scales linearly with table size. -See sample: `calcsums-instead-of-calcfields-in-loop.bad.al`. +See sample: [`calcsums-instead-of-calcfields-in-loop.bad.al`](calcsums-instead-of-calcfields-in-loop.bad.al). diff --git a/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md b/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md index 1e0457e..1ec3863 100644 --- a/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md +++ b/microsoft/knowledge/performance/case-true-of-for-long-condition-chains.md @@ -17,13 +17,13 @@ Because AL gives no short-circuit guarantee for `and` and `or`, a chain of condi Sequence two or three dependent conditions with nested `if`. Beyond that, switch to `case`: use `case false of` for a chain of guards where every condition must hold, letting control fall past `end` when all of them pass; use `case true of` for first-match dispatch, where each later probe runs only if the earlier ones did not match. Comma-separate conditions into one value set only when every one of them is a pure, order-independent test with no side effect — a field comparison, an enum check, a bound test — so it makes no difference whether AL evaluates all of them or stops early; grouping these costs nothing and removes the repeated action. A condition that guards another, or that carries a side effect or a cost of its own — a `Get`, a `Find`, a procedure call — keeps its own value set, placed immediately after the value set it depends on, so the code relies only on the ordering the documentation actually states. A value set needs no parentheses around a comparison, unlike an operand of `and` or `or`: the AL operator hierarchy places `and` and `or` above the comparison operators, so parentheses are mandatory there and the chain fills up with them. This keeps every condition at one indentation level, makes evaluation order explicit rather than implied by nesting, and preserves the stop-at-first-match behaviour it relies on. It also aligns with the AL programming convention that more than two alternatives belong in a `case` statement rather than an `if-then-else`. -See sample: `case-true-of-for-long-condition-chains.good.al`. +See sample: [`case-true-of-for-long-condition-chains.good.al`](case-true-of-for-long-condition-chains.good.al). ## Anti Pattern An `if` ladder four or more levels deep whose only purpose is sequencing guards. Detection: a chain of nested `if` statements with no `else`, each condition guarding the one below it, terminating in a single action or `exit`; or the same `exit`/`error` duplicated at every level of such a nested chain, purely to escape it. The second, worse form is collapsing that ladder into one `and` chain to escape the nesting — that trades indentation for a real defect, because the operands are still all evaluated. A third, subtler form is over-applying the comma-grouping itself: putting a guard and the condition it protects — for example `Item.Get(...)` and a read of a field on that same record — into one comma-separated value set. That relies on an evaluation order within a single value set that the documentation does not state; keep them in separate value sets instead. Reach for `case` over nested `if` or a collapsed `and` chain, and keep order-dependent conditions in their own value sets within it. -See sample: `case-true-of-for-long-condition-chains.bad.al`. +See sample: [`case-true-of-for-long-condition-chains.bad.al`](case-true-of-for-long-condition-chains.bad.al). ## See also diff --git a/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md b/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md index fddef76..5946e42 100644 --- a/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md +++ b/microsoft/knowledge/performance/changecompany-in-loop-drops-caches.md @@ -19,10 +19,10 @@ application-area: [all] Group work by company. Call `ChangeCompany` once per distinct company, then `FindSet`/`Get` that company's rows. If the record variable is reused afterward, call `ChangeCompany()` without a company name to redirect it back to the current company. -See sample: `changecompany-in-loop-drops-caches.good.al`. +See sample: [`changecompany-in-loop-drops-caches.good.al`](changecompany-in-loop-drops-caches.good.al). ## Anti Pattern `repeat Rec.ChangeCompany(Buffer.Company); Rec.Get(Buffer."No."); until Buffer.Next() = 0` when `Buffer` is not ordered by company, or even when it is — if `ChangeCompany` still runs every row. The signal is `ChangeCompany` inside `repeat`/`while` keyed by a document line rather than by a company loop. -See sample: `changecompany-in-loop-drops-caches.bad.al`. +See sample: [`changecompany-in-loop-drops-caches.bad.al`](changecompany-in-loop-drops-caches.bad.al). diff --git a/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md index 3261a2c..1a60051 100644 --- a/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md +++ b/microsoft/knowledge/performance/choose-maintainsiftindex-by-read-write-ratio.md @@ -19,7 +19,7 @@ application-area: [all] Measure aggregate-read latency and write cost under realistic filters and volumes. Keep `MaintainSIFTIndex = true` when the maintained aggregate materially benefits frequent `CalcSums` or FlowField reads. Consider `false` when writes dominate and the less-frequent aggregate reads can tolerate calculation from the base table. -See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`. +See sample: [`choose-maintainsiftindex-by-read-write-ratio.good.al`](choose-maintainsiftindex-by-read-write-ratio.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md index 89777f1..8abdd20 100644 --- a/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md +++ b/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md @@ -17,10 +17,10 @@ application-area: [all] When a piece of work must either complete fully or have no effect, put it in its own codeunit and invoke it via `Codeunit.Run`, capturing the return. Use `if not Codeunit.Run(X) then Error(...)` to abort and unwind; use the plain boolean branch to react to failure without aborting the caller. This replaces the SQL-style `BEGIN TRAN / COMMIT / ROLLBACK` habit with a pattern the AL runtime implements natively. Do not confuse `Codeunit.Run` with `[TryFunction]` — both catch errors, but only `Codeunit.Run` rolls back database changes on failure (see `use-tryfunction-for-error-catching-not-rollback.md`). Note that if the caller is already in a write transaction, the platform requires a `Commit()` before `Codeunit.Run` — the sub-operation cannot nest inside an open transaction (see `codeunit-run-requires-prior-commit-inside-transaction.md`). -See sample: `codeunit-run-as-atomic-sub-operation.good.al`. +See sample: [`codeunit-run-as-atomic-sub-operation.good.al`](codeunit-run-as-atomic-sub-operation.good.al). ## Anti Pattern Inlining the work in the caller and sprinkling `Commit()` to simulate sub-transaction boundaries. The caller's enclosing transaction is fused to the sub-work; any Commit between checkpoints survives subsequent errors, and any errors after a Commit cannot be cleanly unwound. Per-row Commits (see `avoid-commit-inside-loops.md`) are a frequent symptom. -See sample: `codeunit-run-as-atomic-sub-operation.bad.al`. +See sample: [`codeunit-run-as-atomic-sub-operation.bad.al`](codeunit-run-as-atomic-sub-operation.bad.al). diff --git a/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md index a07520f..936ae5b 100644 --- a/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md +++ b/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md @@ -17,10 +17,10 @@ application-area: [all] For the `Codeunit.Run` atomic-sub-operation pattern (see `codeunit-run-as-atomic-sub-operation.md`) to work in a loop, keep the outer scope **read-only**. Move per-iteration writes — progress updates, logging, audit entries — into the sub-codeunit so they commit or roll back together with the per-item work. If logging must live outside the atomic boundary, defer it: collect failure info in memory during the loop (a `List of [Text]`, a temporary record, local variables) and write it in one pass after the loop ends, when no outer write transaction is open. -See sample: `codeunit-run-requires-prior-commit-inside-transaction.good.al`. +See sample: [`codeunit-run-requires-prior-commit-inside-transaction.good.al`](codeunit-run-requires-prior-commit-inside-transaction.good.al). ## Anti Pattern Inserting `Commit()` before each `Codeunit.Run` to silence the runtime error. The error goes away, but the outer scope now commits per iteration — the behavior `avoid-commit-inside-loops.md` exists to warn against. Attempting to silence the implicit commit inside the sub-codeunit with `[CommitBehavior(CommitBehavior::Ignore)]` also fails: the attribute does not apply to `Codeunit.Run`'s implicit commit. Conditioning the Commit on `Database.IsInWriteTransaction()` (runtime 11.0+) is another version of the same trap — the method has legitimate uses for diagnostics and library code that genuinely cannot control its caller, but branching production flow on runtime transaction state typically signals unclear ownership that would be better fixed by restructuring the caller so transaction state is predictable. -See sample: `codeunit-run-requires-prior-commit-inside-transaction.bad.al`. +See sample: [`codeunit-run-requires-prior-commit-inside-transaction.bad.al`](codeunit-run-requires-prior-commit-inside-transaction.bad.al). diff --git a/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md b/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md index 89ee55e..943242d 100644 --- a/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md +++ b/microsoft/knowledge/performance/dataaccessintent-readonly-on-analytical-objects.md @@ -19,10 +19,10 @@ application-area: [all] On report objects and `PageType = API` pages with `Editable = false` that never write, set `DataAccessIntent = ReadOnly`. For query objects, set it when the query is consumed via OData or an API endpoint. Keep the default on objects that insert, modify, or call a write codeunit from a processing-only report. -See sample: `dataaccessintent-readonly-on-analytical-objects.good.al`. +See sample: [`dataaccessintent-readonly-on-analytical-objects.good.al`](dataaccessintent-readonly-on-analytical-objects.good.al). ## Anti Pattern A listing report or API query with no `DataAccessIntent` that scans G/L or sales lines. The object is read-only in practice and still loads the primary. -See sample: `dataaccessintent-readonly-on-analytical-objects.bad.al`. +See sample: [`dataaccessintent-readonly-on-analytical-objects.bad.al`](dataaccessintent-readonly-on-analytical-objects.bad.al). diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md index f25af2e..2580144 100644 --- a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md @@ -17,10 +17,10 @@ application-area: [all] Reserve `LockTable` for the read directly before a `Modify`, `Insert`, or `Delete` that depends on the read value. If a helper is sometimes called for reading and sometimes for writing, split it into separate read and write paths and call `LockTable` only on the write path. For read-only existence checks or lookups, the right primitive is `ReadIsolation` (see `prefer-readisolation-over-locktable-for-reads.md`). -See sample: `do-not-locktable-in-read-only-procedure.good.al`. +See sample: [`do-not-locktable-in-read-only-procedure.good.al`](do-not-locktable-in-read-only-procedure.good.al). ## Anti Pattern A pure getter that opens with `Rec.LockTable();`. Every caller's transaction now acquires `UPDLOCK` on that table for every subsequent read until commit. The contention shows up as blocking on unrelated sessions whose own code path looks innocent — the locker is invisible to the blocked reader. -See sample: `do-not-locktable-in-read-only-procedure.bad.al`. +See sample: [`do-not-locktable-in-read-only-procedure.bad.al`](do-not-locktable-in-read-only-procedure.bad.al). diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md index 9de0903..a30ada9 100644 --- a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md @@ -17,10 +17,10 @@ A list page's `OnAfterGetRecord` fires once per visible row, every time the user When the trigger needs to compute display-only state per row, write the result into a page variable (a global on the page object) rather than back to the database. Reserve `Modify` for triggers that fire on an explicit user action — `OnAction`, validation triggers, `OnQueryClosePage` — where one action maps to one write. -See sample: `do-not-modify-in-onaftergetrecord.good.al`. +See sample: [`do-not-modify-in-onaftergetrecord.good.al`](do-not-modify-in-onaftergetrecord.good.al). ## Anti Pattern `trigger OnAfterGetRecord() begin Rec."Warning Flag" := CalcWarning(); Rec.Modify(); end;` — on a list page over a moderately sized table, scrolling through fifty rows produces fifty writes. The page feels slow, the table accumulates churn, and the warning flag — which is recomputed on every refresh anyway — never needed persistence. -See sample: `do-not-modify-in-onaftergetrecord.bad.al`. +See sample: [`do-not-modify-in-onaftergetrecord.bad.al`](do-not-modify-in-onaftergetrecord.bad.al). diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md index a7215be..ef4b137 100644 --- a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md @@ -17,10 +17,10 @@ application-area: [all] If a page or record was declared temporary on purpose — to buffer payloads, accept synthetic rows, or expose computed data through an API surface without persisting it — keep it temporary. When removing the property looks necessary, audit the call sites first: a temporary API page is often consumed by integrations that issue many calls per minute, and the round-trip cost is paid per call. If persistence is genuinely required, weigh storage and lock cost against alternatives (a regular table the API page reads from, an event-driven write). -See sample: `do-not-remove-sourcetabletemporary-from-api-page.good.al`. +See sample: [`do-not-remove-sourcetabletemporary-from-api-page.good.al`](do-not-remove-sourcetabletemporary-from-api-page.good.al). ## Anti Pattern Dropping `SourceTableTemporary = true` from an API page to "simplify" it, without revisiting the access pattern. The page begins issuing real SQL on every request; locks now contend with other writers; bulk integrations slow proportionally. The same trap exists for a record that was `TableType = Temporary` and gets demoted to a persistent table to make a debugger view easier. -See sample: `do-not-remove-sourcetabletemporary-from-api-page.bad.al`. +See sample: [`do-not-remove-sourcetabletemporary-from-api-page.bad.al`](do-not-remove-sourcetabletemporary-from-api-page.bad.al). diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md index 48a1deb..3ab780a 100644 --- a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md @@ -17,10 +17,10 @@ application-area: [all] Use `FindSet(true)` only when the loop body genuinely modifies the iterated rows; use `FindSet()` (or `FindSet(false)`) when the loop only reads. Do not write `FindSet(true, true)` or `FindSet(true, false)` — the two-parameter form is the obsolete signature. -See sample: `findset-true-applies-updlock-on-read.good.al`. +See sample: [`findset-true-applies-updlock-on-read.good.al`](findset-true-applies-updlock-on-read.good.al). ## Anti Pattern `FindSet(true)` on a loop that does not modify the iterated rows takes an `UpdLock` the work does not need; competing readers and writers stall against a lock the loop never uses. The mirror anti-pattern is `FindSet()` (no parameter) on a loop that *does* modify each row — the read takes a shared lock, the `Modify` then needs to upgrade, and the gap between them is a deadlock candidate. -See sample: `findset-true-applies-updlock-on-read.bad.al`. +See sample: [`findset-true-applies-updlock-on-read.bad.al`](findset-true-applies-updlock-on-read.bad.al). diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md index 3a63613..fdedc42 100644 --- a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md @@ -17,10 +17,10 @@ A FlowField is computed by SQL on demand. CodeCop AA0232 — "FlowFields should When introducing or changing a FlowField, walk the `CalcFormula`'s `WHERE` clause field by field and verify the source table has a key whose key fields cover those filters, with the aggregated field in `SumIndexFields`. The same applies when the destination side of the FlowField filter is a list-page column: the page filter triggers the FlowField on every visible row, and only SIFT keeps that affordable. -See sample: `flowfield-source-key-needs-sumindexfields.good.al`. +See sample: [`flowfield-source-key-needs-sumindexfields.good.al`](flowfield-source-key-needs-sumindexfields.good.al). ## Anti Pattern A `sum` FlowField against a large source table with no matching SIFT key. Each calculation aggregates rows directly; on a ledger-sized source the FlowField becomes the slowest column on every page that displays it. Pointing an existing FlowField's `CalcFormula` at a larger source table without verifying the new source's keys is the same trap a step removed — the upstream review guidance flags it as "CalcFormula changed to larger source table". -See sample: `flowfield-source-key-needs-sumindexfields.bad.al`. +See sample: [`flowfield-source-key-needs-sumindexfields.bad.al`](flowfield-source-key-needs-sumindexfields.bad.al). diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md index c466ffe..d56c716 100644 --- a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md @@ -17,10 +17,10 @@ Event subscribers fire on every event matching their signature — for `OnAfterV Open the subscriber with an in-memory predicate that filters out the calls the subscriber does not handle — record type, document type, status, parameter-passed flags. Only after the cheap guard passes should the body issue a database call, and only with `SetLoadFields` for the columns the body actually reads. -See sample: `guard-event-subscribers-before-db-call.good.al`. +See sample: [`guard-event-subscribers-before-db-call.good.al`](guard-event-subscribers-before-db-call.good.al). ## Anti Pattern `[EventSubscriber(...'OnAfterValidateEvent', 'Quantity', ...)] local procedure ... var Item: Record Item; begin Item.Get(Rec."No."); if Item.HasCustomPricing() then ...;` — `Item.Get` runs on every quantity change, including changes to lines whose `Type` is not `Item`. A pre-check `if Rec.Type <> Rec.Type::Item then exit;` ahead of the `Get` removes most of the calls. -See sample: `guard-event-subscribers-before-db-call.bad.al`. +See sample: [`guard-event-subscribers-before-db-call.bad.al`](guard-event-subscribers-before-db-call.bad.al). diff --git a/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md b/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md index 01dc1f8..bf0bb7d 100644 --- a/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md +++ b/microsoft/knowledge/performance/guiallowed-guard-on-pages-used-as-odata.md @@ -19,10 +19,10 @@ Pages exposed as OData, including Edit in Excel, still run AL page triggers for Wrap UI-only work — FactBox refresh, notifications, defaulting that is not part of the web-service contract — in `if GuiAllowed then`. Keep the OData path to field values the API actually returns. -See sample: `guiallowed-guard-on-pages-used-as-odata.good.al`. +See sample: [`guiallowed-guard-on-pages-used-as-odata.good.al`](guiallowed-guard-on-pages-used-as-odata.good.al). ## Anti Pattern Unconditional FactBox or calculation logic in `OnAfterGetRecord` / `OnAfterGetCurrRecord` on a page that is published as a web service or used with Edit in Excel. The signal is trigger work that calls `CurrPage` parts or extra queries without a `GuiAllowed` guard. -See sample: `guiallowed-guard-on-pages-used-as-odata.bad.al`. +See sample: [`guiallowed-guard-on-pages-used-as-odata.bad.al`](guiallowed-guard-on-pages-used-as-odata.bad.al). diff --git a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md index b03ac51..7db17d3 100644 --- a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md +++ b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-before-bc26-opt-in.md @@ -17,10 +17,10 @@ By default, a FlowField used directly as a page control's source is calculated w On BC 26 and later, enable and verify the visible-only FlowField feature before relying on `Visible` to suppress calculation. When the target environment does not guarantee that option, avoid binding an expensive FlowField directly to a usually-hidden control: calculate it only in the branch that displays it and bind the page control to a variable. Do not flag a hidden FlowField when the v26 feature is known to be enabled or the FlowField is cheap and intentionally preloaded. -See sample: `hidden-flowfields-still-calculate-before-bc26-opt-in.good.al`. +See sample: [`hidden-flowfields-still-calculate-before-bc26-opt-in.good.al`](hidden-flowfields-still-calculate-before-bc26-opt-in.good.al). ## Anti Pattern Adding a costly Sum or Lookup FlowField to a page with `Visible = SomeRareMode` and assuming the hidden state prevents its query on all supported versions. The review signal is the direct FlowField source plus conditional or false visibility, not visibility alone. -See sample: `hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al`. +See sample: [`hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al`](hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al). diff --git a/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md index 4c1d500..21a2c04 100644 --- a/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md +++ b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md @@ -21,10 +21,10 @@ Defer the HTTP call to a separate session. When the external operation must corr A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood. -See sample: `httpclient-inside-write-transaction-holds-locks.good.al`. +See sample: [`httpclient-inside-write-transaction-holds-locks.good.al`](httpclient-inside-write-transaction-holds-locks.good.al). ## Anti Pattern `Modify`/`Insert` followed by `HttpClient` in the same procedure with no `Commit` between them. Detection signal: any `HttpClient` use after a write on the same execution path, especially in posting, page actions, or subscribers. -See sample: `httpclient-inside-write-transaction-holds-locks.bad.al`. +See sample: [`httpclient-inside-write-transaction-holds-locks.bad.al`](httpclient-inside-write-transaction-holds-locks.bad.al). diff --git a/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md b/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md index a8bf187..c88cdfe 100644 --- a/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md +++ b/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md @@ -19,10 +19,10 @@ application-area: [all] When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set. -See sample: `isempty-before-findset-is-extra-round-trip.good.al`. +See sample: [`isempty-before-findset-is-extra-round-trip.good.al`](isempty-before-findset-is-extra-round-trip.good.al). ## Anti Pattern `if not Rec.IsEmpty() then if Rec.FindSet() then repeat`. Also a false-positive review comment that asks to add that guard. The second read does not avoid the first; it duplicates it. -See sample: `isempty-before-findset-is-extra-round-trip.bad.al`. +See sample: [`isempty-before-findset-is-extra-round-trip.bad.al`](isempty-before-findset-is-extra-round-trip.bad.al). diff --git a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md index 257b0fb..6a48663 100644 --- a/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md +++ b/microsoft/knowledge/performance/load-common-fields-before-branching-on-case.md @@ -19,10 +19,10 @@ When a known input determines which fields a subsequent record read will use, a Call `SetLoadFields` with the common fields. In each branch, call `AddLoadFields` with that branch's normal fields and then perform the record read. This applies only when the discriminator is known before the read; branching on a field from an already-loaded row is too late to tailor that row's initial SQL projection. -See sample: `load-common-fields-before-branching-on-case.good.al`. +See sample: [`load-common-fields-before-branching-on-case.good.al`](load-common-fields-before-branching-on-case.good.al). ## Anti Pattern A single top-level `SetLoadFields` enumerating every branch's fields, or a branch-local `SetLoadFields` that accidentally discards the common selection. Both make the declared load plan differ from the fields the selected path actually uses. -See sample: `load-common-fields-before-branching-on-case.bad.al`. +See sample: [`load-common-fields-before-branching-on-case.bad.al`](load-common-fields-before-branching-on-case.bad.al). diff --git a/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md index 533ab8c..4e88b83 100644 --- a/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md +++ b/microsoft/knowledge/performance/load-only-primary-key-fields-for-reference-work.md @@ -19,10 +19,10 @@ Work that uses a record only for its identity — passing it to another procedur When the iterating code's body touches only primary key fields (or passes the record to another procedure that will apply its own `SetLoadFields`), declare `SetLoadFields` with just the primary key fields before applying filters and calling `FindSet`. Callers downstream that need more fields issue their own `Get` or extend the load explicitly. -See sample: `load-only-primary-key-fields-for-reference-work.good.al`. +See sample: [`load-only-primary-key-fields-for-reference-work.good.al`](load-only-primary-key-fields-for-reference-work.good.al). ## Anti Pattern Using the default full-record load in loops whose body only reads the primary key, or forwards the record to another codeunit that immediately re-queries. The non-key payload is fetched across the wire and held in memory for the duration of the loop, then discarded unread. -See sample: `load-only-primary-key-fields-for-reference-work.bad.al`. +See sample: [`load-only-primary-key-fields-for-reference-work.bad.al`](load-only-primary-key-fields-for-reference-work.bad.al). diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md index e540859..8898e44 100644 --- a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md +++ b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md @@ -17,7 +17,7 @@ application-area: [all] When changing a key property to `MaintainSQLIndex = false`, find every FlowField whose `CalcFormula` filters on that key and verify another key covers the same fields. When adding a FlowField whose source table has only a `MaintainSQLIndex = false` key for its filter columns, add a fully-indexed key (or accept that the FlowField cannot ride SIFT and reshape the design — see `flowfield-source-key-needs-sumindexfields.md`). -See sample: `maintainsqlindex-false-breaks-flowfield-sift.bad.al`. +See sample: [`maintainsqlindex-false-breaks-flowfield-sift.bad.al`](maintainsqlindex-false-breaks-flowfield-sift.bad.al). ## Anti Pattern diff --git a/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md index 3cb8459..e8da1c5 100644 --- a/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md +++ b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md @@ -19,10 +19,10 @@ application-area: [all] Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work. -See sample: `oncompanyopen-subscribers-must-not-do-io.good.al`. +See sample: [`oncompanyopen-subscribers-must-not-do-io.good.al`](oncompanyopen-subscribers-must-not-do-io.good.al). ## Anti Pattern An `OnAfterLogin` / `OnCompanyOpenCompleted` subscriber that calls `HttpClient` or scans a ledger. Detection signal: `HttpClient`, `FindSet`, or `CalcFields` inside a subscriber bound to those events. -See sample: `oncompanyopen-subscribers-must-not-do-io.bad.al`. +See sample: [`oncompanyopen-subscribers-must-not-do-io.bad.al`](oncompanyopen-subscribers-must-not-do-io.bad.al). diff --git a/microsoft/knowledge/performance/order-case-branches-by-frequency.md b/microsoft/knowledge/performance/order-case-branches-by-frequency.md index 1634475..dd327e9 100644 --- a/microsoft/knowledge/performance/order-case-branches-by-frequency.md +++ b/microsoft/knowledge/performance/order-case-branches-by-frequency.md @@ -19,10 +19,10 @@ AL documentation does not guarantee that a `case` statement uses a linear compar After profiling confirms the comparison path matters and the runtime frequency is known, list common branches first without changing the set of handled values, fallback behavior, or branch bodies. -See sample: `order-case-branches-by-frequency.good.al`. +See sample: [`order-case-branches-by-frequency.good.al`](order-case-branches-by-frequency.good.al). ## Anti Pattern Reordering branches based on assumed frequency without profiling, or changing an `else` arm or handled value while making the optimization. The good and bad forms must differ only in branch order. -See sample: `order-case-branches-by-frequency.bad.al`. +See sample: [`order-case-branches-by-frequency.bad.al`](order-case-branches-by-frequency.bad.al). diff --git a/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md b/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md index 48fae1c..8b2bb39 100644 --- a/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md +++ b/microsoft/knowledge/performance/page-background-tasks-for-expensive-cues.md @@ -19,10 +19,10 @@ Role-center cues and CardPart totals that run `CalcFields`, scans, or HTTP on th Bind the cue to a page variable, enqueue a read-only calculation from `OnAfterGetCurrRecord` (not `OnAfterGetRecord` on a list), and apply the result in `OnPageBackgroundTaskCompleted`. Show a placeholder until then. -See sample: `page-background-tasks-for-expensive-cues.good.al`. +See sample: [`page-background-tasks-for-expensive-cues.good.al`](page-background-tasks-for-expensive-cues.good.al). ## Anti Pattern `CalcFields` or a ledger `Count` in `OnOpenPage` / `OnAfterGetCurrRecord` of a CueGroup CardPart with no background task. The Role Center waits on SQL the user may never look at. -See sample: `page-background-tasks-for-expensive-cues.bad.al`. +See sample: [`page-background-tasks-for-expensive-cues.bad.al`](page-background-tasks-for-expensive-cues.bad.al). diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.md b/microsoft/knowledge/performance/pair-findset-with-next-loop.md index 80f855c..12d3bd4 100644 --- a/microsoft/knowledge/performance/pair-findset-with-next-loop.md +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.md @@ -17,10 +17,10 @@ Two CodeCop rules carve out the loop pattern. AA0181 says `FindSet()`/`Find()` " When the body executes `repeat ... until Next() = 0;`, open the iteration with `FindSet()`. When the body needs one record and does not call `Next`, use `FindFirst`, `FindLast`, or — if the full primary key is known — `Get` (see `use-get-instead-of-findfirst-on-full-primary-key.md`). The choice is per call site, not a global preference. -See sample: `pair-findset-with-next-loop.good.al`. +See sample: [`pair-findset-with-next-loop.good.al`](pair-findset-with-next-loop.good.al). ## Anti Pattern `if Customer.FindFirst() then repeat ... until Customer.Next() = 0;` — AA0233 flags this. The single-row API does not prepare the runtime for iteration, so the loop pays a cost the FindSet path does not. The mirror anti-pattern is calling `FindSet` to read a single record (see `use-isempty-for-existence-check.md` when only existence is required). -See sample: `pair-findset-with-next-loop.bad.al`. +See sample: [`pair-findset-with-next-loop.bad.al`](pair-findset-with-next-loop.bad.al). diff --git a/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md index 45214b8..4fc83c4 100644 --- a/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md +++ b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md @@ -17,7 +17,7 @@ application-area: [all] Reach for the `(false)` form when the calling code already enforces the invariants the trigger would, or when the trigger is empty for the current table/extension. Use `(true)` when the trigger does work the caller depends on (number-series allocation, validation, cascading writes). Decide per call, not by code style: a default of "always `true`" makes bulk writes pay for triggers they did not need, and a default of "always `false`" silently skips validation the trigger was put there to enforce. -See sample: `pass-false-to-insert-when-trigger-not-needed.good.al`. +See sample: [`pass-false-to-insert-when-trigger-not-needed.good.al`](pass-false-to-insert-when-trigger-not-needed.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md b/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md index ce4c4bc..726ec41 100644 --- a/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md +++ b/microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md @@ -19,10 +19,10 @@ A `FindSet`/`Next` loop builds an enumerator from the fields selected for load. Helpers that read extra fields on an in-flight iterator must take the record as `var`, or the caller must `AddLoadFields` those fields before the loop. Prefer declaring the extra fields up front so no JIT is needed. -See sample: `pass-var-record-to-preserve-partial-load-enumerator.good.al`. +See sample: [`pass-var-record-to-preserve-partial-load-enumerator.good.al`](pass-var-record-to-preserve-partial-load-enumerator.good.al). ## Anti Pattern A `SetLoadFields` loop that passes the iterator by value into a helper which then reads a field that was not loaded. The first row pays one JIT; every subsequent row pays it again because the enumerator never learned the extra field. -See sample: `pass-var-record-to-preserve-partial-load-enumerator.bad.al`. +See sample: [`pass-var-record-to-preserve-partial-load-enumerator.bad.al`](pass-var-record-to-preserve-partial-load-enumerator.bad.al). diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md index 024aae1..ad7a6cf 100644 --- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md @@ -17,10 +17,10 @@ application-area: [all] Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics. -See sample: `prefer-modifyall-over-per-row-modify.good.al`. +See sample: [`prefer-modifyall-over-per-row-modify.good.al`](prefer-modifyall-over-per-row-modify.good.al). ## Anti Pattern A loop that only assigns a constant and calls `Modify(false)` on a field with no validation side effects or bulk fallback condition. A progress dialog alone does not exempt this loop. Conversely, replacing `Validate(Field, Value); Modify(true)` with `ModifyAll(Field, Value)` is also an anti-pattern because it silently drops field validation and may drop table-trigger behavior. -See sample: `prefer-modifyall-over-per-row-modify.bad.al`. +See sample: [`prefer-modifyall-over-per-row-modify.bad.al`](prefer-modifyall-over-per-row-modify.bad.al). diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md index f798636..a82ec8b 100644 --- a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md @@ -17,10 +17,10 @@ Without read scale-out, `LockTable` causes subsequent reads of that table in the For a read-only operation that specifically requires committed data, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted` immediately before the read. If the default isolation is sufficient, set neither property. `ReadCommitted` can still block behind writers and does not guarantee that repeated reads stay unchanged; use the isolation level required by the operation. Reserve update locks for read-before-write logic, not read-only helpers. -See sample: `prefer-readisolation-over-locktable-for-reads.good.al`. +See sample: [`prefer-readisolation-over-locktable-for-reads.good.al`](prefer-readisolation-over-locktable-for-reads.good.al). ## Anti Pattern `Rec.LockTable();` at the top of a helper that only reads, perhaps to "make sure the read is consistent". It takes stronger isolation than the helper needs and changes later reads of that table in the surrounding transaction or read-scale-out session. -See sample: `prefer-readisolation-over-locktable-for-reads.bad.al`. +See sample: [`prefer-readisolation-over-locktable-for-reads.bad.al`](prefer-readisolation-over-locktable-for-reads.bad.al). diff --git a/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md b/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md index dd03115..9097b5a 100644 --- a/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md +++ b/microsoft/knowledge/performance/prefer-related-table-over-extension-on-hot-ledgers.md @@ -20,10 +20,10 @@ Since v23, all extensions on the same base table share at most one companion-tab Put optional, sparse, or integration attributes in a related table with the ledger entry number as primary key. Show them from a FactBox or a FlowField. Use a tableextension stored field only when the value must appear as a native list column and is read on almost every access. -See sample: `prefer-related-table-over-extension-on-hot-ledgers.good.al`. +See sample: [`prefer-related-table-over-extension-on-hot-ledgers.good.al`](prefer-related-table-over-extension-on-hot-ledgers.good.al). ## Anti Pattern `tableextension` on `"G/L Entry"` (or another posting table) that adds several stored `Text`/`Blob` fields used only by one integration. The companion join is paid on every posting and on any AL code path that loads extension fields, even when those columns are not needed for the current operation. -See sample: `prefer-related-table-over-extension-on-hot-ledgers.bad.al`. +See sample: [`prefer-related-table-over-extension-on-hot-ledgers.bad.al`](prefer-related-table-over-extension-on-hot-ledgers.bad.al). diff --git a/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md b/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md index 1f3205f..5bcfbd8 100644 --- a/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md +++ b/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md @@ -19,10 +19,10 @@ The Business Central server caches primary-key `Get` calls within a transaction. Keep `Record.Get` for repeated lookups of the same primary keys in one transaction. Use a Query when the work is a true join or aggregation that the record API would express as nested scans. Do not flag a guarded `Get` on a repeating key as an N+1 solely because a Query could express the same columns. -See sample: `query-results-bypass-primary-key-cache.good.al`. +See sample: [`query-results-bypass-primary-key-cache.good.al`](query-results-bypass-primary-key-cache.good.al). ## Anti Pattern Rewriting a helper that `Get`s Customer by `No.` on every sales line into a Query opened inside that helper. Distinct line customers still need a lookup; repeating customers were already served from the PK cache. The Query pays SQL every time. -See sample: `query-results-bypass-primary-key-cache.bad.al`. +See sample: [`query-results-bypass-primary-key-cache.bad.al`](query-results-bypass-primary-key-cache.bad.al). diff --git a/microsoft/knowledge/performance/reset-clears-partial-record-selection.md b/microsoft/knowledge/performance/reset-clears-partial-record-selection.md index c99f8d2..d53aecb 100644 --- a/microsoft/knowledge/performance/reset-clears-partial-record-selection.md +++ b/microsoft/knowledge/performance/reset-clears-partial-record-selection.md @@ -19,10 +19,10 @@ application-area: [all] Call `Reset` (or empty `SetLoadFields()`) first when the variable must be reused, then call `SetLoadFields` with the fields the next read actually uses, then apply filters and read. After `Reset`, a new `SetLoadFields` is required; the previous list is gone. -See sample: `reset-clears-partial-record-selection.good.al`. +See sample: [`reset-clears-partial-record-selection.good.al`](reset-clears-partial-record-selection.good.al). ## Anti Pattern `SetLoadFields(...)` followed by `Reset()` (or by parameterless `SetLoadFields()`) and then `FindSet` without restoring the load list. The filters look correct; the SQL still selects every column. -See sample: `reset-clears-partial-record-selection.bad.al`. +See sample: [`reset-clears-partial-record-selection.bad.al`](reset-clears-partial-record-selection.bad.al). diff --git a/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md index 40428ca..ad0bb40 100644 --- a/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md +++ b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md @@ -26,10 +26,10 @@ Decide `SetCurrentKey` on one question only: **do I need the result set in a spe To make a filtered read fast, ensure a key (index) exists on the table whose leading fields cover the filter, and filter on those fields with `SetRange`/`SetFilter`. That is what lets the optimizer seek. Defining the key creates the index; `SetCurrentKey` is not required to make the optimizer use it. -See sample: `setcurrentkey-sets-sort-order-not-index-hint.good.al`. +See sample: [`setcurrentkey-sets-sort-order-not-index-hint.good.al`](setcurrentkey-sets-sort-order-not-index-hint.good.al). ## Anti Pattern Adding `SetCurrentKey` to a filtered read purely in the belief that it forces SQL Server to seek a particular index, when the code never uses the resulting order. This does nothing for index selection and only appends an `ORDER BY` the query does not need, risking an unnecessary sort. Remove the `SetCurrentKey`; rely on the filters and an existing covering key instead. -See sample: `setcurrentkey-sets-sort-order-not-index-hint.bad.al`. +See sample: [`setcurrentkey-sets-sort-order-not-index-hint.bad.al`](setcurrentkey-sets-sort-order-not-index-hint.bad.al). diff --git a/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md b/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md index 8077f28..41a0ad8 100644 --- a/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md +++ b/microsoft/knowledge/performance/skip-setloadfields-on-write-and-transferfields.md @@ -19,10 +19,10 @@ application-area: [all] Omit `SetLoadFields` on loops whose body performs a documented full-load operation (`Insert`, `Delete`, `Rename`, `TransferFields`, or assignment into a temporary record) on the same record variable, so the initial read already materializes every field those operations need. -See sample: `skip-setloadfields-on-write-and-transferfields.good.al`. +See sample: [`skip-setloadfields-on-write-and-transferfields.good.al`](skip-setloadfields-on-write-and-transferfields.good.al). ## Anti Pattern Calling `SetLoadFields` immediately before a `FindSet` whose body performs `Delete`, `Rename`, `TransferFields`, or copies the record into a temporary table. The review signal is a partial-record setup on a record variable that feeds one of these documented full-load operations in the same iteration. -See sample: `skip-setloadfields-on-write-and-transferfields.bad.al`. +See sample: [`skip-setloadfields-on-write-and-transferfields.bad.al`](skip-setloadfields-on-write-and-transferfields.bad.al). diff --git a/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md b/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md index 191568e..6a9793f 100644 --- a/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md +++ b/microsoft/knowledge/performance/use-dedicated-lookup-pages-not-full-lists.md @@ -19,10 +19,10 @@ A `TableRelation` lookup opens the table's `LookupPageId`. If that is the full l Give master tables a slim lookup page (`PageType = List`, few columns, no FactBoxes, no heavy `OnAfterGetRecord`) and assign it to `LookupPageId`. Keep the full list for `DrillDownPageId` and the role-explorer entry. -See sample: `use-dedicated-lookup-pages-not-full-lists.good.al`. +See sample: [`use-dedicated-lookup-pages-not-full-lists.good.al`](use-dedicated-lookup-pages-not-full-lists.good.al). ## Anti Pattern `LookupPageId = Page::"... List"` on a table that already has (or should have) a lookup page. Opening a field lookup then pays list-page cost. The signal is `LookupPageId` pointing at a page that declares FactBoxes or a wide repeater. -See sample: `use-dedicated-lookup-pages-not-full-lists.bad.al`. +See sample: [`use-dedicated-lookup-pages-not-full-lists.bad.al`](use-dedicated-lookup-pages-not-full-lists.bad.al). diff --git a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md index 41ad41f..8c847b4 100644 --- a/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md +++ b/microsoft/knowledge/performance/use-deleteall-for-filtered-bulk-deletion.md @@ -19,10 +19,10 @@ application-area: [all] Use filtered `DeleteAll(false)` for purpose-built staging or cleanup tables only after verifying that base-table `OnDelete` logic is unnecessary and that trigger code, related subscribers, security filtering, media fields, and companion fields do not add required per-row behavior or regress the bulk path. If deletion requires per-row business logic, keep an explicit triggered operation instead of simulating trigger execution separately. -See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`. +See sample: [`use-deleteall-for-filtered-bulk-deletion.good.al`](use-deleteall-for-filtered-bulk-deletion.good.al). ## Anti Pattern Iterating with `FindSet` + `Delete(false)` to clear a filtered staging batch that has no delete logic or fallback condition. The reverse mistake is assuming `DeleteAll` is always one SQL statement without checking the documented fallback conditions. -See sample: `use-deleteall-for-filtered-bulk-deletion.bad.al`. +See sample: [`use-deleteall-for-filtered-bulk-deletion.bad.al`](use-deleteall-for-filtered-bulk-deletion.bad.al). diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md index 06c0383..e74614b 100644 --- a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md @@ -17,10 +17,10 @@ application-area: [all] When all primary-key fields are available at the call site, call `Get` (or `GetBySystemId`) with them. Reserve `FindFirst` for cases where the filter is on something other than the full primary key — a unique secondary field, a partial composite key, a sort that the caller cares about. -See sample: `use-get-instead-of-findfirst-on-full-primary-key.good.al`. +See sample: [`use-get-instead-of-findfirst-on-full-primary-key.good.al`](use-get-instead-of-findfirst-on-full-primary-key.good.al). ## Anti Pattern Composing `SetRange` calls that exactly cover the primary key and then calling `FindFirst`. The result is correct but the call site reads as "search the table" rather than "look up by key", which obscures both the intent and the access pattern from later reviewers. -See sample: `use-get-instead-of-findfirst-on-full-primary-key.bad.al`. +See sample: [`use-get-instead-of-findfirst-on-full-primary-key.bad.al`](use-get-instead-of-findfirst-on-full-primary-key.bad.al). diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.md b/microsoft/knowledge/performance/use-isempty-for-existence-check.md index ab574ec..cfa2dc1 100644 --- a/microsoft/knowledge/performance/use-isempty-for-existence-check.md +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.md @@ -17,10 +17,10 @@ When the caller only needs to know whether any row matches a filter, `IsEmpty()` Phrase existence checks as `if not Record.IsEmpty() then ...` (or `if Record.IsEmpty() then ...` for the negative). Apply filters via `SetRange`/`SetFilter` before the call so the existence check runs against the intended subset. Reserve `Count` for cases where the actual number matters and `FindFirst` for cases where the record fields are read. -See sample: `use-isempty-for-existence-check.good.al`. +See sample: [`use-isempty-for-existence-check.good.al`](use-isempty-for-existence-check.good.al). ## Anti Pattern `if Customer.Count() > 0 then ...` and `if Customer.FindFirst() then ...` (when the record is discarded) — both are flagged by the upstream guidance as the wrong tool. The first asks the database for the full count; the second asks for a row's fields. Both answers go unused. -See sample: `use-isempty-for-existence-check.bad.al`. +See sample: [`use-isempty-for-existence-check.bad.al`](use-isempty-for-existence-check.bad.al). diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md index 0c749ce..b5f9d6a 100644 --- a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md +++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md @@ -17,10 +17,10 @@ application-area: [all] Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. Use `CalcSums` instead when the required result is one aggregate over the filtered set (see `calcsums-instead-of-calcfields-in-loop.md`). -See sample: `use-setautocalcfields-for-per-row-flowfields.good.al`. +See sample: [`use-setautocalcfields-for-per-row-flowfields.good.al`](use-setautocalcfields-for-per-row-flowfields.good.al). ## Anti Pattern Calling `CalcFields` inside the loop when every iteration reads the same FlowField. Each `CalcFields` request requires a separate SQL statement unless a compatible recent result is cached. Do not replace row-specific decisions with `CalcSums`; an aggregate cannot preserve which rows met the condition. -See sample: `use-setautocalcfields-for-per-row-flowfields.bad.al`. +See sample: [`use-setautocalcfields-for-per-row-flowfields.bad.al`](use-setautocalcfields-for-per-row-flowfields.bad.al). diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index a8d134f..06b2d14 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -17,7 +17,7 @@ application-area: [all] Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. The pattern `SetLoadFields(...); if Record.Get(...) then ...` is the upstream-endorsed shape. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons (`singleton-setup-tables-need-no-access-optimization.md`, `temporary-tables-have-no-database-cost.md`). For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see `addloadfields-in-report-onpredataitem.md`). -See sample: `use-setloadfields-for-partial-records.good.al`. +See sample: [`use-setloadfields-for-partial-records.good.al`](use-setloadfields-for-partial-records.good.al). ## Anti Pattern @@ -25,4 +25,4 @@ Loading a wide table and reading one field per row in a loop. The bytes transfer Statement order is not part of this anti pattern. `SetLoadFields` placed ahead of `SetRange`/`SetFilter` materializes exactly the same columns as the reverse order, so a reviewer reports it as a readability observation at most — never as a performance defect. -See sample: `use-setloadfields-for-partial-records.bad.al`. +See sample: [`use-setloadfields-for-partial-records.bad.al`](use-setloadfields-for-partial-records.bad.al). diff --git a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md index 6070b76..41f751f 100644 --- a/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md +++ b/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md @@ -19,13 +19,13 @@ Reach for `[TryFunction]` when you want to catch a failure without unwinding the Use `[TryFunction]` sparingly. Each caught error writes to the session-wide `GetLastErrorText` and `GetLastErrorCallStack` buffers, and every subsequent catch overwrites the earlier state — a helper that reads `GetLastErrorText` later may see a different error than the one it intended to inspect. Prefer explicit checks (non-throwing predicates, guard conditions, upfront validation) for operations with predictable failure modes; reserve `[TryFunction]` for genuinely unpredictable failures such as network calls, third-party interop, or evaluation of user-supplied expressions. When you do catch, read `GetLastErrorText` immediately after the failed call, and call `ClearLastError` before the call if an earlier catch in the same scope could have left state behind — per the platform reference, "If you call the GetLastErrorText method immediately after you call the ClearLastError method, then an empty string is returned." -See sample: `use-tryfunction-for-error-catching-not-rollback.good.al`. +See sample: [`use-tryfunction-for-error-catching-not-rollback.good.al`](use-tryfunction-for-error-catching-not-rollback.good.al). ## Anti Pattern Wrapping database writes in `[TryFunction]` and expecting successful writes before the error to roll back. They remain, the caller receives `false`, and partially applied state can escape. Defensive sprinkling is also unsafe: every catch overwrites the session error buffer and can hide the failure a later helper intended to inspect. -See sample: `use-tryfunction-for-error-catching-not-rollback.bad.al`. +See sample: [`use-tryfunction-for-error-catching-not-rollback.bad.al`](use-tryfunction-for-error-catching-not-rollback.bad.al). ## See also diff --git a/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md b/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md index 00b9657..2b87c2a 100644 --- a/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md +++ b/microsoft/knowledge/performance/validate-on-partial-record-forces-jit.md @@ -19,10 +19,10 @@ application-area: [all] In a partial-record loop, assign fields directly when trigger side effects are not required. If `Validate` is required, do not use `SetLoadFields` on that iterator, or `AddLoadFields` every field the validate path can touch before the read. -See sample: `validate-on-partial-record-forces-jit.good.al`. +See sample: [`validate-on-partial-record-forces-jit.good.al`](validate-on-partial-record-forces-jit.good.al). ## Anti Pattern `SetLoadFields` on a handful of columns, then `Validate` inside the loop. The load list looks optimal; runtime JIT and TableRelation I/O dominate. The signal is `Validate(` on a record that still has a `SetLoadFields` in the same procedure. -See sample: `validate-on-partial-record-forces-jit.bad.al`. +See sample: [`validate-on-partial-record-forces-jit.bad.al`](validate-on-partial-record-forces-jit.bad.al). diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md index f9245b1..f4a25f3 100644 --- a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md @@ -17,10 +17,10 @@ Error method trace telemetry includes the AL error string only when the first `E Declare the complete message as a `Label` or `TextConst` and pass it directly to `Error`, followed by substitution values. The client receives the formatted message while telemetry retains the static message template without using the dynamic values as its message. Independently review whether each substitution value is appropriate to show to the current user. -See sample: `avoid-strsubstno-prebuild-before-error.good.al`. +See sample: [`avoid-strsubstno-prebuild-before-error.good.al`](avoid-strsubstno-prebuild-before-error.good.al). ## Anti Pattern `Error(StrSubstNo(CustomerInvalidErr, Customer."No."))` and `Error(HeaderErr + DetailErr)` both make the first argument dynamic. They reduce error telemetry quality; they do not cause that composed string to be logged verbatim as the telemetry message. -See sample: `avoid-strsubstno-prebuild-before-error.bad.al`. +See sample: [`avoid-strsubstno-prebuild-before-error.bad.al`](avoid-strsubstno-prebuild-before-error.bad.al). diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md index b431c12..6bad6f6 100644 --- a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -17,10 +17,10 @@ application-area: [all] Set `DataClassification` to the value that matches the data the field actually stores. A `Customer."E-Mail"`-style field is `CustomerContent` (data belonging to the tenant's customers); a personal identifier such as an employee number or user ID is `EndUserIdentifiableInformation` or `EndUserPseudonymousIdentifiers` depending on whether it is directly identifying. A field that identifies an organization rather than a person — a company registration or VAT registration number — is `OrganizationIdentifiableInformation`, and a financial account identifier such as a bank account number or IBAN is `AccountData`. Choose the classification at field definition time — fixing it later is a schema change. -See sample: `data-classification-required-on-pii-fields.good.al`. +See sample: [`data-classification-required-on-pii-fields.good.al`](data-classification-required-on-pii-fields.good.al). ## Anti Pattern Declaring a field that stores PII with `DataClassification = SystemMetadata` to silence the compiler warning. The field compiles but the platform now treats customer data as system metadata in telemetry, GDPR exports and admin reports. -See sample: `data-classification-required-on-pii-fields.bad.al`. +See sample: [`data-classification-required-on-pii-fields.bad.al`](data-classification-required-on-pii-fields.bad.al). diff --git a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md index ce1b684..d83131f 100644 --- a/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md +++ b/microsoft/knowledge/privacy/errorinfo-telemetry-classification-and-errortype.md @@ -17,10 +17,10 @@ Runtime 3.0 (BC 14) provides `ErrorInfo.Message`, `DataClassification`, and `Err Keep `Message` stable and classify its actual content. Choose `ErrorType` for client usability, not as a telemetry privacy boundary. On BC 19 and later, put only support-safe technical context in `DetailedMessage`, because a user can copy it from the dialog. The samples use only members available at the BC 14 article floor. -See sample: `errorinfo-telemetry-classification-and-errortype.good.al`. +See sample: [`errorinfo-telemetry-classification-and-errortype.good.al`](errorinfo-telemetry-classification-and-errortype.good.al). ## Anti Pattern Marking a dynamic customer-bearing `Message` as `SystemMetadata`, or assuming `ErrorType::Internal` keeps it out of telemetry. On BC 19 and later, the same anti-pattern includes placing secrets or personal data in `DetailedMessage` because it is not the primary dialog text. -See sample: `errorinfo-telemetry-classification-and-errortype.bad.al`. +See sample: [`errorinfo-telemetry-classification-and-errortype.bad.al`](errorinfo-telemetry-classification-and-errortype.bad.al). diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md index 6d8bfb5..f34804b 100644 --- a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md @@ -17,10 +17,10 @@ application-area: [all] Pass only non-personal context through `CustomDimensions` — feature names, status enums, counts, error codes, durations. For uptake or usage signals that do not need per-call context, prefer the parameterless overload of `LogUptake`/`LogUsage` over a `CustomDimensions` dictionary that risks accreting PII over time. -See sample: `featuretelemetry-customdimensions-no-pii.good.al`. +See sample: [`featuretelemetry-customdimensions-no-pii.good.al`](featuretelemetry-customdimensions-no-pii.good.al). ## Anti Pattern `CustomDimensions.Add('EmployeeNo', ExpenseHeader."Employee No.")` followed by `FeatureTelemetry.LogUsage(...)` — the employee number is a pseudonymous user identifier (EUPI) and is now in telemetry. Same pattern with `'UserName'`, `'CustomerEmail'`, `'AttachmentName'` etc. -See sample: `featuretelemetry-customdimensions-no-pii.bad.al`. +See sample: [`featuretelemetry-customdimensions-no-pii.bad.al`](featuretelemetry-customdimensions-no-pii.bad.al). diff --git a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md index 863f3a8..64a9b12 100644 --- a/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md +++ b/microsoft/knowledge/privacy/featuretelemetry-logerror-implicit-errortext.md @@ -17,10 +17,10 @@ application-area: [all] Review the dedicated error arguments as telemetry payload. Capture `GetLastErrorText(true)` when scrubbed platform error text is sufficient, and pass `GetLastErrorCallStack()` only as a call stack. Keep custom dimensions non-personal too. -See sample: `featuretelemetry-logerror-implicit-errortext.good.al`. +See sample: [`featuretelemetry-logerror-implicit-errortext.good.al`](featuretelemetry-logerror-implicit-errortext.good.al). ## Anti Pattern Approving a `LogError` call because its explicit dictionary contains only safe values while it passes unsanitized `GetLastErrorText()` or arbitrary context through `ErrorText` or `ErrorCallStack`. Those arguments become telemetry dimensions outside the dictionary. -See sample: `featuretelemetry-logerror-implicit-errortext.bad.al`. +See sample: [`featuretelemetry-logerror-implicit-errortext.bad.al`](featuretelemetry-logerror-implicit-errortext.bad.al). diff --git a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md index d3a1b7b..659b775 100644 --- a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md +++ b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md @@ -17,7 +17,7 @@ application-area: [all] Do not declare `DataClassification` on `FieldClass = FlowField` or `FieldClass = FlowFilter` fields — the inherited `SystemMetadata` is correct and the property is redundant. If a FlowField exposes sensitive data, ensure the underlying source field has the right `DataClassification`; that is where the platform reads classification from for GDPR and telemetry purposes. -See sample: `flowfield-flowfilter-classification-systemmetadata.good.al`. +See sample: [`flowfield-flowfilter-classification-systemmetadata.good.al`](flowfield-flowfilter-classification-systemmetadata.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md index 8ff26a8..fd541a1 100644 --- a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md @@ -17,10 +17,10 @@ Parameterless `GetLastErrorText()` can contain customer content such as field va Use a generic label when the user does not need the underlying detail. If showing unsanitized detail is appropriate, put `%1` in a label and pass parameterless `GetLastErrorText()` as a separate argument. This preserves a useful static telemetry message while keeping the dynamic value out of the telemetry message field. -See sample: `getlasterrortext-customer-content-in-errors.good.al`. +See sample: [`getlasterrortext-customer-content-in-errors.good.al`](getlasterrortext-customer-content-in-errors.good.al). ## Anti Pattern `Error(StrSubstNo(AttachmentFailedErr, GetLastErrorText()))` or `Error(AttachmentPrefixErr + GetLastErrorText())`. Both lose the static first argument and trigger AA0231; neither causes the composed text to be logged verbatim as the Error telemetry message. -See sample: `getlasterrortext-customer-content-in-errors.bad.al`. +See sample: [`getlasterrortext-customer-content-in-errors.bad.al`](getlasterrortext-customer-content-in-errors.bad.al). diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md index e27d1e4..8192c84 100644 --- a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md @@ -19,7 +19,7 @@ Keep the telemetry message a static, non-personal string ("Customer record proce If a pseudonymous identifier (record `No.`, primary key value) genuinely belongs in the diagnostic, prefer attaching it through a custom dimension and set the call's `DataClassification` to match the data actually shipped — `EndUserPseudonymousIdentifiers` for pseudonymous IDs, `CustomerContent` for content-bearing telemetry. Changing the `DataClassification` alone does **not** make embedding a customer name into the message string acceptable; the data still ships in the message, and downstream consumers still see the literal string. -See sample: `no-pii-in-telemetry-message-string.good.al`. +See sample: [`no-pii-in-telemetry-message-string.good.al`](no-pii-in-telemetry-message-string.good.al). ## Related @@ -30,4 +30,4 @@ See sample: `no-pii-in-telemetry-message-string.good.al`. `Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), ...)` — the customer name is in telemetry the moment the line runs. Detection signal: a `StrSubstNo` whose result is the second argument of `Session.LogMessage`. The same shape with `FileName`, `EmployeeCode`, or any record field is the same problem. -See sample: `no-pii-in-telemetry-message-string.bad.al`. +See sample: [`no-pii-in-telemetry-message-string.bad.al`](no-pii-in-telemetry-message-string.bad.al). diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md index d95acef..8c3898d 100644 --- a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md @@ -17,10 +17,10 @@ Business Central's `Codeunit "Privacy Notice"` creates notices and records per-i Register the custom notice with `CreatePrivacyNotice` during setup or through `OnRegisterPrivacyNotices`. Before sending data, call `ConfirmPrivacyNoticeApproval()` outside a write transaction, or check `GetPrivacyNoticeApprovalState()` when the flow must not show UI. No path should issue the request without approval. -See sample: `privacy-notice-consent-for-external-data-transfer.good.al`. +See sample: [`privacy-notice-consent-for-external-data-transfer.good.al`](privacy-notice-consent-for-external-data-transfer.good.al). ## Anti Pattern A custom integration that posts data without checking its own notice, or that gates the call with a built-in ID such as the Exchange privacy notice ID. Consent for one service does not authorize another. -See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`. +See sample: [`privacy-notice-consent-for-external-data-transfer.bad.al`](privacy-notice-consent-for-external-data-transfer.bad.al). diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md index 4e76779..a9f12ff 100644 --- a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md @@ -17,7 +17,7 @@ The current extension point is `Codeunit "Privacy Notice"`. Extensions can subsc Choose a stable ID owned by the extension. Register it through `OnRegisterPrivacyNotices`, or call `PrivacyNotice.CreatePrivacyNotice` during an intentional setup or upgrade path. Use that same ID for consent checks described in `privacy-notice-consent-for-external-data-transfer.md`. -See sample: `register-integration-in-privacy-notice-registrations.good.al`. +See sample: [`register-integration-in-privacy-notice-registrations.good.al`](register-integration-in-privacy-notice-registrations.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md index 67381f7..2febcae 100644 --- a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md @@ -17,10 +17,10 @@ application-area: [all] Use the overload that takes `Verbosity`, `DataClassification`, and `TelemetryScope`. For payload-free operational telemetry that does not embed customer data, `DataClassification::SystemMetadata` is the right value. Choose `TelemetryScope::ExtensionPublisher` for telemetry meant for the publishing partner only; `TelemetryScope::All` also forwards to the customer's tenant telemetry. -See sample: `session-logmessage-requires-dataclassification.good.al`. +See sample: [`session-logmessage-requires-dataclassification.good.al`](session-logmessage-requires-dataclassification.good.al). ## Anti Pattern Calling `Session.LogMessage('0003', 'Operation completed', Verbosity::Normal)` — the overload omits `DataClassification` and leaves the platform without the information needed to classify the entry. Detection signal: a `Session.LogMessage` call whose argument list ends at `Verbosity`. -See sample: `session-logmessage-requires-dataclassification.bad.al`. +See sample: [`session-logmessage-requires-dataclassification.bad.al`](session-logmessage-requires-dataclassification.bad.al). diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md index 253d2cc..f41dc11 100644 --- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -17,7 +17,7 @@ A valid table-level `DataClassification` is the effective default for the Normal Use a table-level classification when it accurately describes the table's fields, and add a field-level classification only where a field stores a different kind of data. Do not flag a Normal field solely because it omits an explicit property when its own table supplies a valid default; verify whether the inherited value matches the field's data instead. A `tableextension` has no default to inherit, so require an explicit `DataClassification` on every Normal field it adds. -See sample: `table-level-data-classification-cascades.good.al`. +See sample: [`table-level-data-classification-cascades.good.al`](table-level-data-classification-cascades.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md index 4bc8816..0d7ca47 100644 --- a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md +++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md @@ -17,10 +17,10 @@ Calling `Open()` on an already open query first closes the current dataset and o Open once for one read pass. Close after the pass, and call `Clear(QueryVariable)` before reusing the variable for a logically independent query whose filters must start empty. Set the next pass's filters explicitly before reopening. -See sample: `reopening-query-resets-cursor-but-keeps-filters.good.al`. +See sample: [`reopening-query-resets-cursor-but-keeps-filters.good.al`](reopening-query-resets-cursor-but-keeps-filters.good.al). ## Anti Pattern Calling `Open()` inside or between reads to "advance" or "start fresh", or reusing the same query variable for a new operation while assuming `Open()` cleared old filters. The code compiles but can repeatedly process the first row or silently omit rows behind a retained filter. -See sample: `reopening-query-resets-cursor-but-keeps-filters.bad.al`. +See sample: [`reopening-query-resets-cursor-but-keeps-filters.bad.al`](reopening-query-resets-cursor-but-keeps-filters.bad.al). diff --git a/microsoft/knowledge/query/set-query-filters-before-open.md b/microsoft/knowledge/query/set-query-filters-before-open.md index f999456..bb82e9d 100644 --- a/microsoft/knowledge/query/set-query-filters-before-open.md +++ b/microsoft/knowledge/query/set-query-filters-before-open.md @@ -17,10 +17,10 @@ application-area: [all] Apply every filter before `Open()`, then read the dataset to completion and call `Close()`. When a later branch needs different filters, close or clear the query, set the new filters, and open a new dataset deliberately. -See sample: `set-query-filters-before-open.good.al`. +See sample: [`set-query-filters-before-open.good.al`](set-query-filters-before-open.good.al). ## Anti Pattern `Query.Open()` followed by `SetFilter` or `SetRange` and then `Read()` under the assumption that the filter updates the open cursor. Refiltering after `Open()` is valid only when the code intentionally opens a fresh dataset afterward. -See sample: `set-query-filters-before-open.bad.al`. +See sample: [`set-query-filters-before-open.bad.al`](set-query-filters-before-open.bad.al). diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md index 7441712..649c384 100644 --- a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md @@ -15,8 +15,8 @@ AL does not ship a built-in `HtmlEncode` (or equivalent) function. Code that bui ## Best Practice -Replace the four characters by hand before concatenating user content into HTML: `&` → `&` first, then `<` → `<`, `>` → `>`, `"` → `"`. Centralize the substitution in one helper so every HTML producer in the extension uses the same encoder. Better still, do not build raw HTML at all — use a structured format (JSON for an API payload, a report layout for a printed document) and let the renderer do the encoding. See sample: `al-has-no-built-in-htmlencode.good.al`. +Replace the four characters by hand before concatenating user content into HTML: `&` → `&` first, then `<` → `<`, `>` → `>`, `"` → `"`. Centralize the substitution in one helper so every HTML producer in the extension uses the same encoder. Better still, do not build raw HTML at all — use a structured format (JSON for an API payload, a report layout for a printed document) and let the renderer do the encoding. See sample: [`al-has-no-built-in-htmlencode.good.al`](al-has-no-built-in-htmlencode.good.al). ## Anti Pattern -`HtmlContent := '
Welcome ' + UserName + '!
'` — any record-field value or user input concatenated directly into an HTML string. Reviewers should flag any string concatenation whose right-hand operand is a field, a parameter, or any non-literal value, and whose surrounding context contains HTML tags (`<`, `Welcome ' + UserName + '!'` — any record-field value or user input concatenated directly into an HTML string. Reviewers should flag any string concatenation whose right-hand operand is a field, a parameter, or any non-literal value, and whose surrounding context contains HTML tags (`<`, `` column headers, so a captionless field that is mean Reserve `ShowCaption = false` in a layout-table grid for non-editable, free-standing content cells. If a field's role is to label or annotate another field in the same grid, restructure the grid to meet the data-table conditions (see `grid-data-table-heuristic.md`) instead of hiding the caption. -See sample: `standalone-content-in-layout-table.good.al`. +See sample: [`standalone-content-in-layout-table.good.al`](standalone-content-in-layout-table.good.al). diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md index 5040099..720423b 100644 --- a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md +++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md @@ -22,4 +22,4 @@ When `StyleExpr` is Text, you must trace the variable's assignments — typicall Inspect the declared type of the symbol referenced by `StyleExpr` before drawing conclusions. If it is Boolean, evaluate the `Style` property. If it is Text, follow every assignment to the variable and check the full set of possible style values against `cosmetic-styles-need-no-textual-context.md` and `semantic-styles-need-independent-textual-meaning.md`. -See sample: `style-expr-text-vs-boolean.good.al`. +See sample: [`style-expr-text-vs-boolean.good.al`](style-expr-text-vs-boolean.good.al). diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md index b56aadb..c02bbd9 100644 --- a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md +++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md @@ -24,4 +24,4 @@ Both manifestations have the same root cause: tabular semantics were intended bu A single field that keeps its visible caption is enough to demote an entire would-be data-table grid into a layout table — and silently strip the labels off its sibling captionless fields. Either restructure to meet all three conditions, or restore captions on every editable field. -See sample: `tabular-intent-requires-data-table-conditions.bad.al`. +See sample: [`tabular-intent-requires-data-table-conditions.bad.al`](tabular-intent-requires-data-table-conditions.bad.al). diff --git a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md index 75c8a09..d796827 100644 --- a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md +++ b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md @@ -17,13 +17,13 @@ application-area: [all] ## Best Practice -Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action — validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits — see `showmandatory-on-code-required-page-fields.md`. See sample: `validate-request-page-input-in-onqueryclosepage.good.al`. +Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action — validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits — see `showmandatory-on-code-required-page-fields.md`. See sample: [`validate-request-page-input-in-onqueryclosepage.good.al`](validate-request-page-input-in-onqueryclosepage.good.al). ## Anti Pattern Validating mandatory request-page input only in `OnPreReport`. The check is correct and the report is never run with bad input, but every interactive mistake costs the user the whole request page: the error arrives after the page is gone, and filters, dates, and options all have to be entered again. Reviewer signal: a `TestField`, `Error`, or blank/zero-value check in `OnPreReport` or `OnPreDataItem` against a variable that is bound to a request-page control, in a report whose request page declares no `OnQueryClosePage`. -The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant — the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: `validate-request-page-input-in-onqueryclosepage.bad.al`. +The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant — the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: [`validate-request-page-input-in-onqueryclosepage.bad.al`](validate-request-page-input-in-onqueryclosepage.bad.al). ## See also diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md index 9ba7e8a..5efc751 100644 --- a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md @@ -17,10 +17,10 @@ Primary-key changes and field-type changes (for example widening `Integer` to `B Treat primary-key and field-type changes as restricted to tables introduced in the same change. For changes on tables with existing data, design and ship the corresponding upgrade procedure (typically backed by `DataTransfer` and an upgrade tag) that guarantees the new layout is achievable for every row, and verify with concrete evidence that the existing values fit the new constraint (no PK collisions, no value-range overflow). -See sample: `breaking-changes-only-on-tables-without-data.good.al`. +See sample: [`breaking-changes-only-on-tables-without-data.good.al`](breaking-changes-only-on-tables-without-data.good.al). ## Anti Pattern Changing the primary key on a base-app table, or widening / narrowing a field type on a table that has been shipping for releases, with no accompanying upgrade plan. The change compiles cleanly and may even deploy on an empty-ish tenant, then fails on customers who actually have data. -See sample: `breaking-changes-only-on-tables-without-data.bad.al`. +See sample: [`breaking-changes-only-on-tables-without-data.bad.al`](breaking-changes-only-on-tables-without-data.bad.al). diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md index 30f6ca7..8770f93 100644 --- a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md +++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md @@ -17,10 +17,10 @@ application-area: [all] Have check triggers call query-only helpers that raise an error when an invariant fails. Put every `Insert`, `Modify`, `Delete`, `Rename`, `DataTransfer`, and other migration write behind helpers called from the matching `OnUpgrade...` trigger. -See sample: `check-only-triggers-do-not-migrate-data.good.al`. +See sample: [`check-only-triggers-do-not-migrate-data.good.al`](check-only-triggers-do-not-migrate-data.good.al). ## Anti Pattern Repairing data in `OnCheckPreconditions...` or finishing migration in `OnValidateUpgrade...`. Those writes blur the phase contract and make a check alter the state it is supposed to assess. -See sample: `check-only-triggers-do-not-migrate-data.bad.al`. +See sample: [`check-only-triggers-do-not-migrate-data.bad.al`](check-only-triggers-do-not-migrate-data.bad.al). diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md index 988dfa4..830dbdb 100644 --- a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md @@ -17,13 +17,13 @@ Tables that can contain more than 300,000 records, and any newly added field on For a bulk update use a `DataTransfer` variable: call `SetTables(Database::"...", Database::"...")` (source and destination may be the same table), add filters with `AddSourceFilter`, set the target value with `AddConstantValue` (or copy a source field with `AddFieldValue`), and execute with `CopyFields()`. To express multiple distinct updates against the same table, `Clear` the `DataTransfer` between executions and configure the next one. -See sample: `datatransfer-for-bulk-init.good.al`. +See sample: [`datatransfer-for-bulk-init.good.al`](datatransfer-for-bulk-init.good.al). ## Anti Pattern Iterating with `FindSet(true) ... repeat ... Modify() ... until Next() = 0` to set a single field across an entire large table. On 300k+ rows this is the canonical slow-upgrade footgun. -See sample: `datatransfer-for-bulk-init.bad.al`. +See sample: [`datatransfer-for-bulk-init.bad.al`](datatransfer-for-bulk-init.bad.al). ## See also diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md index 34a406b..33173da 100644 --- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md @@ -19,10 +19,10 @@ For *new fields and tables added in the same change* this is fine: nothing yet d Use `DataTransfer` when set-based transfer is safe and row-level business logic is intentionally unnecessary — initial population of a new field is the canonical case. When an existing field's validation must run, loop through records and call `Validate(Field, Value)`; if the table's modify trigger must also run, follow with `Modify(true)`. If performance requires `DataTransfer`, document exactly which field-validation and row-modification triggers or subscribers are intentionally bypassed and verify that derived data remains correct. -See sample: `datatransfer-skips-triggers-and-subscribers.good.al`. +See sample: [`datatransfer-skips-triggers-and-subscribers.good.al`](datatransfer-skips-triggers-and-subscribers.good.al). ## Anti Pattern Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` or `OnModify` logic, without confirming that both validation and row-modification subscribers can be skipped. Replacing it with only `Modify(true)` is also incomplete when field validation is required; call `Validate` for that field first. -See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`. +See sample: [`datatransfer-skips-triggers-and-subscribers.bad.al`](datatransfer-skips-triggers-and-subscribers.bad.al). diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md index cf585eb..868b61e 100644 --- a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md @@ -17,10 +17,10 @@ When upgrade code encounters unexpected data — a record it expected to find, a When an upgrade procedure detects something missing, call `Session.LogMessage` with a stable event ID, classify the message verbosity (typically `Warning`), and `exit` the procedure so the rest of the upgrade can proceed. The platform telemetry then surfaces the situation to the partner without breaking the customer. -See sample: `do-not-block-upgrade-on-data-errors.good.al`. +See sample: [`do-not-block-upgrade-on-data-errors.good.al`](do-not-block-upgrade-on-data-errors.good.al). ## Anti Pattern Calling `Record.Get(Key)` (or any other erroring API) and letting the error propagate out of the upgrade trigger. The first tenant with imperfect data fails to upgrade, and the failure surfaces as a hard upgrade error rather than as a telemetry signal. -See sample: `do-not-block-upgrade-on-data-errors.bad.al`. +See sample: [`do-not-block-upgrade-on-data-errors.bad.al`](do-not-block-upgrade-on-data-errors.bad.al). diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md index 4de929b..332efa6 100644 --- a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md @@ -17,13 +17,13 @@ An AL `enum` is a fixed list of ordinal-named values. Persisted rows reference e When adding an enum value, place it after the last existing `value(N; ...)` entry, with an ordinal strictly greater than every existing one. Never renumber existing entries. To retire a value, do not delete it: mark it `ObsoleteState = Pending` (and later `Removed`) with `ObsoleteReason` and `ObsoleteTag` so the ordinal remains taken. -See sample: `enum-values-additive-at-end.good.al`. +See sample: [`enum-values-additive-at-end.good.al`](enum-values-additive-at-end.good.al). ## Anti Pattern Inserting a value between existing entries ("just put `NewMiddleValue` between `First` and `Second`"), or removing a value from the enum without first going through `ObsoleteState = Pending` → `Removed`. Every row whose persisted ordinal matched the removed or shifted value now reads as a different member. -See sample: `enum-values-additive-at-end.bad.al`. +See sample: [`enum-values-additive-at-end.bad.al`](enum-values-additive-at-end.bad.al). ## See also diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md index 6324836..5cbdec8 100644 --- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md @@ -17,13 +17,13 @@ On the first install of an extension on a tenant the platform records a zero dat In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run first-install seed logic only when they match. On a non-zero data version, follow the reinstall path or exit. -See sample: `first-install-dataversion-zero-check.good.al`. +See sample: [`first-install-dataversion-zero-check.good.al`](first-install-dataversion-zero-check.good.al). ## Anti Pattern Treating `OnInstallAppPerCompany` as if it always implies "fresh tenant". The trigger also fires when reinstalling over an existing data set; without the `0.0.0.0` guard, first-install seed code can run again and duplicate rows. -See sample: `first-install-dataversion-zero-check.bad.al`. +See sample: [`first-install-dataversion-zero-check.bad.al`](first-install-dataversion-zero-check.bad.al). ## See also diff --git a/microsoft/knowledge/upgrade/guard-database-reads.md b/microsoft/knowledge/upgrade/guard-database-reads.md index c5bc206..09a99e3 100644 --- a/microsoft/knowledge/upgrade/guard-database-reads.md +++ b/microsoft/knowledge/upgrade/guard-database-reads.md @@ -17,10 +17,10 @@ Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgrad Wrap every read in an `if`. `if Item.Get(No) then ...`, `if Customer.FindSet() then;`, `if not Vendor.FindLast() then exit;`. The empty-then form `if Customer.FindSet() then;` is the idiomatic way to attempt a read whose only purpose is to position a record, while swallowing the "not found" case. -See sample: `guard-database-reads.good.al`. +See sample: [`guard-database-reads.good.al`](guard-database-reads.good.al). ## Anti Pattern Calling `Item.Get()`, `Customer.FindSet()`, or `Vendor.FindLast()` bare in upgrade code. The first tenant whose data does not match the upgrade's assumptions will fail to upgrade. -See sample: `guard-database-reads.bad.al`. +See sample: [`guard-database-reads.bad.al`](guard-database-reads.bad.al). diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md index 4733ef2..bfa0f03 100644 --- a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md @@ -23,10 +23,10 @@ Several legitimate cases do NOT need upgrade code: When a new field on an existing table has an `InitValue` that matters, ship an upgrade procedure that walks the existing rows and sets the field to the same value — typically via `DataTransfer.AddConstantValue` for performance — guarded by an upgrade tag. -See sample: `initvalue-does-not-update-existing-rows.good.al`. +See sample: [`initvalue-does-not-update-existing-rows.good.al`](initvalue-does-not-update-existing-rows.good.al). ## Anti Pattern Adding a field with `InitValue = true;` (or any non-default `InitValue`) and shipping no upgrade code. Existing rows silently carry the datatype default, leaving the table in two states: rows created before the upgrade with the wrong value, and rows created after with the right one. -See sample: `initvalue-does-not-update-existing-rows.bad.al`. +See sample: [`initvalue-does-not-update-existing-rows.bad.al`](initvalue-does-not-update-existing-rows.bad.al). diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md index 12f432f..52c3989 100644 --- a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md +++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md @@ -17,10 +17,10 @@ An install codeunit runs when an extension is installed for the first time or an Use `Subtype = Install` for first-install and reinstall initialization. Put version migration in a separate `Subtype = Upgrade` codeunit and enter it from `OnUpgradePerCompany` or `OnUpgradePerDatabase`. -See sample: `install-code-does-not-run-on-version-upgrade.good.al`. +See sample: [`install-code-does-not-run-on-version-upgrade.good.al`](install-code-does-not-run-on-version-upgrade.good.al). ## Anti Pattern Putting a schema or data migration only in an install trigger and expecting it to run when a higher app version is upgraded. The migration is never invoked on that path. -See sample: `install-code-does-not-run-on-version-upgrade.bad.al`. +See sample: [`install-code-does-not-run-on-version-upgrade.bad.al`](install-code-does-not-run-on-version-upgrade.bad.al). diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md index b9e5e13..3095655 100644 --- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md @@ -17,10 +17,10 @@ Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. A full Filter directly to invalid rows and use `IsEmpty` or another bounded existence check where possible. If a broad validation is unavoidable, document the invariant that requires it and keep all data changes in `OnUpgrade...`. -See sample: `minimize-onvalidate-upgrade-triggers.good.al`. +See sample: [`minimize-onvalidate-upgrade-triggers.good.al`](minimize-onvalidate-upgrade-triggers.good.al). ## Anti Pattern Reading every record in `OnValidateUpgradePerCompany` when a filtered existence check can prove the same invariant. The scan repeats on every upgrade. -See sample: `minimize-onvalidate-upgrade-triggers.bad.al`. +See sample: [`minimize-onvalidate-upgrade-triggers.bad.al`](minimize-onvalidate-upgrade-triggers.bad.al). diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md index eb644b6..f04fb40 100644 --- a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md @@ -19,10 +19,10 @@ The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedu Defer external calls to runtime code. If a piece of upgrade work conceptually needs data from an external service, set a flag or write a queue row during upgrade and have the runtime code make the call later (for example on first user sign-in or via job queue), where retries and degraded modes are tractable. -See sample: `no-external-calls-in-upgrade.good.al`. +See sample: [`no-external-calls-in-upgrade.good.al`](no-external-calls-in-upgrade.good.al). ## Anti Pattern Calling `HttpClient.Get`, `HttpClient.Post`, or DotNet interop methods from `OnUpgradePerCompany`, `OnUpgradePerDatabase`, or any procedure they invoke. -See sample: `no-external-calls-in-upgrade.bad.al`. +See sample: [`no-external-calls-in-upgrade.bad.al`](no-external-calls-in-upgrade.bad.al). diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md index cb008ac..64a54f2 100644 --- a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md @@ -17,10 +17,10 @@ application-area: [all] Stage the deprecation across releases. Step 1: mark `Pending` with reason and tag; consumers are warned but data and code keep working. Step 2: in a later release, transition to `Removed` and (if persisted data references the element) ship an upgrade procedure that migrates that data — gated by an upgrade tag. The standard mechanic for retiring the actual implementation body is to remove the `#if not CLEAN` block in the same release that flips the state to `Removed`. -See sample: `obsolete-pending-to-removed-staging.good.al`. +See sample: [`obsolete-pending-to-removed-staging.good.al`](obsolete-pending-to-removed-staging.good.al). ## Anti Pattern Jumping straight to `ObsoleteState = Removed` without a prior `Pending` release. Consumers have no deprecation window to migrate and any data still referencing the element is stranded. Equally wrong: leaving an element `Pending` indefinitely and never staging its removal — the deprecation never completes. -See sample: `obsolete-pending-to-removed-staging.bad.al`. +See sample: [`obsolete-pending-to-removed-staging.bad.al`](obsolete-pending-to-removed-staging.bad.al). diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md index d6ec37a..b468437 100644 --- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md @@ -22,13 +22,13 @@ In both forms, the reason should name the replacement and the tag should identif For an object or field, set all three properties together. For a method, variable, or event, provide both `[Obsolete]` arguments. Keep the original tag stable through the lifecycle rather than changing it to a planned removal version. -See sample: `obsoletion-requires-reason-and-tag.good.al`. +See sample: [`obsoletion-requires-reason-and-tag.good.al`](obsoletion-requires-reason-and-tag.good.al). ## Anti Pattern Setting only `ObsoleteState = Pending`/`Removed` on an object or field, or using `[Obsolete('', '')]` on a method, variable, or event. Both forms produce deprecation metadata without useful replacement guidance or traceability. -See sample: `obsoletion-requires-reason-and-tag.bad.al`. +See sample: [`obsoletion-requires-reason-and-tag.bad.al`](obsoletion-requires-reason-and-tag.bad.al). ## See also diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md index e5e1983..0bba7c2 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -19,10 +19,10 @@ Registration is not install-time seeding. When an extension is installed into an In the upgrade codeunit, guard work with `HasUpgradeTag` and call `SetUpgradeTag` only after successful completion. Seed the same tag explicitly from `OnInstallAppPerCompany` when first-install logic should not run as a later upgrade. Also add historical per-company tags to `OnGetPerCompanyUpgradeTags` so `SetAllUpgradeTags` marks them complete for newly created companies. Keep the tag definition shared so all paths use the exact same value. -See sample: `register-upgrade-tags-with-subscribers.good.al`. +See sample: [`register-upgrade-tags-with-subscribers.good.al`](register-upgrade-tags-with-subscribers.good.al). ## Anti Pattern Assuming an `OnGetPerCompanyUpgradeTags` subscriber sets tags during extension installation, or omitting the subscriber and allowing old upgrade steps to run when `SetAllUpgradeTags` initializes a new company. The subscriber supplies a list; only `SetAllUpgradeTags` or an explicit `SetUpgradeTag` call persists it. -See sample: `register-upgrade-tags-with-subscribers.bad.al`. +See sample: [`register-upgrade-tags-with-subscribers.bad.al`](register-upgrade-tags-with-subscribers.bad.al). diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md index 0b441e6..c4558b7 100644 --- a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md @@ -19,10 +19,10 @@ This is the opposite of a load-bearing concern: code that MUST run during the up In a runtime procedure that performs non-essential side effects, guard the side-effect block with `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` and include a brief comment explaining what is being skipped and why. -See sample: `skip-nonessential-work-via-execution-context.good.al`. +See sample: [`skip-nonessential-work-via-execution-context.good.al`](skip-nonessential-work-via-execution-context.good.al). ## Anti Pattern Using `GetExecutionContext()` to *enable* upgrade behaviour from outside an upgrade codeunit. Upgrade behaviour belongs in a codeunit with `Subtype = Upgrade`; runtime code should only use the check to *suppress* optional work. -See sample: `skip-nonessential-work-via-execution-context.bad.al`. +See sample: [`skip-nonessential-work-via-execution-context.bad.al`](skip-nonessential-work-via-execution-context.bad.al). diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md index dcc21e3..078e109 100644 --- a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md @@ -19,10 +19,10 @@ Empty `OnUpgradePerCompany` / `OnUpgradePerDatabase` triggers are acceptable — Each upgrade trigger contains an ordered list of procedure calls, one per feature: `UpgradeFeatureA();` `UpgradeFeatureB();`. Each procedure handles its own upgrade tag, its own data work, and can be added or removed independently. -See sample: `triggers-call-helpers-not-implementations.good.al`. +See sample: [`triggers-call-helpers-not-implementations.good.al`](triggers-call-helpers-not-implementations.good.al). ## Anti Pattern Implementing record loops, `ModifyAll`, or other data work directly in the trigger body. The trigger then mixes orchestration with implementation, and adding a second feature requires editing the trigger rather than appending one line. -See sample: `triggers-call-helpers-not-implementations.bad.al`. +See sample: [`triggers-call-helpers-not-implementations.bad.al`](triggers-call-helpers-not-implementations.bad.al). diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md index a9fee7c..8bf558d 100644 --- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md @@ -17,10 +17,10 @@ A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upg Place every piece of upgrade logic in a codeunit declared with `Subtype = Upgrade;` and expose entry points via the two triggers `OnUpgradePerCompany` and `OnUpgradePerDatabase`. Helper procedures may live in normal codeunits, but they inherit the upgrade-context rules (guarded reads, no external calls, upgrade tags, etc.) when called from an upgrade trigger. -See sample: `upgrade-codeunit-subtype.good.al`. +See sample: [`upgrade-codeunit-subtype.good.al`](upgrade-codeunit-subtype.good.al). ## Anti Pattern Putting upgrade-style logic in a regular codeunit that the platform never invokes during upgrade — for example a normal codeunit with a manually invented "RunUpgrade" procedure that nothing wires to the upgrade pipeline. The migration code will simply not run. -See sample: `upgrade-codeunit-subtype.bad.al`. +See sample: [`upgrade-codeunit-subtype.bad.al`](upgrade-codeunit-subtype.bad.al). diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md index 62347d1..bb7649f 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md @@ -17,13 +17,13 @@ Each piece of upgrade logic must run exactly once per company (or database) acro Every upgrade procedure starts with a `HasUpgradeTag` guard and ends with `SetUpgradeTag` once the work is committed. Each feature gets its own tag string so features can be re-run independently if needed. -See sample: `use-upgrade-tags-not-version-checks.good.al`. +See sample: [`use-upgrade-tags-not-version-checks.good.al`](use-upgrade-tags-not-version-checks.good.al). ## Anti Pattern Branching on `MyApp.DataVersion().Major > N`, or chains of `< N` / `< M` to decide which upgrade step to run. Such code becomes unmaintainable after a few releases and silently does the wrong thing on tenants that skip versions. -See sample: `use-upgrade-tags-not-version-checks.bad.al`. +See sample: [`use-upgrade-tags-not-version-checks.bad.al`](use-upgrade-tags-not-version-checks.bad.al). ## See also diff --git a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md index 7988326..66fe36e 100644 --- a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md +++ b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md @@ -21,7 +21,7 @@ LLMs treat one carrier as universal. Some assume the caption is serialised and r Establish which schema versions the field is served under before changing anything about its enum. Under schema 2.0 (Microsoft's API v2.0, an explicit `$schemaversion=2.0` in the consumer contract, or another reliable context signal) the member name is the contract: keep names stable, put wording changes in `Caption`, add a value by appending a new name with an ordinal above every existing one, and retire a value through `ObsoleteState` rather than by deleting it. For a custom API that clients may still call as schema 1.0, any install of BC 17 to 23 or a caller that pins 1.0, the caption is a contract as well: change neither name nor caption in place, or publish the change as a new `APIVersion` on a new page object. A rename is out in every case: AppSourceCop AS0082 rejects it against a baseline, and dependent extensions bind to the name. -See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.good.al`. +See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.good.al`](api-enum-values-are-a-contract-by-name-not-ordinal.good.al). ## Anti Pattern @@ -31,7 +31,7 @@ Detection signal: a diff hunk that changes the name in a `value(...)` line while The mirror image is a review defect: suppressing a caption-change finding because "the API serialises names". That holds only under schema 2.0. Do not flag a `Caption` change when the reviewer can establish schema 2.0 for every consumer; on a custom API where clients may select schema 1.0, report a caption change on an exposed value as a consumer-visible change and ask for versioning. A value appended at the end changes no contract under either schema and is never a finding. -See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`. +See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`](api-enum-values-are-a-contract-by-name-not-ordinal.bad.al). ## See also diff --git a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md index 2358a6b..8f6a73c 100644 --- a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md +++ b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md @@ -17,10 +17,10 @@ An API meant purely for reading — a reporting or lookup endpoint — is not re For a read-only / reporting API page set all three CRUD guards off — `InsertAllowed = false`, `ModifyAllowed = false`, `DeleteAllowed = false` — and mark the page `Editable = false`. The endpoint then serves GET requests and rejects any insert, modify, or delete, matching the read-only contract regardless of the caller. Make the read-only stance explicit rather than depending on the writable default. -See sample: `disable-write-operations-on-read-only-api-pages.good.al`. +See sample: [`disable-write-operations-on-read-only-api-pages.good.al`](disable-write-operations-on-read-only-api-pages.good.al). ## Anti Pattern An API intended for read-only consumption that omits the CRUD guards, leaving `InsertAllowed`, `ModifyAllowed`, and `DeleteAllowed` at their writable defaults. The endpoint silently accepts POST, PATCH, and DELETE, so a client can mutate or remove data the API was never meant to expose for writing. The detection signal: a read-only/reporting `PageType = API` page that does not set the three `*Allowed = false` properties. -See sample: `disable-write-operations-on-read-only-api-pages.bad.al`. +See sample: [`disable-write-operations-on-read-only-api-pages.bad.al`](disable-write-operations-on-read-only-api-pages.bad.al). diff --git a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md index 739b5aa..4337b41 100644 --- a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md +++ b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md @@ -17,10 +17,10 @@ This is about the data-consistency contract of an API endpoint: what a consumer For an API page that must expose only committed data, set the endpoint's read isolation once as the page opens: in the `OnOpenPage` trigger write `Rec.ReadIsolation := IsolationLevel::ReadCommitted;`. Every read the endpoint then serves ignores uncommitted writes from concurrent transactions, so a consumer never receives a row that another transaction might still roll back. -See sample: `expose-only-committed-data-from-api-reads.good.al`. +See sample: [`expose-only-committed-data-from-api-reads.good.al`](expose-only-committed-data-from-api-reads.good.al). ## Anti Pattern An API intended to return committed-only data that sets no isolation level, leaving reads at the default that can observe in-flight, uncommitted writes. A consumer can fetch a row created by a concurrent transaction that is later rolled back — a dirty read that surfaces data which never durably existed. The detection signal: a committed-only read API with no `Rec.ReadIsolation := IsolationLevel::ReadCommitted` in `OnOpenPage`. -See sample: `expose-only-committed-data-from-api-reads.bad.al`. +See sample: [`expose-only-committed-data-from-api-reads.bad.al`](expose-only-committed-data-from-api-reads.bad.al). diff --git a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md index 7f0ed87..18908a7 100644 --- a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md +++ b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md @@ -17,10 +17,10 @@ An API consumer that needs to *do* something to a record — post it, ship it, r Declare the operation as `[ServiceEnabled] procedure Post(var ActionContext: WebServiceActionContext)` on the API page. Inside, perform the operation against `Rec`, then call a `SetActionResponse` helper that writes the result — the bound record and its id — back into the `WebServiceActionContext` so the caller receives a well-formed response. The operation is now an explicit, named endpoint action separate from ordinary field writes. -See sample: `expose-operations-as-bound-actions.good.al`. +See sample: [`expose-operations-as-bound-actions.good.al`](expose-operations-as-bound-actions.good.al). ## Anti Pattern Exposing a writable Boolean (for example `posted`) whose `OnValidate` performs the posting. A client that PATCHes the field to `true` — an action indistinguishable from any other data edit — silently triggers a side-effecting business operation. The detection signal: an API page field whose `OnValidate` posts, ships, or releases, instead of a `[ServiceEnabled]` bound action. -See sample: `expose-operations-as-bound-actions.bad.al`. +See sample: [`expose-operations-as-bound-actions.bad.al`](expose-operations-as-bound-actions.bad.al). diff --git a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md index 93d2dcd..f34e9a4 100644 --- a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md +++ b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md @@ -17,10 +17,10 @@ Every BC table carries a `SystemId` — an immutable GUID assigned at insert and Set `ODataKeyFields = SystemId` so OData routes records by the stable GUID, and expose it as `field(id; Rec.SystemId)` marked `Editable = false`. Clients then address a record at `.../customers()`, an identity that survives any rename of the business key. Keep the business key (for example `No.`) as an ordinary exposed field, not as the OData key. -See sample: `expose-systemid-as-the-api-key.good.al`. +See sample: [`expose-systemid-as-the-api-key.good.al`](expose-systemid-as-the-api-key.good.al). ## Anti Pattern Setting `ODataKeyFields = "No."` so the endpoint addresses records by a renamable business field. As soon as a user changes that `No.`, every external reference built on the old value points at nothing, silently breaking integrations. The detection signal: `ODataKeyFields` set to a business field rather than `SystemId`, or an API page that exposes no `id` field bound to `Rec.SystemId`. -See sample: `expose-systemid-as-the-api-key.bad.al`. +See sample: [`expose-systemid-as-the-api-key.bad.al`](expose-systemid-as-the-api-key.bad.al). diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md index 4cf81d6..2f92c0c 100644 --- a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md +++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md @@ -17,13 +17,13 @@ application-area: [all] Define the child foreign key as `Guid` with a `TableRelation` to the parent table's `SystemId`, then use `SubPageLink = "" = Field(SystemId)` on the parent API page. A child collection may omit `Multiplicity` and rely on the default 1:N relationship, or declare `Multiplicity = Many` explicitly. Set `Multiplicity = ZeroOrOne` when the intended navigation metadata is a singleton. -See sample: `link-api-parts-on-systemid-and-set-multiplicity.good.al`. +See sample: [`link-api-parts-on-systemid-and-set-multiplicity.good.al`](link-api-parts-on-systemid-and-set-multiplicity.good.al). ## Anti Pattern On a parent API with `ODataKeyFields = SystemId`, linking a child business field such as `"Order No."` to the parent's `"No."` creates a second identity scheme for navigation instead of using the contract's stable GUID. A separate defect is an explicit `Multiplicity` that conflicts with the intended shape, such as `ZeroOrOne` on an order-lines collection or `Many` on a singleton. Do not treat omission alone as a defect: it is valid for a collection because the default is 1:N, while an intended singleton must explicitly use `Multiplicity = ZeroOrOne`. -See sample: `link-api-parts-on-systemid-and-set-multiplicity.bad.al`. +See sample: [`link-api-parts-on-systemid-and-set-multiplicity.bad.al`](link-api-parts-on-systemid-and-set-multiplicity.bad.al). ## Source diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.md b/microsoft/knowledge/web-services/set-required-api-page-properties.md index 496db1a..24edc5d 100644 --- a/microsoft/knowledge/web-services/set-required-api-page-properties.md +++ b/microsoft/knowledge/web-services/set-required-api-page-properties.md @@ -17,10 +17,10 @@ An API page needs `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, and Declare the five routing/entity properties required by the API page and set `APIVersion` explicitly for a stable published contract, for example `'v1.0'`. Expose the record's fields inside a repeater under `area(content)`. Review missing routing metadata as a malformed API definition, but review a missing `APIVersion` as unintended publication under `beta`, not as an unpublished endpoint. -See sample: `set-required-api-page-properties.good.al`. +See sample: [`set-required-api-page-properties.good.al`](set-required-api-page-properties.good.al). ## Anti Pattern Leaving out `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, or `SourceTable` leaves the API definition incomplete. A subtler contract defect is declaring all of those but omitting `APIVersion`: the page is exposed as `beta`, which is valid runtime behavior but not the explicit stable route a production client expects. -See sample: `set-required-api-page-properties.bad.al`. +See sample: [`set-required-api-page-properties.bad.al`](set-required-api-page-properties.bad.al). diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md index bfd2c9f..1e8417b 100644 --- a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md +++ b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md @@ -17,10 +17,10 @@ Once an API version is published, external clients depend on its exact shape — Keep the existing page object and its `APIVersion = 'v1.0'` contract unchanged. Copy the page to a new object ID, set that object's `APIVersion = 'v2.0'`, and make the v2-only shape changes there. A multi-value `APIVersion` list is appropriate only when the exact same page shape is supported under each listed version. -See sample: `version-apis-by-adding-not-mutating-published-versions.good.al`. +See sample: [`version-apis-by-adding-not-mutating-published-versions.good.al`](version-apis-by-adding-not-mutating-published-versions.good.al). ## Anti Pattern Editing the published `v1.0` page in place breaks its clients. So does adding `v2.0` to that same page and assuming subsequent field changes apply only to v2: both routes use one object shape. The detection signal is a breaking shape change without a separate API page object retaining the old version. -See sample: `version-apis-by-adding-not-mutating-published-versions.bad.al`. +See sample: [`version-apis-by-adding-not-mutating-published-versions.bad.al`](version-apis-by-adding-not-mutating-published-versions.bad.al). diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md index b35a05c..2aad620 100644 --- a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md +++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md @@ -17,13 +17,13 @@ Business Central can subscribe only to eligible API pages, not every endpoint th Before creating a subscription, confirm the resource appears in `webhookSupportedResources` and that a custom endpoint is an API page with a single stable key over an eligible persistent table. Use one validation path that echoes `validationToken` for both create (`POST`) and renew (`PATCH`) handshakes. Track `expirationDateTime` and renew before expiry: online subscriptions expire after three days, while on-premises lifetime defaults to three days and can be changed with `ApiSubscriptionExpiration`. -See samples: `webhook-eligibility-and-validationtoken-renewal.good.al` and `webhook-eligibility-and-validationtoken-renewal.good.js`. +See samples: [`webhook-eligibility-and-validationtoken-renewal.good.al`](webhook-eligibility-and-validationtoken-renewal.good.al) and [`webhook-eligibility-and-validationtoken-renewal.good.js`](webhook-eligibility-and-validationtoken-renewal.good.js). ## Anti Pattern Attempting to subscribe to an API query, temporary/composite/system-table/Job Queue Entry API page, or assuming a successful create handshake makes renewal automatic. Composite includes an explicit multi-field `ODataKeyFields` and a missing `ODataKeyFields` when the source table's primary key has multiple fields. A renewal issues the same validation challenge; a notification handler that ignores the query-string token cannot create or renew the subscription. -See samples: `webhook-eligibility-and-validationtoken-renewal.bad.al` and `webhook-eligibility-and-validationtoken-renewal.bad.js`. +See samples: [`webhook-eligibility-and-validationtoken-renewal.bad.al`](webhook-eligibility-and-validationtoken-renewal.bad.al) and [`webhook-eligibility-and-validationtoken-renewal.bad.js`](webhook-eligibility-and-validationtoken-renewal.bad.js). ## Source diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 767c9af..1ddd810 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-review.md @@ -134,7 +134,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until breaking-changes knowledge files land — produces: +When no applicable breaking-changes knowledge is available, the report is: ```json { diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 41f0f78..9972aca 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -300,7 +300,9 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip } ``` -The empty-corpus case — BCQuality's state until knowledge files land — rolls up to `no-knowledge`: +When the selected leaves find no applicable knowledge, the result rolls up to +`no-knowledge`. This example shows two leaf results; a full run includes every +invoked leaf: ```json { diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index bc4598a..63c4d5e 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -132,7 +132,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until error-handling knowledge files land — produces: +When no applicable error-handling knowledge is available, the report is: ```json { diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index 7248a45..559d036 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -141,7 +141,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until events knowledge files land — produces: +When no applicable events knowledge is available, the report is: ```json { diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index d4738ac..f2fa80d 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -134,7 +134,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until performance knowledge files land — produces: +When no applicable performance knowledge is available, the report is: ```json { diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 5d998c9..e3e4049 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -129,7 +129,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s } ``` -The empty-corpus case — BCQuality's state until security knowledge files land — produces: +When no applicable security knowledge is available, the report is: ```json { diff --git a/skills/do.md b/skills/do.md index 5234437..4ac433b 100644 --- a/skills/do.md +++ b/skills/do.md @@ -19,7 +19,12 @@ An action skill is a single markdown file with YAML frontmatter. It lives inside - `/community/skills/` — community-contributed action skills. - `/custom/skills/` — partner or customer action skills (typically in a consumer repo, not in BCQuality itself). -Action skills do not live at the repo root. The files in `/skills/` — the three meta-skill contracts (READ, DO, WRITE) and the entry-point skill (`entry.md`, `kind: entry-point`) — are the only skills that sit outside a layer. The entry-point skill structurally follows this same four-step pattern but produces a dispatch record rather than a findings-report; see `skills/entry.md` for its contract. +Action skills do not live at the repo root. Layer-independent files in +`/skills/` contain the three meta-skill contracts (READ, DO, WRITE), the +entry-point skill (`entry.md`, `kind: entry-point`), and host-format adapters. +Adapters are not action skills. Entry structurally follows the same +four-step pattern but produces a dispatch record rather than a findings-report; +see [entry.md](entry.md) for its contract. ## Skills hold mechanics; knowledge files hold BC facts @@ -319,15 +324,16 @@ A super-skill's top-level `suppressed[]` remains knowledge-file-only and is typi ## Worked example -A minimal action skill that cites applicable guidance for a changed AL file, without generating findings of its own: +A minimal action skill that reviews a changed AL file against applicable +guidance. Relevance alone never produces a finding: ```yaml --- kind: action-skill -id: cite-applicable-guidance +id: review-applicable-guidance version: 1 -title: Cite applicable guidance -description: Lists knowledge files relevant to a changed AL file. +title: Review applicable guidance +description: Reviews a changed AL file against applicable knowledge. inputs: [file-path] outputs: [findings-report] technologies: [al] @@ -345,7 +351,12 @@ Filter by `technologies: [al]` and `bc-version` matching the target environment. Intersect `keywords` with tokens derived from the target file's object name and changed members. ## Action -For each worklist entry, emit one finding with severity `info`, a message naming the concern, and a reference object pointing to the knowledge file. +Read each worklisted article in full and compare its normative guidance to the +input. Emit a finding only for a concrete violation or an observation the +article explicitly defines, with justified severity, evidence, and a reference +copied from the discovered article path. Do not report an article merely +because it was relevant. If every item was evaluated and none warrants a +finding, return `completed` with an empty `findings` array. ## Output Conforms to the DO output contract. diff --git a/skills/read.md b/skills/read.md index 6a2080d..f2e9c1e 100644 --- a/skills/read.md +++ b/skills/read.md @@ -7,7 +7,9 @@ title: Schema + Use — how to read a knowledge file # READ -Every consumer of BCQuality — an agent, an action skill, a human reviewer — reads this file first. It defines what a knowledge file is, what fields it contains, what they mean, and how to reconcile multiple files. +Read this contract before interpreting knowledge files. Task execution starts +at [Entry](entry.md); READ is loaded on demand when a dispatched skill needs +it. It defines knowledge fields, their meaning, and how to reconcile files. This contract is stable. Changes require a PR approved by both maintainers. @@ -131,7 +133,7 @@ Rules: - A sample file is identified by the article's slug followed by a `..` suffix. The supported kinds are `good` and `bad`. Additional kinds MAY be introduced by a layer; consumers MUST ignore unknown kinds without failing. - The extension matches the technology (`al`, `ps1`, `js`, `kql`, …). A single article MAY carry samples in multiple technologies if the article's frontmatter `technologies` lists them. -- Articles MAY have a `good` sample only, a `bad` sample only, both, or neither. The article text SHOULD reference each sample it ships, using a relative path like `` `.good.al` ``. +- Articles MAY have a `good` sample only, a `bad` sample only, both, or neither. The article text SHOULD reference each sample it ships with a relative Markdown link whose label retains the backticked filename, like `` [`.good.al`](.good.al) ``. - Samples are **demonstration-only**. They are not deployed, not compiled as part of a published app, and not derived from the Business Central base application source. Each sample is self-contained and exists purely to make the accompanying article concrete for humans and agents. - Layer precedence applies to sample files the same way it applies to articles: a `/custom/knowledge//.good.al` overrides a `/microsoft/knowledge//.good.al` for the same article in the same layer hierarchy. diff --git a/skills/write.md b/skills/write.md index 8096f1a..c2edab5 100644 --- a/skills/write.md +++ b/skills/write.md @@ -16,7 +16,7 @@ Before authoring anything, confirm a knowledge file is the right artifact. BCQua - **Skills** (`*/skills/**`) hold only finder/applier mechanics — how to discover, filter, worklist, and emit findings. See `skills/do.md`. - **Knowledge files** (`*/knowledge/**`) hold every Business-Central-specific fact a skill acts on. -A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file — apply the admission test in the [README](../README.md#what-belongs-here): *would a capable LLM get this wrong without the file?* If you find yourself editing a skill to stop it flagging something, stop and write a knowledge file instead. +A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file — apply the [admission test](../docs/contributing.md#what-belongs-here): *would a capable LLM get this wrong without the file?* If you find yourself editing a skill to stop it flagging something, stop and write a knowledge file instead. ### Negative knowledge is first-class @@ -56,6 +56,13 @@ Target under 100 lines. Ideal under 50. Long files almost always mean two concer Custom `##` sections are permitted when they serve the concern (for example, `## Applies to` for scope caveats or `## See also` for related files). Consumers are not required to understand them, so do not put load-bearing content there. +When adding or changing a platform claim, cite an authoritative public source +where available. A short `## References` section can link the relevant API, +property documentation, or public source definition. If no such source is +available, identify the evidence or policy basis explicitly; do not imply an +official guarantee. Keep the actual rule and its exceptions in normative +sections, not only in references. See [sources and examples](../docs/contributing.md#sources-and-examples). + ## No fenced code blocks Knowledge files do not contain code. Samples live as **sibling files** next to the article — `.good.al`, `.bad.al`, etc. — in the same knowledge-layer folder. See `skills/read.md` for the full convention. This keeps knowledge files retrieval-friendly and prevents code from drifting out of sync with BC platform changes buried inside prose. @@ -93,7 +100,7 @@ The `/custom/` layer is **empty by default** in the upstream `microsoft/BCQualit Before authoring or scaffolding any file under `/custom/knowledge/` or `/custom/skills/`, an author — human or agent — MUST confirm the working repository is **not** `microsoft/BCQuality`: - Check the `origin` remote: `git remote get-url origin`. If it points at `github.com/microsoft/BCQuality`, stop — you are in the upstream repo, not a fork. -- If you are in the upstream repo, do not write the file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or — if the guidance is genuinely shareable — author it in `/community/knowledge/` instead. +- If you are in the upstream repo, do not write the custom file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or — if the guidance is genuinely shareable — use the shared layer that owns the domain, following *Choosing a layer* above. Community is not a staging area for Microsoft-owned domains. A pull request that adds `/custom/` content to `microsoft/BCQuality` will be **automatically closed** by the `Guard custom layer` workflow. Validate the fork precondition first so authoring effort is not wasted on a PR that cannot be merged. @@ -109,7 +116,8 @@ Before opening a pull request: - Frontmatter `domain` exactly matches the containing domain folder. - File is in the correct layer and domain folder. - Name is kebab-case and descriptive. -- Every companion sample is referenced by filename from the article, and every referenced sample exists. +- Every companion sample has a clickable relative link retaining its backticked filename, and every referenced sample exists. +- Platform claims link supporting sources where available; policy or empirical guidance is identified as such. - Every review-leaf domain has at least one article with both `.good.al` and `.bad.al` companions; the evaluation harness derives positive and clean controls from that convention automatically. Agents scaffolding new files SHOULD run this checklist programmatically before emitting the file. From ac9e4fd9a28952bb58d02a23f1aadc1482240618 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 11 Sep 2026 09:09:53 +0200 Subject: [PATCH 05/51] Complete partner contribution and knowledge consumption guides (#176) * Improve partner onboarding and documentation navigation Lead with a complete plugin quick start and add task-oriented usage, troubleshooting, customization, and contribution guides. Preserve the broader plugin framing, correct conflicting contract guidance, support Agents folder reviews, and align repository validation. Convert existing sample references to clickable links without changing knowledge rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Complete partner contribution and knowledge consumption guides Explain direct reading, supplied skills, and custom-agent consumption. Add a first-contribution walkthrough and concrete integration bootstrap, and clarify SetLoadFields guidance with authoritative sources and explicit review heuristics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +- docs/README.md | 4 +- docs/agent-consumption.md | 59 +++++++++++++++++++ docs/contributing.md | 42 +++++++++++++ docs/standalone-runner.md | 3 + docs/using-bcquality.md | 53 +++++++++++++++-- .../use-setloadfields-for-partial-records.md | 11 +++- 7 files changed, 168 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9eeee91..2118ce0 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,13 @@ the review can still discover knowledge by reading the folders. | I want to... | Start here | | --- | --- | +| Choose direct reading, a supplied skill, or my own agent | [Ways to use BCQuality](docs/using-bcquality.md#choose-how-to-use-bcquality) | | Review a file, changes, a branch, or a particular concern | [Using BCQuality](docs/using-bcquality.md) | | Resolve setup problems, incomplete reviews, or incorrect findings | [Troubleshooting and support](docs/troubleshooting.md) | | Browse the available guidance | [Knowledge by domain](docs/using-bcquality.md#knowledge-by-domain) | | Configure the plugin or use my organization's rules | [Customizing BCQuality](docs/customizing-bcquality.md) | -| Contribute knowledge or improve a rule | [Contributing](docs/contributing.md) | -| Connect a host, agent, or CI integration | [How agents consume BCQuality](docs/agent-consumption.md) | +| Contribute knowledge or improve a rule | [Your first contribution](docs/contributing.md#your-first-contribution) | +| Connect a host, agent, or CI integration | [Minimal integration example](docs/agent-consumption.md#try-a-minimal-integration) | [All documentation and technical references](docs/README.md). diff --git a/docs/README.md b/docs/README.md index 1d7248b..d9ca27b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,12 +8,13 @@ of BCQuality's internal protocol is needed. | Goal | Guide | | --- | --- | +| Choose direct reading, a supplied skill, or my own agent | [Ways to use BCQuality](using-bcquality.md#choose-how-to-use-bcquality) | | Review an app, file, changes, or branch | [Using BCQuality](using-bcquality.md) | | Understand a report and its limitations | [Reading your results](using-bcquality.md#reading-your-results) | | Find a particular rule or example | [Knowledge by domain](using-bcquality.md#knowledge-by-domain) | | Fix setup problems or report an incorrect finding | [Troubleshooting and support](troubleshooting.md) | | Select layers, add company rules, or maintain a fork | [Customizing BCQuality](customizing-bcquality.md) | -| Add or improve shared knowledge | [Contributing](contributing.md) | +| Add or improve shared knowledge | [Your first contribution](contributing.md#your-first-contribution) | ## Integration and technical reference @@ -22,6 +23,7 @@ prerequisites for using the plugin. | Reference | Purpose | | --- | --- | +| [Minimal integration example](agent-consumption.md#try-a-minimal-integration) | A bootstrap prompt connecting your agent to a BCQuality checkout and an app folder. | | [How agents consume BCQuality](agent-consumption.md) | Architecture, repository structure, routing, and delivery of findings. | | [Standalone runner](standalone-runner.md) | Optional model selection, scheduling, retries, and telemetry. | | [Global skills](../skills/README.md) | Host adapters versus internal protocol files. | diff --git a/docs/agent-consumption.md b/docs/agent-consumption.md index 1bf4284..06858c0 100644 --- a/docs/agent-consumption.md +++ b/docs/agent-consumption.md @@ -10,6 +10,65 @@ mental model. This is the operational reference for integration authors. Partners using the installed plugin do not need to implement this flow themselves. +## Try a minimal integration + +**"Invoke `skills/entry.md`" means ask your agent to read and follow that +instruction document.** It is not a shell command, HTTP endpoint, or executable +library. Your host must be able to read files, enumerate directories, and +execute the selected skills as instructed. Merely mentioning BCQuality does +not make its content available to the model. + +For a first integration, create or reuse a dedicated BCQuality checkout. +For example, in PowerShell: + +```powershell +git clone https://github.com/microsoft/BCQuality.git "C:\Knowledge\BCQuality" +``` + +Give the host access to **both** that content directory and your own app +directory. The plugin is not required for this route. Replace the paths and +BC version below with your actual values, then send this prompt to the agent: + +```text +BCQuality root: C:\Knowledge\BCQuality +Review input: folder-path = C:\Repos\MyBusinessCentralApp + +Read BCQuality's skills\entry.md and follow it with this task context: +task-context: + goal: Review the complete AL app without changing its source files. + inputs-available: [folder-path] + technologies: [al] + bc-version: 28 + enabled-layers: [microsoft, community, custom] + disabled-skills: [] + +Resolve BCQuality instructions, knowledge, and index preparation against the +BCQuality root, not the app directory. Pass the actual review-input path above +when a dispatched skill accepts folder-path. +Follow Entry's preparation and dispatch instructions. Execute every dispatched +action skill with its exact input subset, reading READ and DO on demand. +Return each complete findings report unchanged. If Entry returns no-match or +failed, return that dispatch record unchanged instead of inventing a review. +``` + +`inputs-available` lists input **types**; the `Review input` line binds the type +to the actual app directory. It is not an extra Entry schema field. Omit +`bc-version` when unknown rather than guessing it; add localization or +application-area context only when known. Keep the two roots distinct so index +preparation operates on BCQuality, not your app. + +Expect Entry to select the action skills and the agent to execute them. +A broad review normally returns the Microsoft coordinator's report with +domain `sub-results`, plus any separately dispatched reports. Each report +must retain its outcome, including incomplete or failed work; see +[reading results](using-bcquality.md#reading-your-results). A dispatch record +alone is not a completed review. + +This prompt delegates the existing protocol rather than implementing new +routing logic. For repeatable runs, [pin the checkout](customizing-bcquality.md#updates-and-versions). +Add scheduling, retries, and rendering only when needed, using the +[runner contract](standalone-runner.md). + ## The actors - **Orchestrator** — the tool that triggers work. Lives *outside* BCQuality. Knows *when* to run something, not *what* to run. diff --git a/docs/contributing.md b/docs/contributing.md index b4cbb0b..0e493c2 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -6,6 +6,34 @@ Partners are welcome to contribute shared knowledge, examples, skills, and documentation. To report an incorrect finding without preparing a change, use the [support guide](troubleshooting.md#reporting-a-problem). +## Your first contribution + +You can propose shared guidance without write access to the upstream +repository. Use this path for a correction or a new article: + +1. Search the [existing knowledge](using-bcquality.md#knowledge-by-domain) and + [open issues](https://github.com/microsoft/BCQuality/issues). Correct or + extend an existing article when it already owns the concern; add a new + article only for a distinct concern that meets the admission test below. +2. [Fork BCQuality](https://github.com/microsoft/BCQuality/fork) into your GitHub + account or organization, clone your fork, and create a working branch from + the current upstream `main`. Make edits in that branch, not the plugin cache. +3. Choose the [owning layer and domain](#choose-the-right-destination), then + edit the article or use the [shared-article starter](#shared-article-starter). + A contribution intended for everyone does not belong in `custom/`. +4. Add supporting sources and relevant good/bad samples. For a false positive, + explain the valid pattern and the mistaken finding the rule should prevent. +5. Run the [documented checks](#before-opening-a-pr), then commit and push + your branch to your fork. +6. On GitHub, open a pull request with **base repository + `microsoft/BCQuality`, base branch `main`**, and your fork's working branch + as the head. Explain why the change is needed and respond to review by + pushing further commits to the same branch. + +Merged content is not automatically loaded into an existing agent session. +Consumers must pick up the updated content through their installation or +checkout; see [updates and versions](customizing-bcquality.md#updates-and-versions). + ## What belongs here BCQuality is a remedial knowledge base. A knowledge file exists because a @@ -69,6 +97,20 @@ to catch in `Anti Pattern`; those are the normative sections. Explain legitimate exceptions so a reviewer does not turn a useful rule into a false positive. Code fences are not allowed in knowledge articles. +### Shared-article starter + +Use [caption-required-on-page-fields.md](../microsoft/knowledge/style/caption-required-on-page-fields.md) +as a complete shared-knowledge example. It demonstrates all six metadata +fields, a clear concern, normative guidance and exceptions, linked good/bad +samples, and authoritative sources. + +For a new concern, follow that structure but choose your own descriptive +filename, domain, applicability, keywords, and guidance. Replace its sources +and sample links with ones supporting your concern; do not duplicate the +caption rule. If you are correcting caption guidance itself, edit the +existing article instead. Use a company-only rule only in your fork's Custom +layer, following the separate [customization example](customizing-bcquality.md#add-an-organization-specific-rule). + ### Sources and examples When adding or changing a platform claim, link the authoritative source that diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 31ab1dd..61ecb55 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -12,6 +12,9 @@ Use the built-in standalone plugin when the host's default execution is sufficient. Build a runner when you need explicit control over cost, latency, concurrency, or integration with another review surface. +Start with the [minimal integration example](agent-consumption.md#try-a-minimal-integration) +to connect your agent to the content before adding runner-specific behavior. + ## Keep BCQuality current For plugin installation, use the [quick start](../README.md#quick-start). diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md index bb1d955..44e5975 100644 --- a/docs/using-bcquality.md +++ b/docs/using-bcquality.md @@ -2,10 +2,55 @@ [Documentation](README.md) | [Quick start](../README.md#quick-start) | [Troubleshooting](troubleshooting.md) -BCQuality supplies knowledge and reusable skills to your AI host. The plugin -currently exposes `al-code-review`; the examples below use that skill. The -host supplies authentication, model access, tools, permissions, and rendering. -Installing BCQuality does not install a Business Central extension or an agent. +BCQuality is knowledge you can read and reuse, plus skills that tell an agent +how to apply it. You do not need an AI tool to read the articles. When using +an agent, your host supplies authentication, model access, tools, permissions, +and rendering; BCQuality does not install a BC extension or an agent. + +## Choose how to use BCQuality + +| Path | What to do | +| --- | --- | +| Read the knowledge yourself | Browse [knowledge by domain](#knowledge-by-domain), or search the repository for an AL concept. Read the article and its samples. No installation required. | +| Use a supplied skill | Follow the [plugin quick start](../README.md#quick-start). The currently exposed skill, `al-code-review`, performs reviews and returns findings. | +| Use your own agent or workflow | Supply selected articles as context, as described below, or use the [integration bootstrap](agent-consumption.md#try-a-minimal-integration) to execute BCQuality action skills without the plugin. | + +### Read and reuse an article + +Start with a concern, such as `SetLoadFields`, and search within +`microsoft/BCQuality` on GitHub or open its domain folder. For example, +[partial-record guidance](../microsoft/knowledge/performance/use-setloadfields-for-partial-records.md) +explains the concern and links good/bad samples. + +Before applying an article, read its frontmatter, the small metadata block at +the top: + +| Field | How to read it | +| --- | --- | +| `bc-version` | `[24..]` means BC 24 and later; `[26..28]` means BC 26 through 28; `[all]` means every version. Use your target BC major version, not your extension's version. | +| `technologies` | `[al]` means the guidance applies to AL; multiple values identify the technologies the article covers. | +| `countries` | `[w1]` means worldwide; a code such as `[dk]` limits the guidance to that localization. | +| `application-area` | `[all]` means any application area; a named area narrows applicability. | +| `domain` and `keywords` | Help you find the topic; they are not instructions or additional requirements. | + +Read `Description` for context, then `Best Practice` and `Anti Pattern` for the +rule and its exceptions. Follow any sample and source links. Do not turn a +sample into a production implementation without considering your own context. +The [READ reference](../skills/read.md) defines the precise matching rules. + +To use an article with your own agent, give it access to the full article and +relevant samples, not just a title or index row. For example, replace the +bracketed values in this prompt: + +> Read [article URL or local path] and its linked samples. Apply the relevant +> guidance while implementing [task] for BC [major version]. Explain which +> guidance you used, cite the article, and identify any missing context. + +A URL only works if the host can retrieve it; otherwise provide the files +directly. This is ordinary reuse of knowledge for explanation or code writing, +**not a packaged code-generation skill or a complete BCQuality review**. +For the structured review process, invoke a supplied skill or follow the +integration protocol. The remaining sections describe the review workflow. ## Hosts and prerequisites diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index 06b2d14..de92bb1 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -11,11 +11,13 @@ application-area: [all] ## Description -`SetLoadFields(...)` declares the subset of normal fields the next read should materialize, "reducing data read and transfer thereby improving performance significantly." Per the upstream guidance, "the gains scale with the amount of rows read, so for loops that read many rows `SetLoadFields` is even more important." Primary-key fields, `SystemId`, and system audit fields are loaded automatically, "and fields that are filtered on are also automatically included" — those do not need to appear in the list. `SetLoadFields` only affects `FieldClass = Normal`; it does not narrow FlowFields or FlowFilters. Its position relative to `SetRange`/`SetFilter` does not change the projection: filtered fields are added to the load set at read time either way. Projection-changing operations are separate: `AddLoadFields(...)` expands the selection, a later `SetLoadFields(...)` or `SetBaseLoadFields()` overwrites it, and `Reset()` or a fieldless `SetLoadFields()` restores all readable normal fields. +`SetLoadFields(...)` declares the subset of normal fields the next read should materialize. Microsoft's partial-record guidance explains how loading fewer fields reduces work, particularly for read loops and tables with extensions. Primary-key fields, `SystemId`, system audit fields, and fields being filtered on are loaded automatically; those do not need to appear in the selection. Only `FieldClass = Normal` fields can be selected, not FlowFields or FlowFilters. + +Its position relative to `SetRange`/`SetFilter` does not change the projection: filtered fields are included at read time either way. Projection-changing operations are separate: `AddLoadFields(...)` expands the selection, a later `SetLoadFields(...)` or `SetBaseLoadFields()` overwrites it, and `Reset()` or a fieldless `SetLoadFields()` restores all readable normal fields. The Microsoft Learn references below document the selection and reset behavior. ## Best Practice -Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. The pattern `SetLoadFields(...); if Record.Get(...) then ...` is the upstream-endorsed shape. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons (`singleton-setup-tables-need-no-access-optimization.md`, `temporary-tables-have-no-database-cost.md`). For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see `addloadfields-in-report-onpredataitem.md`). +Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. For example, `SetLoadFields(...); if Record.Get(...) then ...` selects fields before the read. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons ([singleton setup tables](singleton-setup-tables-need-no-access-optimization.md), [temporary tables](temporary-tables-have-no-database-cost.md)). The numeric cutoffs are BCQuality review heuristics, not Microsoft platform thresholds. For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see [report partial loads](addloadfields-in-report-onpredataitem.md)). See sample: [`use-setloadfields-for-partial-records.good.al`](use-setloadfields-for-partial-records.good.al). @@ -26,3 +28,8 @@ Loading a wide table and reading one field per row in a loop. The bytes transfer Statement order is not part of this anti pattern. `SetLoadFields` placed ahead of `SetRange`/`SetFilter` materializes exactly the same columns as the reverse order, so a reviewer reports it as a readability observation at most — never as a performance defect. See sample: [`use-setloadfields-for-partial-records.bad.al`](use-setloadfields-for-partial-records.bad.al). + +## References + +- [Record.SetLoadFields remarks](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-setloadfields-method#remarks): automatically loaded fields, normal-field restrictions, and resetting the selection. +- [Using partial records](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-partial-records): performance rationale, load-selection APIs, reset behavior, and report guidance. From c12b2f0a88c7316b82d2c9e01dfb8518a9eb6eea Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 11 Sep 2026 09:26:14 +0200 Subject: [PATCH 06/51] Separate analyzer rules from BCQuality knowledge (#178) Retire deterministic compiler and analyzer duplicates, remove their review routing, and clarify the admission test for contextual analyzer knowledge. Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + docs/contributing.md | 13 +++++- docs/using-bcquality.md | 6 +++ evaluation/README.md | 2 +- evaluation/review-fixtures.json | 3 -- .../object-affixes-prevent-collisions.bad.al | 43 ------------------- .../object-affixes-prevent-collisions.good.al | 40 ----------------- .../object-affixes-prevent-collisions.md | 30 ------------- ...ct-affix-not-extension-member-affix.bad.al | 22 ---------- ...t-affix-not-extension-member-affix.good.al | 22 ---------- ...object-affix-not-extension-member-affix.md | 28 ------------ ...s-part-of-published-object-identity.bad.al | 9 ---- ...-part-of-published-object-identity.good.al | 8 ---- ...ce-is-part-of-published-object-identity.md | 26 ----------- ...ationarea-required-on-page-controls.bad.al | 25 ----------- ...tionarea-required-on-page-controls.good.al | 40 ----------------- ...plicationarea-required-on-page-controls.md | 28 ------------ .../begin-on-same-line-as-then-else-do.bad.al | 14 ------ ...begin-on-same-line-as-then-else-do.good.al | 24 ----------- .../begin-on-same-line-as-then-else-do.md | 26 ----------- .../block-keywords-start-new-line.bad.al | 15 ------- .../block-keywords-start-new-line.good.al | 23 ---------- .../style/block-keywords-start-new-line.md | 26 ----------- ...-subscriber-param-names-match-publisher.md | 26 ----------- .../function-call-parentheses-required.bad.al | 11 ----- ...function-call-parentheses-required.good.al | 11 ----- .../function-call-parentheses-required.md | 26 ----------- .../style/label-suffix-approved-list.bad.al | 14 ------ .../style/label-suffix-approved-list.good.al | 15 ------- .../style/label-suffix-approved-list.md | 26 ----------- .../style/lowercase-reserved-keywords.bad.al | 14 ------ .../style/lowercase-reserved-keywords.good.al | 14 ------ .../style/lowercase-reserved-keywords.md | 28 ------------ ...o-begin-end-around-single-statement.bad.al | 11 ----- ...-begin-end-around-single-statement.good.al | 10 ----- .../no-begin-end-around-single-statement.md | 26 ----------- .../no-space-before-method-parenthesis.bad.al | 11 ----- ...no-space-before-method-parenthesis.good.al | 11 ----- .../no-space-before-method-parenthesis.md | 26 ----------- ...on-required-and-matches-membercount.bad.al | 17 -------- ...n-required-and-matches-membercount.good.al | 18 -------- ...aption-required-and-matches-membercount.md | 26 ----------- .../single-space-after-not-operator.bad.al | 11 ----- .../single-space-after-not-operator.good.al | 11 ----- .../style/single-space-after-not-operator.md | 26 ----------- ...ingle-space-around-binary-operators.bad.al | 12 ------ ...ngle-space-around-binary-operators.good.al | 12 ------ .../single-space-around-binary-operators.md | 26 ----------- .../style/this-keyword-in-codeunits.bad.al | 14 ------ .../style/this-keyword-in-codeunits.good.al | 14 ------ .../style/this-keyword-in-codeunits.md | 26 ----------- .../variable-declaration-order-by-type.bad.al | 13 ------ ...variable-declaration-order-by-type.good.al | 13 ------ .../variable-declaration-order-by-type.md | 26 ----------- .../variable-name-must-not-shadow.bad.al | 19 -------- .../variable-name-must-not-shadow.good.al | 19 -------- .../style/variable-name-must-not-shadow.md | 26 ----------- .../skills/review/al-appsource-review.md | 29 ++++++------- .../review/al-breaking-changes-review.md | 5 +-- microsoft/skills/review/al-style-review.md | 22 +++++----- 60 files changed, 45 insertions(+), 1095 deletions(-) delete mode 100644 microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al delete mode 100644 microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al delete mode 100644 microsoft/knowledge/appsource/object-affixes-prevent-collisions.md delete mode 100644 microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al delete mode 100644 microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al delete mode 100644 microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md delete mode 100644 microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al delete mode 100644 microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al delete mode 100644 microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md delete mode 100644 microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al delete mode 100644 microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al delete mode 100644 microsoft/knowledge/style/applicationarea-required-on-page-controls.md delete mode 100644 microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al delete mode 100644 microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al delete mode 100644 microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md delete mode 100644 microsoft/knowledge/style/block-keywords-start-new-line.bad.al delete mode 100644 microsoft/knowledge/style/block-keywords-start-new-line.good.al delete mode 100644 microsoft/knowledge/style/block-keywords-start-new-line.md delete mode 100644 microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md delete mode 100644 microsoft/knowledge/style/function-call-parentheses-required.bad.al delete mode 100644 microsoft/knowledge/style/function-call-parentheses-required.good.al delete mode 100644 microsoft/knowledge/style/function-call-parentheses-required.md delete mode 100644 microsoft/knowledge/style/label-suffix-approved-list.bad.al delete mode 100644 microsoft/knowledge/style/label-suffix-approved-list.good.al delete mode 100644 microsoft/knowledge/style/label-suffix-approved-list.md delete mode 100644 microsoft/knowledge/style/lowercase-reserved-keywords.bad.al delete mode 100644 microsoft/knowledge/style/lowercase-reserved-keywords.good.al delete mode 100644 microsoft/knowledge/style/lowercase-reserved-keywords.md delete mode 100644 microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al delete mode 100644 microsoft/knowledge/style/no-begin-end-around-single-statement.good.al delete mode 100644 microsoft/knowledge/style/no-begin-end-around-single-statement.md delete mode 100644 microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al delete mode 100644 microsoft/knowledge/style/no-space-before-method-parenthesis.good.al delete mode 100644 microsoft/knowledge/style/no-space-before-method-parenthesis.md delete mode 100644 microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al delete mode 100644 microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al delete mode 100644 microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md delete mode 100644 microsoft/knowledge/style/single-space-after-not-operator.bad.al delete mode 100644 microsoft/knowledge/style/single-space-after-not-operator.good.al delete mode 100644 microsoft/knowledge/style/single-space-after-not-operator.md delete mode 100644 microsoft/knowledge/style/single-space-around-binary-operators.bad.al delete mode 100644 microsoft/knowledge/style/single-space-around-binary-operators.good.al delete mode 100644 microsoft/knowledge/style/single-space-around-binary-operators.md delete mode 100644 microsoft/knowledge/style/this-keyword-in-codeunits.bad.al delete mode 100644 microsoft/knowledge/style/this-keyword-in-codeunits.good.al delete mode 100644 microsoft/knowledge/style/this-keyword-in-codeunits.md delete mode 100644 microsoft/knowledge/style/variable-declaration-order-by-type.bad.al delete mode 100644 microsoft/knowledge/style/variable-declaration-order-by-type.good.al delete mode 100644 microsoft/knowledge/style/variable-declaration-order-by-type.md delete mode 100644 microsoft/knowledge/style/variable-name-must-not-shadow.bad.al delete mode 100644 microsoft/knowledge/style/variable-name-must-not-shadow.good.al delete mode 100644 microsoft/knowledge/style/variable-name-must-not-shadow.md diff --git a/README.md b/README.md index 2118ce0..e9e18d5 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ the agent's judgment; it is not an exhaustive BC manual or a substitute for compilation, analyzers, tests, or human review. See [coverage and limits](docs/using-bcquality.md#coverage-and-limits) for the available domains and the difference between a folder review and a comparison. +Mechanical issues already enforced by the AL compiler or standard analyzers are +intentionally left to those deterministic tools rather than duplicated here. Functional areas such as Finance, Supply Chain Management, Manufacturing, Jobs, Warehousing, and Service, and technologies such as PowerShell, pipelines, and diff --git a/docs/contributing.md b/docs/contributing.md index 0e493c2..27da177 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -41,7 +41,9 @@ capable LLM **would get something wrong, or miss something, without it**, not simply because the topic is important. Apply this admission test: > If this file did not exist, would a modern LLM reviewing or generating BC -> code make a mistake this file would have prevented? +> code make a BC-specific mistake that the configured compiler, analyzers, and +> tests would not reliably catch, or would it misinterpret or incorrectly +> remediate one of their diagnostics? Good candidates encode a BC-specific mechanic that models get wrong, a version-dependent behavior, or a misleading interpretation of an analyzer @@ -56,6 +58,15 @@ transactions short" does not earn a separate knowledge file merely by being sound advice. Negative clarifications that prevent false positives are as valuable as rules that catch defects. +Do not add knowledge whose anti-pattern is fully and deterministically detected +by the AL compiler or a standard analyzer. This applies to authoring as well as +review: an authoring agent should compile with the consuming app's actual +ruleset and correct the resulting diagnostics instead of carrying prose copies +of analyzer rules in context. Analyzer-related knowledge belongs here only when +it adds a BC-specific exception, version boundary, cross-object implication, or +remediation constraint that the diagnostic itself cannot establish. Merely +explaining why a deterministic rule exists is not sufficient. + **Skills hold discovery and execution mechanics; knowledge files hold BC facts.** Correct or extend a knowledge article when a BC fact is missing or wrong. Do not hide that fact in a skill's instructions. A genuine routing, diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md index 44e5975..dcaddde 100644 --- a/docs/using-bcquality.md +++ b/docs/using-bcquality.md @@ -196,6 +196,12 @@ removal or another comparison-only regression requires an actual baseline. The corpus is technical AL guidance, not exhaustive functional validation or AppSource certification. +BCQuality intentionally does not duplicate mechanical diagnostics already +enforced by the AL compiler or standard analyzers. Run the consuming app's +normal compiler and analyzer pipeline alongside review and authoring. Knowledge +may still discuss a diagnostic when BC-specific context is needed to avoid a +false positive or choose a correct remediation. + ### Knowledge by domain Each article describes one concern. Where samples exist, use its linked diff --git a/evaluation/README.md b/evaluation/README.md index 7063baf..7be55b4 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -36,7 +36,7 @@ This credential-free check proves every selected leaf maps to a same-named knowl { "id": "case-a1b2c3d4", "findings": [ - { "id": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" } + { "id": "microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md" } ] } ] diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index f5c7046..e11508a 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -7,9 +7,6 @@ "agents": { "article": "wire-all-three-agent-interfaces" }, - "appsource": { - "context": "AppSourceCop mandatoryAffixes is configured to ABC." - }, "breaking-changes": { "article": "do-not-expose-sensitive-data-through-public-api" }, diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al deleted file mode 100644 index dc1c6f3..0000000 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.bad.al +++ /dev/null @@ -1,43 +0,0 @@ -// Anti-pattern: an own object with no affix. Another app that also defines a -// "Loyalty Tier" table cannot be installed alongside this one. -table 50379 "Loyalty Tier" -{ - Caption = 'Loyalty Tier'; - DataClassification = CustomerContent; - - fields - { - field(1; "Code"; Code[20]) - { - Caption = 'Code'; - } - field(10; Description; Text[100]) - { - Caption = 'Description'; - } - } - - keys - { - key(PK; "Code") - { - Clustered = true; - } - } -} - -// Anti-pattern (the common half-measure): the extension object carries the -// affix, but the field it adds to the standard Customer table does not. That -// unaffixed field still collides with any other app that adds "Loyalty Points" -// to Customer, and AS0011 flags it. -tableextension 50378 "ABC Customer Ext" extends Customer -{ - fields - { - field(50378; "Loyalty Points"; Integer) - { - Caption = 'Loyalty Points'; - DataClassification = CustomerContent; - } - } -} diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al deleted file mode 100644 index 28bfa4d..0000000 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al +++ /dev/null @@ -1,40 +0,0 @@ -// Own object: the affix "ABC" is carried at object-name level. -table 50377 "ABC Loyalty Tier" -{ - Caption = 'Loyalty Tier'; - DataClassification = CustomerContent; - - fields - { - field(1; "Code"; Code[20]) - { - Caption = 'Code'; - } - field(10; Description; Text[100]) - { - Caption = 'Description'; - } - } - - keys - { - key(PK; "Code") - { - Clustered = true; - } - } -} - -// Extension of a standard object: the added field is individually affixed, -// because the object name (Customer) belongs to the base application. -tableextension 50376 "ABC Customer Ext" extends Customer -{ - fields - { - field(50376; "Loyalty Points ABC"; Integer) - { - Caption = 'Loyalty Points'; - DataClassification = CustomerContent; - } - } -} diff --git a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md b/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md deleted file mode 100644 index a53e584..0000000 --- a/microsoft/knowledge/appsource/object-affixes-prevent-collisions.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -bc-version: [all] -domain: appsource -keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension, first-party, isv] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Apply a reserved affix to objects and to members added to base objects - -## Description - -An AppSource extension must prevent name collisions through its registered affix or, on BC23 and later for objects it owns, a namespace with at least two levels. The affix still applies to every field, key, control, or action added to a base-application object; see `two-level-namespace-replaces-object-affix-not-extension-member-affix.md`. Without either mechanism, two apps that both define a `Loyalty Tier` table cannot coexist, and two apps that add an unaffixed `Loyalty Points` field to `Customer` still collide regardless of their namespaces. - -AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough — an unaffixed field bolted onto `Customer` still collides and still fails validation. - -This rule scopes to Marketplace ISV extensions, which is what AppSourceCop validates. A first-party Microsoft in-box module (publisher `Microsoft`, an object range reserved for first-party use, and no `AppSourceCop.json`/`mandatoryAffixes` in the app) is not built or shipped as an Marketplace extension and is not subject to AS0011, so an unaffixed action or field it adds to a base-application page is not a collision risk to flag. Renaming an existing shipped first-party member to add an affix is itself a breaking change to that module's own history and is not required by this rule. - -## Best Practice - -Own objects use the registered affix (for example `ABC Loyalty Tier`) or, when targeting BC23 or later, a qualifying namespace. Every field or action added to a standard object remains individually affixed (for example `Loyalty Points ABC` on a `Customer` tableextension). - -See sample: [`object-affixes-prevent-collisions.good.al`](object-affixes-prevent-collisions.good.al). - -## Anti Pattern - -An owned object with neither a qualifying namespace nor an affix, an unaffixed extension member, or the common half-measure where the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing collision protection and the field can still collide with another app. - -See sample: [`object-affixes-prevent-collisions.bad.al`](object-affixes-prevent-collisions.bad.al). diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al deleted file mode 100644 index de2d3ed..0000000 --- a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al +++ /dev/null @@ -1,22 +0,0 @@ -namespace Contoso; - -table 50462 "Rental Agreement" -{ - DataClassification = CustomerContent; - - fields - { - field(1; "No."; Code[20]) { } - } -} - -tableextension 50463 "Rental Customer Ext" extends Customer -{ - fields - { - field(50463; "Loyalty Points"; Integer) - { - DataClassification = CustomerContent; - } - } -} diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al deleted file mode 100644 index 93fa9c6..0000000 --- a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al +++ /dev/null @@ -1,22 +0,0 @@ -namespace Contoso.Rentals; - -table 50460 "Rental Agreement" -{ - DataClassification = CustomerContent; - - fields - { - field(1; "No."; Code[20]) { } - } -} - -tableextension 50461 "Rental Customer Ext" extends Customer -{ - fields - { - field(50461; "Loyalty Points RNT"; Integer) - { - DataClassification = CustomerContent; - } - } -} diff --git a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md b/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md deleted file mode 100644 index fef6d6c..0000000 --- a/microsoft/knowledge/appsource/two-level-namespace-replaces-object-affix-not-extension-member-affix.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [23..] -domain: appsource -keywords: [namespace, two-level, affix, prefix, suffix, as0011, tableextension, pageextension, false-positive] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# A two-level namespace replaces an object affix, not an extension-member affix - -## Description - -Current AppSource naming guidance accepts a namespace with at least two levels, such as `Contoso.Rentals`, instead of a registered prefix or suffix on the names of objects the app owns. The namespace does not qualify members added to another publisher's object: fields, keys, controls, and actions introduced through table or page extensions still share the target object's flat member namespace and still need the registered affix. - -The requirement comes from AppSourceCop rule AS0011, which only runs when the app enables AppSourceCop and configures a mandatory affix — normally an `AppSourceCop.json` next to the app manifest. An app that ships no such configuration is not subject to AS0011, and its extension members are not a compliance gap. This is the usual situation for first-party, in-box apps that ship as part of the product rather than through AppSource: their uniqueness comes from allocated object ID ranges and a controlled source tree, not from a registered affix. Confirm the extending app actually configures a mandatory affix before reporting an unaffixed extension member. - -## Best Practice - -Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. Do not raise a missing member affix against an app that does not enable AppSourceCop with a mandatory affix; there AS0011 never fires, and the app's namespace is not the reason — the absent configuration is. - -See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al). - -## Anti Pattern - -Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer` in an app that does configure a mandatory affix. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. The mirror-image mistake is reporting an unaffixed extension member in an app that enables no mandatory affix at all — AS0011 does not apply there, and the finding is a false positive. - -See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al). diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al deleted file mode 100644 index e2a5152..0000000 --- a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.bad.al +++ /dev/null @@ -1,9 +0,0 @@ -// This published object previously used namespace Contoso.Rentals. -namespace Contoso.RentalManagement; - -codeunit 50467 "Rental Agreement Mgt." -{ - procedure CreateAgreement() - begin - end; -} diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al deleted file mode 100644 index ea151a8..0000000 --- a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.good.al +++ /dev/null @@ -1,8 +0,0 @@ -namespace Contoso.Rentals; - -codeunit 50466 "Rental Agreement Mgt." -{ - procedure CreateAgreement() - begin - end; -} diff --git a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md b/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md deleted file mode 100644 index 6c345ee..0000000 --- a/microsoft/knowledge/breaking-changes/namespace-is-part-of-published-object-identity.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [23..] -domain: breaking-changes -keywords: [namespace, published-object, dependency, breaking-change, as0007, compile-time-identity] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Treat a published namespace as part of object identity - -## Description - -AL resolves an object by namespace and name. Once an app ships and dependent extensions compile against that identity, changing the namespace breaks their references even when the object name and ID stay unchanged. AppSourceCop AS0007 rejects changing the namespace of published objects; namespaces are therefore not a cosmetic folder-like label that can be reorganized after release. - -## Best Practice - -Choose a globally meaningful namespace before first publication and keep it stable. Add new functional areas beneath that structure without moving existing published objects. If an identity must move, use the platform's supported move/obsoletion lifecycle rather than a source-only namespace rename. - -See sample: [`namespace-is-part-of-published-object-identity.good.al`](namespace-is-part-of-published-object-identity.good.al). - -## Anti Pattern - -Changing `namespace Contoso.Rentals;` to `namespace Contoso.RentalManagement;` as a cleanup while leaving the object name and ID untouched. Every dependent `using` directive and qualified reference targets the old identity and stops compiling. - -See sample: [`namespace-is-part-of-published-object-identity.bad.al`](namespace-is-part-of-published-object-identity.bad.al). diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al deleted file mode 100644 index 8da7777..0000000 --- a/microsoft/knowledge/style/applicationarea-required-on-page-controls.bad.al +++ /dev/null @@ -1,25 +0,0 @@ -page 50375 "Sample App Area Bad" -{ - PageType = Card; - SourceTable = Customer; - layout - { - area(Content) - { - group(General) - { - // Anti-pattern: no ApplicationArea. AS0062 flags this control, - // and it is silently hidden in the Web client for profiles whose - // enabled areas do not already cover it. - field("No."; Rec."No.") - { - ToolTip = 'Specifies the number that identifies the customer.'; - } - field(Name; Rec.Name) - { - ToolTip = 'Specifies the customer''s name.'; - } - } - } - } -} diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al b/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al deleted file mode 100644 index d344337..0000000 --- a/microsoft/knowledge/style/applicationarea-required-on-page-controls.good.al +++ /dev/null @@ -1,40 +0,0 @@ -page 50374 "Sample App Area Good" -{ - PageType = Card; - SourceTable = Customer; - layout - { - area(Content) - { - group(General) - { - field("No."; Rec."No.") - { - ApplicationArea = All; - ToolTip = 'Specifies the number that identifies the customer.'; - } - field(Name; Rec.Name) - { - ApplicationArea = All; - ToolTip = 'Specifies the customer''s name.'; - } - } - } - } - actions - { - area(Processing) - { - action(Refresh) - { - ApplicationArea = All; - ToolTip = 'Reloads the current record.'; - - trigger OnAction() - begin - CurrPage.Update(false); - end; - } - } - } -} diff --git a/microsoft/knowledge/style/applicationarea-required-on-page-controls.md b/microsoft/knowledge/style/applicationarea-required-on-page-controls.md deleted file mode 100644 index 3766dda..0000000 --- a/microsoft/knowledge/style/applicationarea-required-on-page-controls.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [application-area, page-control, as0062, appsourcecop, hidden-control, web-client] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Every page control needs an `ApplicationArea` (AppSourceCop AS0062) - -## Description - -A field control on a page or pageextension that has no `ApplicationArea` property is silently hidden in the Web client for every profile whose enabled application areas do not cover it. There is no error and no warning at runtime — the field simply does not appear, which reads as data loss to the user. AppSourceCop AS0062 flags any page control or action that is missing the `ApplicationArea` property, and AppSource technical validation rejects the app until it is set. - -Set the property to an area the app actually enables. `All` makes the control visible under every profile and is the common default; if the app declares narrower areas in `app.json`, use one of those. The property applies to field controls and to actions. This is a sibling concern to `caption-required-on-page-fields.md` and `tooltip-required-on-page-fields.md`; note that the ToolTip requirement is the separate CodeCop rule AA0218, not AS0062. - -## Best Practice - -Every field control and action carries `ApplicationArea = All;` (or a declared area of the app). The value is set once per control and keeps the control visible in the Web client. - -See sample: [`applicationarea-required-on-page-controls.good.al`](applicationarea-required-on-page-controls.good.al). - -## Anti Pattern - -A field control with no `ApplicationArea`. AS0062 flags it, and the control is invisible in the Web client for any profile that does not already enable a matching area. - -See sample: [`applicationarea-required-on-page-controls.bad.al`](applicationarea-required-on-page-controls.bad.al). diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al deleted file mode 100644 index fd3ac45..0000000 --- a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50235 "Sample Begin Own Line Bad" -{ - procedure Run(Condition: Boolean) - begin - if Condition then - begin - DoSomething(); - DoSomethingElse(); - end; - end; - - local procedure DoSomething() begin end; - local procedure DoSomethingElse() begin end; -} diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al deleted file mode 100644 index 6043c5b..0000000 --- a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al +++ /dev/null @@ -1,24 +0,0 @@ -codeunit 50234 "Sample Begin Same Line Good" -{ - procedure Run(Condition: Boolean) - var - i: Integer; - begin - if Condition then begin - DoSomething(); - DoSomethingElse(); - end else begin - Reset(); - Notify(); - end; - for i := 1 to 10 do begin - DoSomething(); - DoSomethingElse(); - end; - end; - - local procedure DoSomething() begin end; - local procedure DoSomethingElse() begin end; - local procedure Reset() begin end; - local procedure Notify() begin end; -} diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md deleted file mode 100644 index de30708..0000000 --- a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [begin, end, compound-statement, aa0005, codecop, formatting] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# `begin` goes on the same line as `then`, `else`, or `do` (CodeCop AA0005) - -## Description - -When a compound block follows `then`, `else`, or `do`, the `begin` keyword must sit on the same line as the preceding keyword, separated by exactly one space. `if Condition then begin` and `for i := 1 to N do begin` are correct. The form that puts `begin` on its own line — common in older AL and in languages like Pascal — is flagged by CodeCop AA0005. The rule does not change indentation of the block body; it only governs the placement of `begin` relative to `then`/`else`/`do`. - -## Best Practice - -`if Condition then begin … end;`, `else begin … end;`, `for i := 1 to N do begin … end;`. The block body is indented one level below the `if`/`for` line, and `end;` sits at the same indentation as the line that opened the block. - -See sample: [`begin-on-same-line-as-then-else-do.good.al`](begin-on-same-line-as-then-else-do.good.al). - -## Anti Pattern - -A line that ends with `then` (or `else`, or `do`) and is followed by a line whose only content is `begin`. The compiler accepts it but CodeCop AA0005 flags it; the visual cost is a wasted line per block and a layout that looks alien to readers used to current AL style. - -See sample: [`begin-on-same-line-as-then-else-do.bad.al`](begin-on-same-line-as-then-else-do.bad.al). diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.bad.al b/microsoft/knowledge/style/block-keywords-start-new-line.bad.al deleted file mode 100644 index ef7f937..0000000 --- a/microsoft/knowledge/style/block-keywords-start-new-line.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50239 "Sample Block Kw Bad" -{ - procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) - var - i: Integer; - begin - if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode(); - for i := 1 to 10 do begin DoSomething(i); DoSomethingElse(i); end; - end; - - local procedure ValidateContactName() begin end; - local procedure ValidateSalespersonCode() begin end; - local procedure DoSomething(I: Integer) begin end; - local procedure DoSomethingElse(I: Integer) begin end; -} diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.good.al b/microsoft/knowledge/style/block-keywords-start-new-line.good.al deleted file mode 100644 index eb6c3d4..0000000 --- a/microsoft/knowledge/style/block-keywords-start-new-line.good.al +++ /dev/null @@ -1,23 +0,0 @@ -codeunit 50238 "Sample Block Kw Good" -{ - procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) - var - i: Integer; - begin - if IsContactName then - ValidateContactName() - else - if IsSalespersonCode then - ValidateSalespersonCode(); - - for i := 1 to 10 do begin - DoSomething(i); - DoSomethingElse(i); - end; - end; - - local procedure ValidateContactName() begin end; - local procedure ValidateSalespersonCode() begin end; - local procedure DoSomething(I: Integer) begin end; - local procedure DoSomethingElse(I: Integer) begin end; -} diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.md b/microsoft/knowledge/style/block-keywords-start-new-line.md deleted file mode 100644 index 8161a08..0000000 --- a/microsoft/knowledge/style/block-keywords-start-new-line.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [block-keyword, end, if, repeat, until, for, while, case, aa0018] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Block keywords (`end`, `if`, `repeat`, `until`, `for`, `while`, `case`) start a new line (CodeCop AA0018) - -## Description - -CodeCop AA0018 requires that the block-introducing keywords `if`, `repeat`, `until`, `for`, `while`, `case`, and the block-terminating keyword `end` always start a new line. Multiple statements packed onto one line — `if A then X() else if B then Y();` written inline, or `for i := 1 to 10 do begin X(i); Y(i); end;` — defeat code review tooling that operates line-by-line and obscure the control flow. The rule does not prohibit short single-statement constructs spread across two lines (`if Cond then X();`); it prohibits packing the entire control structure onto one line. - -## Best Practice - -Each `if`, `else if`, `repeat`, `for`, `while`, and `case` starts a line. Each `end;` (the closing of a `begin … end` block or a `case`) starts a line. Branch bodies are on their own line, indented. - -See sample: [`block-keywords-start-new-line.good.al`](block-keywords-start-new-line.good.al). - -## Anti Pattern - -`if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode();` collapses an `if/else if` chain onto a single line; AA0018 flags both the `else` and the second `if`. The same applies to `for i := 1 to 10 do begin DoX(i); DoY(i); end;` — `end` is not at the start of its line. - -See sample: [`block-keywords-start-new-line.bad.al`](block-keywords-start-new-line.bad.al). diff --git a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md deleted file mode 100644 index e408ebb..0000000 --- a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber, false-positive] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Event subscriber parameter names must match the publisher signature - -## Description - -In AL, an `[EventSubscriber]` procedure is bound to its publisher by event name and parameter list. For every parameter the subscriber declares, the name is not a style choice — it must match the name the publisher declared. The compiler validates the match at build time and emits an error if the subscriber renames a parameter. This means a reviewer cannot apply a generic "use better names" pass to subscriber parameters: `Sender`, `Rec`, `xRec`, `RunTrigger`, the table-and-field-specific parameter names a publisher emits — all are dictated by the publisher and must be reproduced verbatim. - -A subscriber may, however, declare fewer parameters than the publisher. AL binds each subscriber parameter to the publisher parameter of the same name, so the subscriber can omit any parameters its handler does not use, from any position, and can even declare the ones it keeps in a different order than the publisher. This compiles and binds correctly, so a shorter or differently ordered subscriber signature is not a signature mismatch. In shipping BCApps code, `Test Runner - Mgt::OnBeforeTestMethodRun` publishes `CurrentTestMethodLine, CodeunitID, CodeunitName, FunctionName, FunctionTestPermissions, Skip`, and subscribers such as `ALTestRunnerResetEnvironment` bind to it while omitting `Skip` and declaring `CurrentTestMethodLine` last. - -## Best Practice - -Copy each parameter's name and type from the publisher verbatim for every parameter the subscriber keeps, and omit the ones the handler does not use. When in doubt, navigate to the publisher (`OnAfterValidateEvent`, `OnBeforePostSalesDoc`, etc.) and copy its parameter list. Style rules that apply to other locals — descriptive names, no spaces — do not apply to subscriber parameters. Do not flag a subscriber for declaring fewer parameters than the publisher, for omitting one from the middle of the list, or for declaring them in a different order, as long as every parameter it does declare matches a publisher parameter by name and type: that is valid AL, not a mismatch. - -## Anti Pattern - -Renaming a publisher parameter to look prettier in the subscriber. The build breaks immediately, because the name is what the runtime binds on. More insidiously, a parameter name that happens to match by coincidence in one event publisher but not in a similar one will compile in some versions of BC and fail in others when the publisher signature evolves. - -Detection: a subscriber parameter whose name or type does not correspond to any parameter on the publisher — not a subscriber that merely declares fewer parameters, drops one from the middle, or lists them in a different order. diff --git a/microsoft/knowledge/style/function-call-parentheses-required.bad.al b/microsoft/knowledge/style/function-call-parentheses-required.bad.al deleted file mode 100644 index 2677716..0000000 --- a/microsoft/knowledge/style/function-call-parentheses-required.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50213 "Sample Parens Bad" -{ - procedure Run() - var - Customer: Record Customer; - begin - Customer.Init; - if Customer.FindFirst then - Customer.Modify; - end; -} diff --git a/microsoft/knowledge/style/function-call-parentheses-required.good.al b/microsoft/knowledge/style/function-call-parentheses-required.good.al deleted file mode 100644 index 53f6f85..0000000 --- a/microsoft/knowledge/style/function-call-parentheses-required.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50212 "Sample Parens Good" -{ - procedure Run() - var - Customer: Record Customer; - begin - Customer.Init(); - if Customer.FindFirst() then - Customer.Modify(); - end; -} diff --git a/microsoft/knowledge/style/function-call-parentheses-required.md b/microsoft/knowledge/style/function-call-parentheses-required.md deleted file mode 100644 index f24985a..0000000 --- a/microsoft/knowledge/style/function-call-parentheses-required.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [parentheses, function-call, method-call, aa0008, codecop] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Always write parentheses on procedure calls (CodeCop AA0008) - -## Description - -AL allows a parameterless procedure to be called without parentheses — `Customer.Init` instead of `Customer.Init()` — and the result is syntactically identical at runtime. CodeCop AA0008 still flags the parenthesis-less form. The reason is twofold: written without parentheses, a procedure call is visually indistinguishable from a property read, which makes BC code harder to scan; and the same identifier may exist as both a property and a procedure on different objects, so the parentheses are the only local signal that this is a call. The rule applies to every parameterless invocation, including `Init`, `Insert`, `Modify`, `Delete`, `DeleteAll`, `FindFirst`, `FindSet`, `Next`, `Get`, `CalcFields`, and user-defined procedures. - -## Best Practice - -Always write `()` on a procedure call, even when it takes no arguments: `Customer.Init();`, `TempBuffer.DeleteAll();`, `if Customer.FindFirst() then …`. The same applies inside expressions and as a condition. - -See sample: [`function-call-parentheses-required.good.al`](function-call-parentheses-required.good.al). - -## Anti Pattern - -`Customer.Init;`, `TempBuffer.DeleteAll;`, `if Customer.FindFirst then …`. Every one of those is an AA0008 violation. Reviewers should treat a parameterless procedure name appearing without parentheses as a defect, even though the compiler accepts it. - -See sample: [`function-call-parentheses-required.bad.al`](function-call-parentheses-required.bad.al). diff --git a/microsoft/knowledge/style/label-suffix-approved-list.bad.al b/microsoft/knowledge/style/label-suffix-approved-list.bad.al deleted file mode 100644 index 6b227de..0000000 --- a/microsoft/knowledge/style/label-suffix-approved-list.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50201 "Sample Label Suffix Bad" -{ - var - CannotDeleteLine: Label 'Cannot delete this line.'; - Text000: Label 'Update complete'; - UpdateLocation: Label 'Update location?'; - WrongSuffixTok: Label 'Customer %1 not found.'; - - procedure ShowMessages() - begin - Error(WrongSuffixTok, '10000'); - Message(Text000); - end; -} diff --git a/microsoft/knowledge/style/label-suffix-approved-list.good.al b/microsoft/knowledge/style/label-suffix-approved-list.good.al deleted file mode 100644 index f3ec561..0000000 --- a/microsoft/knowledge/style/label-suffix-approved-list.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50200 "Sample Label Suffix Good" -{ - var - UpdateCompleteMsg: Label 'Update complete.'; - CustomerNotFoundErr: Label 'Customer %1 does not exist.'; - DeleteRecordQst: Label 'Delete this record?'; - CustomerNameLbl: Label 'Customer Name'; - GetMethodTok: Label 'GET', Locked = true; - TelemetryStartedTxt: Label 'Operation started for customer %1.', Locked = true; - - procedure ShowMessage() - begin - Message(UpdateCompleteMsg); - end; -} diff --git a/microsoft/knowledge/style/label-suffix-approved-list.md b/microsoft/knowledge/style/label-suffix-approved-list.md deleted file mode 100644 index 2bda119..0000000 --- a/microsoft/knowledge/style/label-suffix-approved-list.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [label, textconst, suffix, aa0074, codecop, msg, err, qst, lbl, tok] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use approved suffixes on Label and TextConst names (CodeCop AA0074) - -## Description - -CodeCop AA0074 flags `Label` and `TextConst` identifiers that do not end with an approved usage suffix. The suffix signals at the call site how the text is consumed and what translation behaviour it should get. The approved suffixes and their intended usage are: `Msg` for text shown via `Message()`; `Err` for text passed to `Error()`; `Qst` for text used with `Confirm` or `StrMenu`; `Lbl` for captions and tooltips; `Tok` for short tokens such as `'GET'`, `'PUT'`, `'HTTPS'`, GUIDs, or JSON/XML snippets that are not translated (typically with `Locked = true`); and `Txt` for general text including telemetry messages. A `Label` named `Text000` or `CannotDeleteLine` without a suffix violates the rule, regardless of how readable the prose is. - -## Best Practice - -Pick the suffix that matches the call where the label is consumed: `UpdateCompleteMsg` for `Message(...)`, `CustomerNotFoundErr` for `Error(...)`, `DeleteRecordQst` for `Confirm(...)`, `CustomerNameLbl` for tooltips and captions, `GetMethodTok` for locked tokens, `TelemetryDataTxt` for telemetry payloads. Suffix choices between `Tok`, `Lbl`, `Txt`, and `Msg` are judgment calls when the suffix is valid for the usage — what matters is that the suffix is on the approved list and matches the actual call. - -See sample: [`label-suffix-approved-list.good.al`](label-suffix-approved-list.good.al). - -## Anti Pattern - -A `Label` declared with no suffix (`CannotDeleteLine: Label '…';`), a generic name (`Text000: Label '…';`), or a suffix that contradicts the usage (`WrongSuffixTok: Label 'Customer %1 not found.'` then passed to `Error()`). All three trip AA0074 or its reviewers and obscure the call-site contract. - -See sample: [`label-suffix-approved-list.bad.al`](label-suffix-approved-list.bad.al). diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al b/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al deleted file mode 100644 index 83d5994..0000000 --- a/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50245 "Sample Upper Keywords Bad" -{ - procedure Walk(VAR Customer: Record Customer) - VAR - Found: Boolean; - BEGIN - IF Customer.FindSet() THEN - REPEAT - Found := TRUE; - UNTIL Customer.Next() = 0; - IF Found THEN - EXIT; - END; -} diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.good.al b/microsoft/knowledge/style/lowercase-reserved-keywords.good.al deleted file mode 100644 index 25fb20e..0000000 --- a/microsoft/knowledge/style/lowercase-reserved-keywords.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50244 "Sample Lower Keywords Good" -{ - procedure Walk(var Customer: Record Customer) - var - Found: Boolean; - begin - if Customer.FindSet() then - repeat - Found := true; - until Customer.Next() = 0; - if Found then - exit; - end; -} diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.md b/microsoft/knowledge/style/lowercase-reserved-keywords.md deleted file mode 100644 index 7215491..0000000 --- a/microsoft/knowledge/style/lowercase-reserved-keywords.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [reserved-keyword, lowercase, aa0241, codecop, if, then, begin] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Reserved keywords are written in lowercase (CodeCop AA0241) - -## Description - -CodeCop AA0241 requires reserved AL keywords — `if`, `then`, `else`, `begin`, `end`, `var`, `procedure`, `local`, `internal`, `for`, `while`, `repeat`, `until`, `case`, `of`, `do`, `not`, `and`, `or`, `exit`, `break`, `skip`, `quit`, and the rest — to be lowercase. Old Navision and C/AL code used `IF…THEN…BEGIN…END` in uppercase, and that style still lingers in training data and legacy modules. New AL code is lowercase. The rule applies to keywords only — type names (`Record`, `Codeunit`, `Integer`), property names (`Caption`, `ToolTip`), and identifiers are unaffected. - -Test codeunits that retain legacy uppercase forms (`OPENEDIT`, `ASSERTERROR`, `VALUE`) are an accepted exception: the test framework historically uses those identifiers and rewriting them brings no benefit. The rule applies to new code in modified lines, not to long-standing test patterns. - -## Best Practice - -Write keywords lowercase: `if Condition then begin … end;`, `repeat … until Found;`, `for i := 1 to N do …`. The standard AL formatter normalizes casing automatically. - -See sample: [`lowercase-reserved-keywords.good.al`](lowercase-reserved-keywords.good.al). - -## Anti Pattern - -`IF Condition THEN BEGIN DoSomething(); END;`, `REPEAT GetNext(); UNTIL Found;`. Uppercase keywords trip AA0241 and signal C/AL-era code that has not been modernized. - -See sample: [`lowercase-reserved-keywords.bad.al`](lowercase-reserved-keywords.bad.al). diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al deleted file mode 100644 index 1803e1c..0000000 --- a/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50237 "Sample Single Stmt Bad" -{ - procedure Validate(IsAssemblyOutputLine: Boolean) - var - SalesLine: Record "Sales Line"; - begin - if IsAssemblyOutputLine then begin - SalesLine.TestField("Order Line No.", 0); - end; - end; -} diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al deleted file mode 100644 index 684a86c..0000000 --- a/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50236 "Sample Single Stmt Good" -{ - procedure Validate(IsAssemblyOutputLine: Boolean) - var - SalesLine: Record "Sales Line"; - begin - if IsAssemblyOutputLine then - SalesLine.TestField("Order Line No.", 0); - end; -} diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.md b/microsoft/knowledge/style/no-begin-end-around-single-statement.md deleted file mode 100644 index 3c1cf9f..0000000 --- a/microsoft/knowledge/style/no-begin-end-around-single-statement.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [begin, end, single-statement, aa0013, codecop, compound] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not wrap a single statement in `begin … end` (CodeCop AA0013) - -## Description - -CodeCop AA0013 flags `begin … end` blocks that contain exactly one statement. The compound-block syntax exists to group multiple statements as a unit; using it for a single statement adds two lines and a level of nesting without adding meaning. `if IsAssemblyOutputLine then begin TestField("Order Line No.", 0); end;` should be `if IsAssemblyOutputLine then TestField("Order Line No.", 0);` — one statement, no block. The same logic applies after `else`, `for`, `while`, and `repeat`. - -## Best Practice - -A single statement following `then`, `else`, `do`, or a case label is written on its own line, indented one level, with no `begin … end`. Use `begin … end` only when there are two or more statements to group. - -See sample: [`no-begin-end-around-single-statement.good.al`](no-begin-end-around-single-statement.good.al). - -## Anti Pattern - -`if Cond then begin OneCall(); end;` — single statement wrapped in a block. AA0013 flags it. The reviewer signal is "a `begin` followed by exactly one statement before its `end`." - -See sample: [`no-begin-end-around-single-statement.bad.al`](no-begin-end-around-single-statement.bad.al). diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al deleted file mode 100644 index b2f8295..0000000 --- a/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50231 "Sample No Space Paren Bad" -{ - procedure Lookup(CustomerNo: Code[20]) - var - Customer: Record Customer; - GreetingMsg: Label 'Hello %1'; - begin - if Customer.Get ( CustomerNo ) then - Message ( GreetingMsg, Customer.Name ); - end; -} diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al deleted file mode 100644 index eb16dc3..0000000 --- a/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50230 "Sample No Space Paren Good" -{ - procedure Lookup(CustomerNo: Code[20]) - var - Customer: Record Customer; - GreetingMsg: Label 'Hello %1'; - begin - if Customer.Get(CustomerNo) then - Message(GreetingMsg, Customer.Name); - end; -} diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.md b/microsoft/knowledge/style/no-space-before-method-parenthesis.md deleted file mode 100644 index 9c5ffea..0000000 --- a/microsoft/knowledge/style/no-space-before-method-parenthesis.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [spacing, parenthesis, method-call, aa0002, codecop] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# No space between a method name and its opening parenthesis (CodeCop AA0002) - -## Description - -CodeCop AA0002 forbids whitespace between a procedure/method name and its `(`. `Customer.Get(CustomerNo)` is correct; `Customer.Get (CustomerNo)` is not. The rule applies to user-defined procedures, system methods (`Insert`, `FindFirst`, `CalcFields`), trigger-style invocations, and the parenthesised cast/conversion forms (`Format(Value)`, `CopyStr(Source, 1, 10)`). The whitespace between `(` and the first argument, and between the last argument and `)`, is also forbidden by the same rule. - -## Best Practice - -`Customer.Get(CustomerNo)`, `Customer.SetFilter("No.", '%1', '*A*')`, `Message(GreetingMsg, UserName)`. The standard AL formatter enforces this automatically. - -See sample: [`no-space-before-method-parenthesis.good.al`](no-space-before-method-parenthesis.good.al). - -## Anti Pattern - -`Customer.Get ( CustomerNo )`, `Message ( GreetingMsg, UserName )`. Both trip AA0002 and read as if the call had an extra unnamed parameter — a small but persistent friction every reader pays. - -See sample: [`no-space-before-method-parenthesis.bad.al`](no-space-before-method-parenthesis.bad.al). diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al deleted file mode 100644 index 9d5269c..0000000 --- a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al +++ /dev/null @@ -1,17 +0,0 @@ -table 50255 "Sample OptionCaption Bad" -{ - fields - { - field(1; Status; Option) - { - Caption = 'Status'; - OptionMembers = Open,Released,Pending; - } - field(2; Priority; Option) - { - Caption = 'Priority'; - OptionMembers = Low,Medium,High,Critical; - OptionCaption = 'Low,Medium,High'; - } - } -} diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al deleted file mode 100644 index d0e9866..0000000 --- a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al +++ /dev/null @@ -1,18 +0,0 @@ -table 50254 "Sample OptionCaption Good" -{ - fields - { - field(1; Status; Option) - { - Caption = 'Status'; - OptionMembers = Open,Released,Pending; - OptionCaption = 'Open,Released,Pending'; - } - field(2; Priority; Option) - { - Caption = 'Priority'; - OptionMembers = Low,Medium,High,Critical; - OptionCaption = 'Low,Medium,High,Critical'; - } - } -} diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md deleted file mode 100644 index e90fb16..0000000 --- a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [optioncaption, option, member-count, aa0221, aa0223, aa0224] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Option fields need `OptionCaption`, and its element count must match `OptionMembers` (CodeCop AA0221/AA0223/AA0224) - -## Description - -CodeCop AA0221 requires an `OptionCaption` on every option-type field that is not sourced from a table column (table-sourced option fields inherit the captions of the underlying field). AA0223 and AA0224 add two integrity checks: the number of comma-separated entries in `OptionCaption` must equal the number of entries in `OptionMembers`, and each caption must align by position with its member. The position alignment is what the platform uses to translate option values — the `OptionMembers` list never changes per locale, the `OptionCaption` list does. A mismatch in count or order produces silent corruption: the option `Released` shows the caption that belongs to `Pending`, and the bug is locale-dependent. - -## Best Practice - -`OptionMembers = Open,Released,Pending;` and `OptionCaption = 'Open,Released,Pending';` — same count, same order. When adding a new member, update both lines in the same commit. - -See sample: [`optioncaption-required-and-matches-membercount.good.al`](optioncaption-required-and-matches-membercount.good.al). - -## Anti Pattern - -`OptionMembers = Open,Released,Pending;` with no `OptionCaption` at all (the user sees the raw English members and translation is impossible), or `OptionMembers = Low,Medium,High,Critical;` paired with `OptionCaption = 'Low,Medium,High';` — count mismatch, `Critical` displays as blank or carries the wrong caption depending on platform version. - -See sample: [`optioncaption-required-and-matches-membercount.bad.al`](optioncaption-required-and-matches-membercount.bad.al). diff --git a/microsoft/knowledge/style/single-space-after-not-operator.bad.al b/microsoft/knowledge/style/single-space-after-not-operator.bad.al deleted file mode 100644 index c7143e7..0000000 --- a/microsoft/knowledge/style/single-space-after-not-operator.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50233 "Sample Not Spacing Bad" -{ - procedure Check(): Boolean - var - Customer: Record Customer; - begin - if NOT Customer.IsEmpty() then - exit(true); - exit(false); - end; -} diff --git a/microsoft/knowledge/style/single-space-after-not-operator.good.al b/microsoft/knowledge/style/single-space-after-not-operator.good.al deleted file mode 100644 index 92d8f33..0000000 --- a/microsoft/knowledge/style/single-space-after-not-operator.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50232 "Sample Not Spacing Good" -{ - procedure Check(): Boolean - var - Customer: Record Customer; - begin - if not Customer.IsEmpty() then - exit(true); - exit(false); - end; -} diff --git a/microsoft/knowledge/style/single-space-after-not-operator.md b/microsoft/knowledge/style/single-space-after-not-operator.md deleted file mode 100644 index 41e65fe..0000000 --- a/microsoft/knowledge/style/single-space-after-not-operator.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [spacing, not, operator, aa0003, codecop] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Exactly one space between `not` and its argument (CodeCop AA0003) - -## Description - -CodeCop AA0003 requires exactly one space between the `not` operator and the expression it negates. `if not Customer.FindFirst() then …` is correct; `if not Customer.FindFirst() then …` (two spaces) and `if notCustomer.FindFirst() then …` (zero — which fails parsing anyway) are not. The rule is also the place where uppercase `NOT` is flagged in combination with CodeCop AA0241 (reserved keywords must be lowercase): `if NOT Condition then` is doubly wrong. - -## Best Practice - -`if not Condition then`, `if not Customer.IsEmpty() then`, `exit(not Result)`. One space, lowercase keyword, no parentheses around the bare boolean. - -See sample: [`single-space-after-not-operator.good.al`](single-space-after-not-operator.good.al). - -## Anti Pattern - -`if NOT condition then`, `if not condition then`, `if !condition then` (which is not even AL — `!` is not a negation operator in AL). All three either trip AA0003 / AA0241 or fail to compile. - -See sample: [`single-space-after-not-operator.bad.al`](single-space-after-not-operator.bad.al). diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.bad.al b/microsoft/knowledge/style/single-space-around-binary-operators.bad.al deleted file mode 100644 index 2515d33..0000000 --- a/microsoft/knowledge/style/single-space-around-binary-operators.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50229 "Sample Spaces Op Bad" -{ - procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal - var - Price: Decimal; - begin - Price:=Amount*Quantity; - if (Amount>0)and(Quantity>0) then - exit(Price); - exit(0); - end; -} diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.good.al b/microsoft/knowledge/style/single-space-around-binary-operators.good.al deleted file mode 100644 index 55793d3..0000000 --- a/microsoft/knowledge/style/single-space-around-binary-operators.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50228 "Sample Spaces Op Good" -{ - procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal - var - Price: Decimal; - begin - Price := Amount * Quantity; - if (Amount > 0) and (Quantity > 0) then - exit(Price); - exit(0); - end; -} diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.md b/microsoft/knowledge/style/single-space-around-binary-operators.md deleted file mode 100644 index 1ad0184..0000000 --- a/microsoft/knowledge/style/single-space-around-binary-operators.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [spacing, binary-operator, aa0001, codecop, formatting] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# One space on each side of every binary operator (CodeCop AA0001) - -## Description - -CodeCop AA0001 requires exactly one space on each side of every binary operator: assignment (`:=`), arithmetic (`+`, `-`, `*`, `/`, `mod`, `div`), comparison (`=`, `<>`, `<`, `<=`, `>`, `>=`), logical (`and`, `or`, `xor`), and string concatenation. `x:=1+2`, `Price:=Amount*Quantity`, `if a=b then`, and `if a and b then` all violate the rule. The rule applies to the binary use of `-` (subtraction); the unary minus (`-Profit`) takes no leading space. - -## Best Practice - -Write `x := 1 + 2`, `Price := Amount * Quantity`, `if a = b then`, `if a and b then`. The standard AL formatter inserts these spaces automatically; running `Alt+Shift+F` (Format Document) in the AL extension is the simplest way to bring an entire file into compliance. - -See sample: [`single-space-around-binary-operators.good.al`](single-space-around-binary-operators.good.al). - -## Anti Pattern - -`x:=1+2;`, `Price:=Amount*Quantity;`, `if a=b then`, `if a and b then`. All trip AA0001. - -See sample: [`single-space-around-binary-operators.bad.al`](single-space-around-binary-operators.bad.al). diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al deleted file mode 100644 index 7fd03a1..0000000 --- a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50215 "Sample This Bad" -{ - procedure ProcessRecord(Customer: Record Customer) - var - Helper: Codeunit "Sample This Helper"; - begin - ValidateCustomer(Customer); - Helper.DoWork(); - end; - - local procedure ValidateCustomer(Customer: Record Customer) - begin - end; -} diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al b/microsoft/knowledge/style/this-keyword-in-codeunits.good.al deleted file mode 100644 index 392c042..0000000 --- a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50214 "Sample This Good" -{ - procedure ProcessRecord(Customer: Record Customer) - var - Helper: Codeunit "Sample This Helper"; - begin - this.ValidateCustomer(Customer); - Helper.DoWork(this); - end; - - local procedure ValidateCustomer(Customer: Record Customer) - begin - end; -} diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.md b/microsoft/knowledge/style/this-keyword-in-codeunits.md deleted file mode 100644 index cb0a8a3..0000000 --- a/microsoft/knowledge/style/this-keyword-in-codeunits.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [25..] -domain: style -keywords: [this, codeunit, self-reference, aa0248, scope] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use the `this` keyword for self-reference inside codeunits (CodeCop AA0248) - -## Description - -CodeCop AA0248 recommends prefixing self-references inside a codeunit with `this`. `this.ValidateCustomer(Customer)` is unambiguous: the call resolves to a procedure on the current codeunit, not to a local variable or a procedure on a passed-in object. Without the prefix, a reader of a 200-line procedure has to scan the whole codeunit to confirm whether `ValidateCustomer` is local. `this` also makes it possible to pass the current codeunit as an argument — `SomeOtherCodeunit.DoWork(this)` — which is the only way to expose the running codeunit instance to a collaborator. The rule applies only to codeunits, not to pages, reports, queries, or tables — those object types do not have a `this` reference in AL. - -## Best Practice - -Inside a codeunit, prefix calls to procedures and accesses to global variables on the same codeunit with `this.`, and pass `this` when an external codeunit needs a reference to the running instance. - -See sample: [`this-keyword-in-codeunits.good.al`](this-keyword-in-codeunits.good.al). - -## Anti Pattern - -Calling a codeunit-local procedure as a bare identifier (`ValidateCustomer(Customer)`) when other readings are possible. The ambiguity costs reading time on every encounter and grows with codeunit size. - -See sample: [`this-keyword-in-codeunits.bad.al`](this-keyword-in-codeunits.bad.al). diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al b/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al deleted file mode 100644 index 3131fa6..0000000 --- a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50247 "Sample Var Order Bad" -{ - procedure Run() - var - CustomerNo: Code[20]; - TempBuffer: Record "Integer" temporary; - Amount: Decimal; - Customer: Record Customer; - IsValid: Boolean; - begin - IsValid := Customer.Get(CustomerNo); - end; -} diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al b/microsoft/knowledge/style/variable-declaration-order-by-type.good.al deleted file mode 100644 index 590ed25..0000000 --- a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50246 "Sample Var Order Good" -{ - procedure Run() - var - Customer: Record Customer; - TempBuffer: Record "Integer" temporary; - CustomerNo: Code[20]; - Amount: Decimal; - IsValid: Boolean; - begin - IsValid := Customer.Get(CustomerNo); - end; -} diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.md b/microsoft/knowledge/style/variable-declaration-order-by-type.md deleted file mode 100644 index a133e87..0000000 --- a/microsoft/knowledge/style/variable-declaration-order-by-type.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [variable-declaration, order, var, complex-types, aa0021] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Order variable declarations by type, complex types first (CodeCop AA0021) - -## Description - -CodeCop AA0021 requires that variable declarations inside a `var` block follow a fixed ordering by type, with complex (composite) types appearing before primitive types. The canonical order is `Record`, then `Report`, `Codeunit`, `XmlPort`, `Page`, `Query`, `Notification`, `BigText`, `DateFormula`, `RecordId`, `RecordRef`, `FieldRef`, `FilterPageBuilder`, then the simple types `Text`, `Code`, `Integer`, `Decimal`, `Boolean`, `Date`, `Time`, `DateTime`, `Char`, `Byte`. Inside each type group the variables can be alphabetical or in usage order. Temporary records still sort under `Record`. - -## Best Practice - -Declare all `Record` variables first, then other complex types, then primitives. A consistent order makes diffs review-friendly and matches the convention enforced by the AL formatter and CodeCop. - -See sample: [`variable-declaration-order-by-type.good.al`](variable-declaration-order-by-type.good.al). - -## Anti Pattern - -A `var` block where records and primitives are interleaved — `CustomerNo: Code[20];` between two `Record` variables, or `Amount: Decimal;` declared above the `Customer: Record Customer;` it is computed from. AA0021 flags it and the block is harder to scan; readers expect composite types at the top. - -See sample: [`variable-declaration-order-by-type.bad.al`](variable-declaration-order-by-type.bad.al). diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al b/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al deleted file mode 100644 index 65c8223..0000000 --- a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al +++ /dev/null @@ -1,19 +0,0 @@ -codeunit 50249 "Sample Shadow Bad" -{ - var - Customer: Record Customer; - - procedure ProcessSales() - var - Customer: Text; - Amount: Decimal; - begin - Customer := 'C-100'; - Amount := 0; - end; - - procedure Amount(): Decimal - begin - exit(0); - end; -} diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al b/microsoft/knowledge/style/variable-name-must-not-shadow.good.al deleted file mode 100644 index f5391ef..0000000 --- a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al +++ /dev/null @@ -1,19 +0,0 @@ -codeunit 50248 "Sample No Shadow Good" -{ - var - CustomerRec: Record Customer; - - procedure ProcessSales() - var - CustomerName: Text; - SalesAmount: Decimal; - begin - CustomerName := CustomerRec.Name; - SalesAmount := GetAmount(); - end; - - procedure GetAmount(): Decimal - begin - exit(0); - end; -} diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.md b/microsoft/knowledge/style/variable-name-must-not-shadow.md deleted file mode 100644 index 200cae2..0000000 --- a/microsoft/knowledge/style/variable-name-must-not-shadow.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [variable-name, shadow, conflict, aa0198, aa0202, aa0204, codecop] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Local variable names must not shadow globals, fields, methods, or actions (CodeCop AA0198/AA0202/AA0204) - -## Description - -Three CodeCop rules — AA0198, AA0202, AA0204 — together forbid a local variable from sharing a name with a global variable on the same object, with a field on the same table or page source, with a procedure on the same object, or with an action on the same page. The compiler resolves the conflict by binding the closer scope, so a local `Customer: Text` will silently override a global `Customer: Record Customer` for the duration of a procedure — every call site reading `Customer.Name` from inside that procedure refers to the text, and the breakage is invisible to a reader who has both declarations on screen. - -## Best Practice - -Differentiate every local declaration from globals, fields, procedures, and actions on the same object. `Customer` global plus `CustomerName` local; method `GetAmount` plus local `SalesAmount`. The standard pattern is to attach a noun suffix to the local (`CustomerName`, `CustomerRec`, `CustomerNo`) rather than to the global. - -See sample: [`variable-name-must-not-shadow.good.al`](variable-name-must-not-shadow.good.al). - -## Anti Pattern - -A procedure that declares a local `Customer: Text` inside a codeunit that already has a global `Customer: Record Customer`. The local wins and the global becomes unreachable inside the procedure. AA0198/AA0202/AA0204 flag this category of conflict whether the colliding entity is a global, a field, a method, or an action. - -See sample: [`variable-name-must-not-shadow.bad.al`](variable-name-must-not-shadow.bad.al). diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index 3ba30e4..ebbed36 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source and app metadata changes against the `appsource` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply when the review scope contains AppSourceCop configuration, AL object or extension-member names, or AppSource-facing `app.json` metadata. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply to AppSource-facing metadata and complete permission coverage that requires repository context. Mechanical AppSourceCop diagnostics are intentionally outside this skill. The skill returns `not-applicable` when none of those surfaces apply. ## Source @@ -37,19 +37,14 @@ Discard files that are not applicable. Retain conditionally applicable files (an Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: -- The changed files and AL object types — especially `app.json`, `AppSourceCop.json`, namespace declarations, permission-set objects, new objects, and table/page/report extensions that add fields, keys, controls, or actions to base objects. -- The changed object and member names, weighted toward prefix/suffix consistency with `mandatoryAffixes` or `mandatoryPrefix`, plus AppSource-facing help metadata. -- Tokens extracted from the diff that relate to AppSource (`AppSourceCop`, `mandatoryAffixes`, `mandatoryPrefix`, `AS0011`, `prefix`, `suffix`, `namespace`, `using`, `permissionset`, `Assignable`, `Permissions`, `SUPER`, `tableextension`, `pageextension`, `reportextension`, `field`, `key`, `control`, `action`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). +- The changed files and AL object types — especially `app.json`, permission-set objects, setup and usage entry points, and AppSource-facing help metadata. +- Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. -- Select exactly one naming-collision owner. When no namespace declaration is present, a new/renamed object lacks the reserved prefix/suffix, or an extension object adds an unaffixed member to a base object — `object-affixes-prevent-collisions`. -- For BC23 or later, use `two-level-namespace-replaces-object-affix-not-extension-member-affix` instead when the changed source actually declares or changes a namespace and relies on it as the owned-object affix alternative, but has fewer than two levels or incorrectly applies that exception to members on another publisher's object. Never worklist this article for an unaffixed source file with no namespace declaration. - The app has no assignable permission set covering its setup and usage paths, omits visible object/tabledata grants, or requires `SUPER` for normal operation — `permission-sets-cover-setup-and-usage-without-super`. Require repository-level app context; one isolated permission-set object cannot prove complete coverage. - -Before emitting an affix finding, compare every owned object name and every member added to another publisher's object against the configured `mandatoryAffixes`/`mandatoryPrefix`. A matching prefix or suffix is compliant. Do not flag an `ABC`-prefixed object or an `ABC`-suffixed extension member when `ABC` is the configured affix. - For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. @@ -66,13 +61,13 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` Set `confidence` to: -- `high` when the detection is based on an unambiguous pattern match (affix configuration/name or URL path depth). +- `high` when the detection is based on an unambiguous pattern match such as URL path depth. - `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. - `low` when the finding is an advisory derived only from applicability. After evaluating each worklist entry, also consider whether the diff exhibits an AppSource defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material AppSource defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AppSource; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the configured affix to one object or extension member, or replace a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example, replacing a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. @@ -80,7 +75,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable AppSource knowledge survived filtering. -- `not-applicable` — the diff touches no AppSource source, analyzer configuration, or app-metadata surface. +- `not-applicable` — the diff touches no AppSource permission or app-metadata surface. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. @@ -98,19 +93,19 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s }, "findings": [ { - "id": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md", + "id": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md", "severity": "major", - "message": "The tableextension adds an unaffixed Loyalty Points field to Customer, so it violates the configured AppSource affix and can collide with another extension.", + "message": "The app help URL is deeper than two path levels, so Copilot truncates it and may ground answers on unrelated sibling documentation.", "location": { - "file": "src/CustomerExt.TableExt.al", - "line": 8 + "file": "app.json", + "line": 12 }, "references": [ - { "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" } + { "path": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md" } ], "confidence": "high", "domain": "AppSource", - "suggested-code": "field(50100; \"Loyalty Points ABC\"; Integer)" + "suggested-code": "\"help\": \"https://contoso.com/docs/myapp\"" } ], "suppressed": [] diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 1ddd810..cf962ba 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-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, tables, and table extensions that expose procedures, fields, or events to other apps, and any member whose access is being widened. - The changed procedures, fields, and triggers, weighted toward non-`local` procedures, published table fields, event publishers, and any member whose signature, access modifier, or obsolete state is being altered. -- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`, `namespace`, `using`, `AS0007`). +- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`). 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. @@ -50,8 +50,7 @@ The following targeted checks cover every current `breaking-changes` article: - A published procedure changes parameter count/order/type/name, `var`, return type, or array shape instead of preserving the old signature and adding an overload — `do-not-change-published-procedure-signatures`. - A public procedure/event/interface exposes a credential or other sensitive value through `Text` or an externally callable contract — `do-not-expose-sensitive-data-through-public-api`. - Code already marked obsolete is expanded with new behavior instead of routing new callers to its replacement — `do-not-modify-code-already-marked-obsolete`. -- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data — `obsolete-table-fields-instead-of-deleting-them`. This owns AS0005 field-name changes; do not substitute the namespace article. -- A published object's namespace changes between the base and changed source while its identity otherwise remains — `namespace-is-part-of-published-object-identity`. Do not apply it to a new, unshipped object or to an ordinary object-name change with no namespace change. +- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data — `obsolete-table-fields-instead-of-deleting-them`. For `obsolete-table-fields-instead-of-deleting-them`, compare the baseline ID and name before emitting. When the original field remains under the same ID and name with `ObsoleteState = Pending`, and the replacement uses a new ID, the change follows the rule and must not be flagged. diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 223fbbd..6c8e63c 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `style` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -Style findings cover AL conventions that CodeCop and similar analyzers partially enforce — label suffixes, API page naming, temporary-variable prefixes, label properties, named invocations, `FieldCaption`/`TableCaption` in user messages, `OptionCaption` pairing, Error-parameter passing, `this` keyword, required parentheses, file-naming. Use together with a formal analyzer; this skill adds BCQuality's remedial-knowledge explanations of why each rule exists. +Style findings cover AL conventions that require contextual judgment — API page naming, temporary-variable prefixes, label semantics, named invocations, `FieldCaption`/`TableCaption` in user messages, error-parameter handling, and file naming. Mechanical compiler and analyzer rules are intentionally outside this skill; run the consuming app's configured analyzers separately. An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. @@ -40,8 +40,8 @@ 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. For each relevant file, compute overlap against: - Changed AL objects — especially API pages (`PageType = API`), tables and pages declaring Labels/TextConsts, codeunits issuing `Error`/`Message`/`Confirm`, and any file whose name violates the `..al` convention. -- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, option fields, error-handling call sites, and codeunit-internal method calls. -- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `OptionMembers`, `OptionCaption`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`). +- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, error-handling call sites, and API declarations. +- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `StrSubstNo`). 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 or declaration. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -50,8 +50,6 @@ Do not worklist `temporary-variable-temp-prefix.md` for an event publisher param 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. -- `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)`. - Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. When the post-conflict worklist is empty because no applicable style knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable style knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. @@ -60,7 +58,7 @@ When the post-conflict worklist is empty because no applicable style knowledge e For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Style findings rarely reach `blocker` — reserve it for cases where the knowledge file documents a platform-level requirement (for example, API page property constraints the OData runtime rejects). Most style findings are `minor` or `info`; egregious misuse (`Error` with pre-built Text losing translation and telemetry classification) may reach `major`. -Severity calibration — a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, variable-declaration order by type AA0021, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch — lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, or a misleading named invocation. A procedure-local `Label` is valid and is not a correctness or localization finding; an explicit repository preference for object scope is at most low-severity maintainability guidance. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review. +Severity calibration — reserve `minor` for style issues with concrete downstream impact that deterministic tooling does not establish, such as lost translation or telemetry classification from a string-built `Error` or a misleading named invocation. A procedure-local `Label` is valid and is not a correctness or localization finding; an explicit repository preference for object scope is at most low-severity maintainability guidance. Do not rediscover or report mechanical compiler or analyzer diagnostics, even at `info`. Set `confidence` to: @@ -70,7 +68,7 @@ Set `confidence` to: After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on — steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style — naming, labelling, formatting, and analyzer-adjacent conventions. A correctness, logic, data-integrity, or contract defect is NOT a style finding even when it can be reworded as a convention: a method that mutates a shared `Record`'s filters, an unfiltered `DeleteAll`, a violated interface contract, or a wrong boolean guard are behavioural defects, not conventions — do not emit them here under a style framing. If a specific domain leaf covers the concern (performance, security, error-handling, …) it belongs there; if no knowledge file in any domain covers it, it belongs to the `al-code-review` super-skill's cross-cutting self-review agent channel (`from-sub-skill: "agent"`, `severity` capped at `minor`), not to this leaf. A reliable test: if you cannot cite a style `## Best Practice`/`## Anti Pattern` for the concern, it is very likely not a style finding. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. -For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. +For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add a missing contextual `ToolTip`, replace a string-concatenated `Error` with a Label-backed call, or correct an API naming property whose intended value is clear). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract. @@ -91,20 +89,20 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s "skill": { "id": "al-style-review", "version": 1 }, "outcome": "completed", "summary": { - "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 1 }, + "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 }, "coverage": { "worklist-size": 1, "items-evaluated": 1 } }, "findings": [ { - "id": "microsoft/knowledge/style/label-suffix-approved-list.md", - "severity": "info", - "message": "A Label named Text000 has no approved suffix (Msg/Err/Qst/Tok/Lbl/Txt). Per the referenced CodeCop AA0074 guidance, every Label and TextConst carries a suffix indicating its consuming call.", + "id": "microsoft/knowledge/style/label-comment-explains-placeholders.md", + "severity": "minor", + "message": "The label has two ambiguous placeholders but no Comment explaining what each value represents to translators.", "location": { "file": "src/Sales/PostingRoutines.Codeunit.al", "line": 42 }, "references": [ - { "path": "microsoft/knowledge/style/label-suffix-approved-list.md" } + { "path": "microsoft/knowledge/style/label-comment-explains-placeholders.md" } ], "confidence": "high", "domain": "Style" From 51597068b1bc2ac6dda10d3bb1103b0d7da4f4bd Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 11 Sep 2026 12:35:31 +0200 Subject: [PATCH 07/51] Add bounded knowledge retrieval (#179) * Add bounded knowledge retrieval Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d8b7764-f15a-49ea-8d50-d9147334f8af * Fix bc-version overflow and pathless-row identity in bounded retrieval Catalog matching compared an Int32 -BCVersion against a bigint range bound. PowerShell coerces the right operand to the left operand's type, so a bound wider than Int32 threw a conversion error and failed the whole domain catalog rather than the single row. Metadata validation already accepts such bounds, so compare as bigint on both sides. The shared pager built its oversized-row message with $row.path, which throws under Set-StrictMode -Version Latest when a row carries no path, replacing the explicit bound failure with a property-lookup error. Resolve the path defensively for dictionary and object rows so the offset-based fallback is reachable. Both paths gain regression coverage that fails without these fixes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d8b7764-f15a-49ea-8d50-d9147334f8af --- .github/scripts/Test-KnowledgeIndex.ps1 | 3 + .../skills/review/al-appsource-review.md | 2 +- .../review/al-breaking-changes-review.md | 2 +- microsoft/skills/review/al-code-review.md | 14 +- .../skills/review/al-data-modeling-review.md | 2 +- .../skills/review/al-error-handling-review.md | 2 +- microsoft/skills/review/al-events-review.md | 2 +- .../skills/review/al-interfaces-review.md | 2 +- .../skills/review/al-performance-review.md | 2 +- microsoft/skills/review/al-privacy-review.md | 2 +- microsoft/skills/review/al-query-review.md | 2 +- microsoft/skills/review/al-security-review.md | 2 +- microsoft/skills/review/al-style-review.md | 2 +- .../skills/review/al-telemetry-review.md | 2 +- microsoft/skills/review/al-testing-review.md | 2 +- microsoft/skills/review/al-ui-review.md | 2 +- microsoft/skills/review/al-upgrade-review.md | 2 +- .../skills/review/al-web-services-review.md | 2 +- skills/do.md | 30 + skills/read.md | 55 ++ tools/Bounded-Results.ps1 | 117 +++ tools/Build-KnowledgeIndex.ps1 | 178 +++- tools/Get-KnowledgeArticles.ps1 | 187 +++++ tools/Knowledge-Retrieval.ps1 | 298 +++++++ tools/Search-Knowledge.ps1 | 244 ++++++ tools/Test-KnowledgeRetrieval.ps1 | 788 ++++++++++++++++++ 26 files changed, 1891 insertions(+), 55 deletions(-) create mode 100644 tools/Bounded-Results.ps1 create mode 100644 tools/Get-KnowledgeArticles.ps1 create mode 100644 tools/Knowledge-Retrieval.ps1 create mode 100644 tools/Search-Knowledge.ps1 create mode 100644 tools/Test-KnowledgeRetrieval.ps1 diff --git a/.github/scripts/Test-KnowledgeIndex.ps1 b/.github/scripts/Test-KnowledgeIndex.ps1 index 76896af..1e61064 100644 --- a/.github/scripts/Test-KnowledgeIndex.ps1 +++ b/.github/scripts/Test-KnowledgeIndex.ps1 @@ -16,6 +16,8 @@ 3. Selection-input integrity — every parsed article row carries the non-empty `domain` + `keywords` the worklist predicate selects on, and every article parses (an unparseable article is an invalid file). + 4. Bounded retrieval — delegates to tools/Test-KnowledgeRetrieval.ps1 for + lossless paging, exact-body round trips, and explicit failure cases. Exit code 0 = healthy; non-zero = a problem CI must block on. #> @@ -90,4 +92,5 @@ if ($problems.Count) { exit 1 } Write-Host "Knowledge-index check PASSED: $($rows.Count) articles, deterministic, full coverage, selection inputs intact." -ForegroundColor Green +& (Join-Path $Root 'tools/Test-KnowledgeRetrieval.ps1') -Root $Root exit 0 diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index ebbed36..c851ea2 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `appsource` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/appsource/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain appsource`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index cf962ba..7f30713 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `breaking-changes` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/breaking-changes/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain breaking-changes`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 9972aca..6a577ba 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -65,7 +65,10 @@ The worklist is the list of sub-skills judged relevant by the previous step. Eve The Action step consists of **discrete leaf invocations**, not one combined generation. Invocation scheduling belongs to the orchestrator: independent leaves may run serially or concurrently, but their evaluation contexts and findings-reports remain isolated. Concretely this means: -- **Isolate leaf invocations when the host supports it.** For fast/small models, each sub-skill SHOULD run in a fresh model call or child context containing only the task input, READ/DO contracts, the leaf instructions, a domain-filtered slice of the current knowledge index, and articles that leaf worklists. Preserve each index row's exact `path`; the leaf must copy references from that slice. The coordinator then collects the resulting JSON. This is the preferred fast-model profile: it bounds context, prevents later leaves from being skipped as attention is exhausted, and removes any reason to synthesize article paths. +- **Isolate leaf invocations when the host supports it.** Each sub-skill SHOULD run in a fresh model call or child context containing only its assigned source paths, READ/DO contracts, the leaf instructions, the complete bounded domain catalog per READ, and articles that leaf worklists. Preserve each catalog row's exact `path`; the leaf must copy references from that catalog. +- **Keep run artifacts private.** Before dispatch, allocate a new GUID-named directory under the current session's artifact directory and a distinct scratch/report child directory for every leaf. Pass a leaf only its own assigned source paths and child directory, never the run root or sibling paths. A leaf MUST NOT discover, enumerate, read, modify, or delete sibling artifacts. Do not reuse a prior run directory, and do not clean up any run artifact until every leaf has finished and consolidation is complete. +- **Use the exact Task return as the report.** Capture each leaf's exact return as the primary transport and apply DO's consumer acceptance gate before rollup. Worker-side persistence of the same report in its private directory is optional and redundant; a missing report file does not invalidate an otherwise valid exact return. +- **Treat automatic output spills as host-owned.** If the host reports that a Task return was automatically spilled, the coordinator MAY read that file read-only only at the exact path returned by the tool. Never modify, delete, enumerate around, or reuse an automatic spill path. Never bypass a content-exclusion or access denial. - Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source → Relevance → Worklist → Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report independently. - Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches). - The agent self-review pass is its own final iteration. Begin it only after every sub-skill in the worklist has completed and its sub-result is recorded. @@ -77,8 +80,8 @@ The Action step consists of **discrete leaf invocations**, not one combined gene For each sub-skill in the worklist: 1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`. -2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`. -3. If the sub-skill's `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`. +2. Capture the exact Task return and validate it against DO's consumer acceptance gate before accepting it. Preserve an invalid raw return unchanged in the leaf's private artifacts or host log; do not reconstruct or repair it. Record a separate failed validation result with no findings for rollup. +3. Append the accepted findings-report, or the separate failed validation result, to `sub-results`. If its `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`. 4. Otherwise, compare each entry from the sub-skill's `findings[]` with findings already rolled up. Two findings are duplicates when they point to the same file and overlapping line/range and prescribe materially the same correction, even when their knowledge-file IDs differ. Merge duplicates instead of appending both: keep the more specific domain owner, preserve that finding's optional `domain` field verbatim (including its absence), use its reference as `references[0]` and therefore as `id`, append the other references as supporting references, keep the highest severity and confidence justified by either report, and preserve one self-contained message. Article and leaf ownership notes decide specificity; do not choose by execution order. 5. Append each non-duplicate finding, setting `from-sub-skill` to the sub-skill's `skill.id` and preserving its optional `domain` field verbatim, including its absence. For non-citation findings (those whose `id` is a skill-defined slug rather than a reference path), prefix `id` with `:` to prevent collisions across sub-skills. Other finding fields are preserved. @@ -122,7 +125,10 @@ Calculate `summary.counts` from the final top-level `findings[]`, after failed s Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."* -Before emitting the rollup, apply DO's reference-integrity gate to every nested and top-level finding. Every knowledge-backed ID/reference path must exist in the live checkout, must have been opened by the producing leaf, and must be copied verbatim rather than synthesized. Treat a sub-result containing an unverifiable citation as failed and exclude its findings from the top-level rollup. +Before emitting the rollup, apply DO's consumer acceptance gate to every nested +and top-level finding. Treat an invalid sub-result as failed and exclude all of +its findings from the top-level rollup. Preserve its exact raw payload +separately; never reconstruct it into a success-shaped report. ## Output diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 01a983a..05dcd0b 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `data-modeling` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/data-modeling/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain data-modeling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 63c4d5e..56bc0fe 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `error-handling` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/error-handling/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain error-handling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index 559d036..68bcdb0 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `events` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/events/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain events`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md index 885cd0b..f5d65cd 100644 --- a/microsoft/skills/review/al-interfaces-review.md +++ b/microsoft/skills/review/al-interfaces-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `interfaces` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/interfaces/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain interfaces`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index f2fa80d..664f20d 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `performance` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/performance/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain performance`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 469000c..17b4e7b 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `privacy` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/privacy/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain privacy`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md index 3681b23..c52ddd8 100644 --- a/microsoft/skills/review/al-query-review.md +++ b/microsoft/skills/review/al-query-review.md @@ -18,7 +18,7 @@ Reviews AL source changes against the `query` knowledge domain in BCQuality. Thi ## Source -Read `knowledge-index.json` once and take entries whose `domain` is `query` across enabled layers. Open an article body only after it enters the Worklist. If the index is unavailable, discover `*/knowledge/query/*.md` by path. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain query`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index e3e4049..00e8d10 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `security` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/security/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain security`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 6c8e63c..2703c87 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -22,7 +22,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `style` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/style/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain style`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md index 4b50a9e..1c2046d 100644 --- a/microsoft/skills/review/al-telemetry-review.md +++ b/microsoft/skills/review/al-telemetry-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `telemetry` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/telemetry/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain telemetry`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index ed50c46..c96ac83 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `testing` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/testing/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain testing`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index c04a30a..8ffd731 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -22,7 +22,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `ui` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/ui/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain ui`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 2bb1877..94851a3 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `upgrade` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/upgrade/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain upgrade`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index e818e46..19d0735 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -20,7 +20,7 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat ## Source -Read the BCQuality knowledge index once — the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone — see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint — exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `web-services` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/web-services/**`. +Use READ's **Bounded retrieval for review skills** workflow with `-Domain web-services`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. ## Relevance diff --git a/skills/do.md b/skills/do.md index 4ac433b..f86509b 100644 --- a/skills/do.md +++ b/skills/do.md @@ -159,6 +159,36 @@ The emitted document MUST be strict, valid JSON per [RFC 8259](https://www.rfc-e AL source is the common failure case. Quoted identifiers (for example `Rec."No."`) and multi-line snippets routinely appear in `message`, `suggested-code`, and `suggested-code-omission-reason`, and each embedded quote or newline MUST be escaped when placed in a string value. A `suggested-code` payload that spans several lines is a single JSON string with `\n` separators, not a literal multi-line block. Emit the document as one JSON value with no trailing commentary, and do not rely on the consumer to repair unescaped output. +### Consumer acceptance gate + +The exact action-skill return is the primary report transport. Before accepting +it as a findings-report, a coordinator or host MUST validate it +deterministically: + +1. Parse the exact return as strict JSON and validate every required field, + enum, type, conditional requirement, summary count, coverage value, and + leaf/super-skill constraint against this output contract. +2. For every knowledge-backed finding, verify each `references[].path` is an + exact repo-relative knowledge path that exists in the live BCQuality + snapshot, and verify `findings[].id` exactly equals + `references[0].path`. Verify each path is also present in the coordinator's + recorded set of complete article bodies retrieved for that leaf; catalog + membership alone is insufficient. Keep optional `references[].sha` + separate: it is commit provenance, not an article content hash. +3. For every `location`, verify `file` is an exact source path in the supplied + review scope, the file exists in that source snapshot, and `line` and any + inclusive range identify existing lines with `start-line == line` and + `end-line >= start-line`. + +Validation failure invalidates the complete return; consumers MUST NOT salvage +individual findings, infer missing fields, reconstruct JSON, clamp ranges, +rewrite paths, or otherwise silently repair model output. Preserve the invalid +raw payload unchanged in private run artifacts or host logs. Record a separate +failed validation result for that leaf with no findings, and derive the +super-skill outcome as `partial` or `failed` using the normal rollup rules. +Worker-side report-file persistence is optional and never replaces validation +of the exact return. + ### Field semantics **`outcome`** (required) — diff --git a/skills/read.md b/skills/read.md index f2e9c1e..dddd7db 100644 --- a/skills/read.md +++ b/skills/read.md @@ -149,3 +149,58 @@ The standard workflow for finding applicable files: 4. Resolve conflicts via layer precedence. Steps 1–3 are deterministic; step 4 is applied only when conflicts are detected. + +### Bounded retrieval for review skills + +Resolve `$root` to the BCQuality root, not the reviewed source. Entry prepares +the index once before dispatch; that prepared index is the catalog snapshot and +leaves use it read-only. Catalog retrieval validates the complete index metadata +and returned paths without reopening or rehashing article bodies. Post-Entry +body changes therefore take effect only after Entry rebuilds the index; exact +body retrieval rejects a selected article whose content hash differs from its +prepared row. In one PowerShell tool session, invoke the helpers with `&` so +array arguments remain arrays: + +```powershell +& (Join-Path $root 'tools\Search-Knowledge.ps1') -Domain $domain -Technologies @('al') +& (Join-Path $root 'tools\Get-KnowledgeArticles.ps1') -Paths @($exactPath) +``` + +Pass enabled layers and only task dimensions that are actually known. Catalog +retrieval returns every domain and READ-applicable row: it does not rank, +sample, apply top-k, deduplicate by basename, or omit rows based on query text. +Consume every page by passing `continuation.offset` as `-Offset` and +`continuation.snapshot` as `-Snapshot` with the unchanged request until +`complete` is `true`. Each page repeats request context, defaults, and totals. +An omitted applicability field on a row inherits that page's `defaults`; it +does not mean unknown task context. Preserve every row's exact `path`, `layer`, +complete `keywords`, `title`, one-line `description`, non-default applicability +fields, explicit `applicability`, and `unknownDimensions`. + +Apply the leaf's existing Relevance and Worklist to the complete catalog union. +Split the resulting exact paths into stable chunks of at most eight; never pass +more paths than `-MaxArticles` (whose maximum is eight). Request article bodies +only by one such chunk. Consume every +returned `body`, then request `remainingPaths` with +`continuation.snapshot` as `-Snapshot` until `complete` is `true`, preserving +the other request settings. Continuation is confined to that chunk. Bodies are +original strict UTF-8 text with source byte counts and SHA-256 content hashes; +they are never summarized or truncated. Samples are not loaded unless +requested explicitly with `-Samples` and exact sibling paths; their sibling +article must match its prepared hash and contain the exact READ link. + +The default serialized response limit is 16,000 bytes including its output +newline. Never combine pages or bodies into an unbounded prompt. A malformed or +internally inconsistent prepared index, changed continuation snapshot, selected +article hash mismatch, invalid continuation, unsafe or missing path, invalid +UTF-8, broken sample link, oversized path chunk, or row/envelope that cannot fit +fails explicitly. Entry is the only index preparation point: a leaf does not +rebuild. If PowerShell, a helper, or a valid prepared index is unavailable, +discover exact paths across the enabled domain folders and use native bounded +reads through EOF, validating frontmatter per READ and never treating retrieval +failure as an empty result. + +The helpers' `sha256` and `bytes` fields describe the retrieved file content. +They are not citation provenance. Optional findings `references[].sha` is the +BCQuality commit SHA the skill reviewed; omit it when that provenance is not +available or would misrepresent uncommitted content. diff --git a/tools/Bounded-Results.ps1 b/tools/Bounded-Results.ps1 new file mode 100644 index 0000000..6def944 --- /dev/null +++ b/tools/Bounded-Results.ps1 @@ -0,0 +1,117 @@ +# Shared deterministic paging. Callers build the complete immutable result first. +#requires -Version 7.2 +Set-StrictMode -Version Latest + +function Get-ResultSnapshot { + param([Parameter(Mandatory)] $Value) + + $json = ConvertTo-Json -InputObject $Value -Depth 30 -Compress + return [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($json)) + ).ToLowerInvariant() +} + +function Get-SerializedByteCount { + param([Parameter(Mandatory)] [string] $Json) + + # PowerShell writes one platform newline after the returned JSON string. + return [Text.Encoding]::UTF8.GetByteCount($Json) + + [Text.Encoding]::UTF8.GetByteCount([Environment]::NewLine) +} + +function ConvertTo-BoundedPage { + param( + [Parameter(Mandatory)] [Collections.IDictionary] $Header, + [Parameter(Mandatory)] [Collections.IDictionary] $Groups, + [ValidateRange(0, 2147483647)] [int] $Offset = 0, + [string] $Snapshot, + [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000 + ) + + $total = 0 + foreach ($name in $Groups.Keys) { + $total += $Groups[$name].Count + } + if (($total -eq 0 -and $Offset -ne 0) -or ($total -gt 0 -and $Offset -ge $total)) { + throw "Invalid Offset=$Offset for totalCount=$total; no rows were returned." + } + if ($Offset -gt 0 -and -not $Snapshot) { + throw 'Continuation requires Snapshot from the preceding page.' + } + if ($Snapshot -and $Snapshot -cne $Header.snapshot) { + throw 'Snapshot changed or continuation belongs to another request. Discard partial results and restart at Offset=0.' + } + + $page = [ordered]@{} + foreach ($key in $Header.Keys) { + $page[$key] = $Header[$key] + } + $page.offset = $Offset + $page.returnedCount = 0 + $page.totalCount = $total + $page.remainingCount = $total - $Offset + $page.complete = ($total -eq 0) + $page.continuation = if ($total) { + [ordered]@{ offset = $Offset; snapshot = $Header.snapshot } + } + else { + $null + } + foreach ($name in $Groups.Keys) { + $page[$name] = [Collections.Generic.List[object]]::new() + } + + $json = ConvertTo-Json -InputObject $page -Depth 30 -Compress + if ((Get-SerializedByteCount -Json $json) -gt $MaxBytes) { + throw "Page envelope exceeds MaxBytes=$MaxBytes. Use READ's path-discovery fallback; never truncate." + } + + $position = 0 + foreach ($name in $Groups.Keys) { + foreach ($row in $Groups[$name]) { + if ($position++ -lt $Offset) { + continue + } + + $page[$name].Add($row) + $page.returnedCount++ + $page.remainingCount-- + $page.complete = ($page.remainingCount -eq 0) + $page.continuation = if ($page.complete) { + $null + } + else { + [ordered]@{ + offset = $Offset + $page.returnedCount + snapshot = $Header.snapshot + } + } + + $next = ConvertTo-Json -InputObject $page -Depth 30 -Compress + if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) { + $page[$name].RemoveAt($page[$name].Count - 1) + $page.returnedCount-- + $page.remainingCount++ + $page.complete = $false + $page.continuation = [ordered]@{ + offset = $Offset + $page.returnedCount + snapshot = $Header.snapshot + } + if ($page.returnedCount -eq 0) { + $rowPath = $null + if ($row -is [Collections.IDictionary]) { + if ($row.Contains('path')) { $rowPath = $row['path'] } + } + elseif ($null -ne $row -and $row.PSObject.Properties['path']) { + $rowPath = $row.PSObject.Properties['path'].Value + } + $identity = if ($rowPath) { " at $rowPath" } else { " at Offset=$Offset" } + throw "One complete $name row plus envelope exceeds MaxBytes=$MaxBytes$identity. No row was clipped." + } + return $json + } + $json = $next + } + } + return $json +} diff --git a/tools/Build-KnowledgeIndex.ps1 b/tools/Build-KnowledgeIndex.ps1 index 5835e5d..d246ff3 100644 --- a/tools/Build-KnowledgeIndex.ps1 +++ b/tools/Build-KnowledgeIndex.ps1 @@ -30,6 +30,8 @@ expected to prune its clone to policy first). For provenance and to reproduce a consumer's exact view, pass -EnabledLayers to restrict the walk to those layers and to record the policy in the index header. + Invalid articles are omitted with a path-specific warning so one bad + optional layer article cannot block valid siblings. .PARAMETER BCQualityRoot Path to the BCQuality content root to index (typically a filtered clone). @@ -69,6 +71,17 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1') + +if ($PSBoundParameters.ContainsKey('EnabledLayers')) { + if ($null -eq $EnabledLayers) { + throw 'EnabledLayers must be an array; omit it to index all layers.' + } + if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or + @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) { + throw 'EnabledLayers must contain unique canonical lowercase layer names.' + } +} # Default to the clone root (parent of this script's tools/ folder) so the # agent's Entry preparation step can invoke this with no arguments from the @@ -93,6 +106,45 @@ function Get-RelativePath { return ($rel -replace '\\', '/') } +function Get-BytesSha256 { + param([byte[]] $Bytes) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } +} + +function Read-ArticleSource { + param([string] $Path) + $bytes = [IO.File]::ReadAllBytes($Path) + try { + $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + } + catch [Text.DecoderFallbackException] { + throw [IO.InvalidDataException]::new('invalid UTF-8', $_.Exception) + } + return [pscustomobject]@{ + bytes = $bytes + text = $text + sha256 = Get-BytesSha256 -Bytes $bytes + } +} + +function Get-ValueSha256 { + param([Parameter(Mandatory)] $Value) + $bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $Value -Depth 8 -Compress)) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } +} + # Trims a Description to a single short line (<= $Max chars) for the lean # index. Takes the first sentence; truncates on a word boundary if still long. function Get-LeanDescription { @@ -116,9 +168,12 @@ function ConvertFrom-ArticleFrontmatter { # Pattern) is included; the index is a lossless substitute for the # frontmatter + Description the worklist predicate reads, not a # substitute for the article's normative guidance. - param([string] $Path) + param( + [string] $Path, + [string] $Text + ) - $lines = Get-Content -LiteralPath $Path -ErrorAction Stop + $lines = [regex]::Split($Text.TrimStart([char]0xfeff), '\r\n|\n|\r') # Frontmatter is the first '---'-delimited block. if ($lines.Count -lt 1 -or $lines[0].Trim() -ne '---') { return $null } @@ -129,19 +184,42 @@ function ConvertFrom-ArticleFrontmatter { if ($fmEnd -lt 0) { return $null } $fm = @{} + $arrayFields = @('bc-version', 'keywords', 'technologies', 'countries', 'application-area') for ($i = 1; $i -lt $fmEnd; $i++) { $line = $lines[$i] if ($line -match '^\s*([a-zA-Z][\w-]*)\s*:\s*(.*)$') { $key = $Matches[1] $val = $Matches[2].Trim() - if ($val -match '^\[(.*)\]$') { + if ($key -in $arrayFields) { + if ($val -notmatch '^\[(.*)\]$') { + throw [IO.InvalidDataException]::new( + "frontmatter field '$key' must use non-empty bracket-array syntax" + ) + } $inner = $Matches[1].Trim() - if ($inner -eq '') { $fm[$key] = @() } - else { $fm[$key] = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() }) } + if ($inner -eq '') { + throw [IO.InvalidDataException]::new( + "frontmatter field '$key' must use non-empty bracket-array syntax" + ) + } + $values = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() }) + if (@($values | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { + throw [IO.InvalidDataException]::new( + "frontmatter field '$key' must use non-empty bracket-array syntax" + ) + } + $fm[$key] = $values } elseif ($val -ne '') { $fm[$key] = $val } } } + foreach ($field in $arrayFields) { + if (-not $fm.ContainsKey($field) -or $fm[$field] -isnot [array] -or -not $fm[$field].Count) { + throw [IO.InvalidDataException]::new( + "frontmatter field '$field' must use non-empty bracket-array syntax" + ) + } + } # Body parsing: H1 title and the full Description section. The Description # is the article's primary retrieval target per READ and is captured @@ -185,42 +263,71 @@ $indexArticles = [System.Collections.Generic.List[object]]::new() foreach ($layerDir in @('microsoft', 'community', 'custom')) { $kbRoot = Join-Path $BCQualityRoot (Join-Path $layerDir 'knowledge') if (-not (Test-Path $kbRoot)) { continue } - if ($EnabledLayers -and ($EnabledLayers -notcontains $layerDir)) { continue } + if ($EnabledLayers -and ($EnabledLayers -cnotcontains $layerDir)) { continue } - Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | - Sort-Object FullName | - ForEach-Object { - $rel = Get-RelativePath -Root $BCQualityRoot -Full $_.FullName - $parsed = $null - try { $parsed = ConvertFrom-ArticleFrontmatter -Path $_.FullName } catch { $parsed = $null } + $files = @( + Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + Sort-Object FullName + ) + foreach ($file in $files) { + $rel = Get-RelativePath -Root $BCQualityRoot -Full $file.FullName + try { + $source = Read-ArticleSource -Path $file.FullName + $parsed = ConvertFrom-ArticleFrontmatter -Path $file.FullName -Text $source.text if (-not $parsed) { - # Invalid/unparseable file: list path + domain-from-path so it - # is never silently dropped from discovery. Consumers fall back - # to reading it in full. - $domainFromPath = if ($rel -match '/knowledge/([^/]+)/') { $Matches[1] } else { '' } - $indexArticles.Add([pscustomobject]@{ - path = $rel; layer = $layerDir; domain = $domainFromPath - 'bc-version' = @(); technologies = @(); countries = @(); 'application-area' = @() - keywords = @(); title = ''; description = ''; parsed = $false - }) | Out-Null - return + throw [IO.InvalidDataException]::new('missing or unterminated frontmatter') + } + foreach ($required in @( + @('domain', $parsed.domain), + @('H1 title', $parsed.title), + @('Description', $parsed.description) + )) { + if ([string]::IsNullOrWhiteSpace([string]$required[1])) { + throw [IO.InvalidDataException]::new("missing $($required[0])") + } } - $indexArticles.Add([pscustomobject]@{ - path = $rel - layer = $layerDir - domain = $parsed.domain - 'bc-version' = @($parsed.'bc-version') - technologies = @($parsed.technologies) - countries = @($parsed.countries) - 'application-area' = @($parsed.'application-area') - keywords = @($parsed.keywords) - title = $parsed.title - description = if ($FullIndex) { $parsed.description } else { Get-LeanDescription -Text $parsed.description } - parsed = $true - }) | Out-Null } + catch [IO.InvalidDataException] { + Write-Warning "Skipping invalid knowledge article '$rel': $($_.Exception.Message)." + continue + } + + $article = [ordered]@{ + path = $rel + layer = $layerDir + domain = $parsed.domain + 'bc-version' = @($parsed.'bc-version') + technologies = @($parsed.technologies) + countries = @($parsed.countries) + 'application-area' = @($parsed.'application-area') + keywords = @($parsed.keywords) + title = $parsed.title + description = if ($FullIndex) { $parsed.description } else { Get-LeanDescription -Text $parsed.description } + parsed = $true + sourceSha256 = $source.sha256 + } + $problem = Get-KnowledgeMetadataProblem -Row $article + if ($problem) { + Write-Warning "Skipping invalid knowledge article '$rel': $problem." + continue + } + $indexArticles.Add($article) | Out-Null + } } +$articlesByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +foreach ($article in $indexArticles) { + if (-not $articlesByPath.TryAdd($article.path, $article)) { + throw "Duplicate knowledge path while building source snapshot: $($article.path)" + } +} +$sourcePaths = [string[]]@($articlesByPath.Keys) +[Array]::Sort($sourcePaths, [StringComparer]::Ordinal) +$sourceManifest = @( + foreach ($path in $sourcePaths) { + [ordered]@{ path = $path; sha256 = $articlesByPath[$path].sourceSha256 } + } +) $index = [pscustomobject]@{ version = 1 generatedAt = (Get-Date).ToUniversalTime().ToString('o') @@ -228,6 +335,7 @@ $index = [pscustomobject]@{ knowledgeAllow= @($KnowledgeAllow) knowledgeDeny = @($KnowledgeDeny) articleCount = $indexArticles.Count + sourceSnapshot= Get-ValueSha256 -Value $sourceManifest articles = @($indexArticles) } diff --git a/tools/Get-KnowledgeArticles.ps1 b/tools/Get-KnowledgeArticles.ps1 new file mode 100644 index 0000000..cdd112a --- /dev/null +++ b/tools/Get-KnowledgeArticles.ps1 @@ -0,0 +1,187 @@ +<# +.SYNOPSIS + Reads a bounded prefix of exact article or sample paths without altering bodies. +.DESCRIPTION + The UTF-8 byte size bound covers the complete serialized JSON plus its output + newline. A body that cannot fit fails explicitly; it is never summarized or + truncated. Samples are loaded only with -Samples and must be linked by their + sibling article using READ's exact link convention. +#> +#requires -Version 7.2 +[CmdletBinding()] +param( + [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent), + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $Paths, + [ValidateRange(1, 8)] [int] $MaxArticles = 8, + [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000, + [ValidateSet('microsoft', 'community', 'custom')] + [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'), + [string] $IndexPath, + [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot, + [switch] $Samples +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1') +. (Join-Path $PSScriptRoot 'Bounded-Results.ps1') + +$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot +if (-not $IndexPath) { + $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json' +} +if ($Paths.Count -gt $MaxArticles) { + throw "Paths count $($Paths.Count) exceeds MaxArticles=$MaxArticles. Split the worklist into stable chunks of at most $MaxArticles exact paths." +} +if ($null -eq $EnabledLayers) { + throw 'EnabledLayers must be an array.' +} +if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or + @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) { + throw 'EnabledLayers must contain unique canonical lowercase layer names.' +} + +$recovery = "Run Entry preparation once before dispatch, or use READ's bounded native-file fallback. Do not rebuild in a leaf." +$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery +$index = $preparedIndex.index +$byPath = $preparedIndex.byPath +$unrestricted = $index.enabledLayers.Count -eq 0 -or + ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0]) +$indexedLayers = @( + if ($unrestricted) { 'microsoft', 'community', 'custom' } else { $index.enabledLayers } +) +if (@($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) { + throw "Index layer coverage does not cover EnabledLayers. $recovery" +} + +$resolved = [Collections.Generic.List[string]]::new() +$records = [Collections.Generic.List[object]]::new() +$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$articleTexts = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal) +$sampleContents = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +foreach ($path in $Paths) { + $kind = if ($Samples) { 'sample' } else { 'article' } + $fullPath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path -Kind $kind + if ($path.Split('/')[0] -cnotin $EnabledLayers) { + throw "Layer disabled for path: $path" + } + if (-not $seen.Add($path)) { + throw "Duplicate requested path: $path" + } + if ($Samples) { + $articlePath = $path -replace '\.(good|bad)\.[a-z0-9]+$', '.md' + if (-not $byPath.ContainsKey($articlePath)) { + throw "Sample article is absent from the prepared index: $articlePath" + } + $fullArticlePath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $articlePath + if (-not $articleTexts.ContainsKey($articlePath)) { + $articleContent = Read-KnowledgeText -Path $fullArticlePath + if ($articleContent.sha256 -cne $byPath[$articlePath].sourceSha256) { + throw "Selected article hash does not match the prepared index: $articlePath" + } + $articleTexts.Add($articlePath, $articleContent.text) + } + Assert-SampleLink -ArticleText $articleTexts[$articlePath] -SamplePath $fullPath + $sampleContent = Read-KnowledgeText -Path $fullPath + $sampleContents.Add($path, $sampleContent) + $records.Add([ordered]@{ + path = $path + articlePath = $articlePath + articleSha256 = $byPath[$articlePath].sourceSha256 + sampleSha256 = $sampleContent.sha256 + sampleBytes = $sampleContent.bytes + }) + } + else { + if (-not $byPath.ContainsKey($path)) { + throw "Selected article is absent from the prepared index: $path" + } + $records.Add([ordered]@{ + path = $path + expectedSha256 = $byPath[$path].sourceSha256 + }) + } + $resolved.Add($fullPath) +} + +$requestSnapshot = Get-ResultSnapshot -Value ([ordered]@{ + root = $BCQualityRoot + preparedIndexSha256 = $preparedIndex.content.sha256 + kind = if ($Samples) { 'samples' } else { 'articles' } + enabledLayers = @($EnabledLayers) + files = @($records) +}) +if ($Snapshot -and $Snapshot -cne $requestSnapshot) { + throw 'Article snapshot changed or continuation belongs to another exact path batch. Discard partial results and restart.' +} + +$articles = [Collections.Generic.List[object]]::new() +function ConvertTo-BatchJson { + param([int] $ReadCount) + + $remaining = @( + if ($ReadCount -lt $Paths.Count) { + $Paths[$ReadCount..($Paths.Count - 1)] + } + ) + $remainingRecords = @( + if ($ReadCount -lt $records.Count) { + $records[$ReadCount..($records.Count - 1)] + } + ) + $continuation = if ($remaining.Count) { + [ordered]@{ + snapshot = Get-ResultSnapshot -Value ([ordered]@{ + root = $BCQualityRoot + preparedIndexSha256 = $preparedIndex.content.sha256 + kind = if ($Samples) { 'samples' } else { 'articles' } + enabledLayers = @($EnabledLayers) + files = $remainingRecords + }) + } + } + else { + $null + } + return [ordered]@{ + version = 1 + kind = if ($Samples) { 'samples' } else { 'articles' } + snapshot = $requestSnapshot + requestedCount = $Paths.Count + returnedCount = $ReadCount + complete = ($ReadCount -eq $Paths.Count) + articles = @($articles) + remainingPaths = $remaining + continuation = $continuation + } | ConvertTo-Json -Depth 8 -Compress +} + +$json = '' +for ($i = 0; $i -lt [Math]::Min($MaxArticles, $Paths.Count); $i++) { + $content = if ($Samples) { + $sampleContents[$Paths[$i]] + } + else { + Read-KnowledgeText -Path $resolved[$i] + } + if (-not $Samples -and $content.sha256 -cne $records[$i].expectedSha256) { + throw "Selected article hash does not match the prepared index: $($Paths[$i])" + } + $articles.Add([ordered]@{ + path = $Paths[$i] + bytes = $content.bytes + sha256 = $content.sha256 + body = $content.text + }) + $next = ConvertTo-BatchJson -ReadCount ($i + 1) + if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) { + $articles.RemoveAt($articles.Count - 1) + if ($i -eq 0) { + throw "No complete body plus continuation fits MaxBytes=$MaxBytes at $($Paths[$i]). Use a smaller exact path batch or READ's bounded native-file fallback; never truncate." + } + break + } + $json = $next +} + +$json diff --git a/tools/Knowledge-Retrieval.ps1 b/tools/Knowledge-Retrieval.ps1 new file mode 100644 index 0000000..7007868 --- /dev/null +++ b/tools/Knowledge-Retrieval.ps1 @@ -0,0 +1,298 @@ +# Shared filesystem guards for catalog and exact article retrieval. +Set-StrictMode -Version Latest + +function Resolve-KnowledgeRoot { + param([string] $Root) + + $item = Get-Item -LiteralPath $Root -Force -ErrorAction Stop + if ($item.PSProvider.Name -ne 'FileSystem' -or -not $item.PSIsContainer) { + throw "BCQuality root must be a filesystem directory: $Root" + } + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Linked BCQuality roots are not supported: $Root" + } + return $item.FullName +} + +function Assert-KnowledgePath { + param( + [string] $Path, + [ValidateSet('article', 'sample')] [string] $Kind = 'article' + ) + + if ([string]::IsNullOrWhiteSpace($Path) -or + $Path -cnotmatch '^(microsoft|community|custom)/knowledge/[^/]+/.+' -or + $Path -match '[\\:*?"<>|\x00-\x1f]' -or + @($Path.Split('/') | Where-Object { $_ -in '', '.', '..' -or $_ -match '[. ]$' }).Count) { + throw "Invalid knowledge path: $Path" + } + if (($Kind -eq 'article' -and -not $Path.EndsWith('.md', [StringComparison]::Ordinal)) -or + ($Kind -eq 'sample' -and $Path -cnotmatch '\.(good|bad)\.[a-z0-9]+$')) { + throw "Expected an exact $Kind path: $Path" + } +} + +function Resolve-KnowledgePath { + param( + [string] $Root, + [string] $Path, + [ValidateSet('article', 'sample')] [string] $Kind = 'article' + ) + + Assert-KnowledgePath -Path $Path -Kind $Kind + $current = $Root + foreach ($part in $Path.Split('/')) { + $items = @( + Get-ChildItem -LiteralPath $current -Filter $part -Force -ErrorAction Stop | + Where-Object Name -CEQ $part + ) + if ($items.Count -ne 1) { + throw "Knowledge path does not exist with exact casing: $Path" + } + $item = $items[0] + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Linked knowledge paths are not supported: $Path" + } + $current = $item.FullName + } + if ($item.PSIsContainer) { + throw "Knowledge path is not a file: $Path" + } + return $item.FullName +} + +function Read-KnowledgeText { + param([string] $Path) + + $bytes = [IO.File]::ReadAllBytes($Path) + try { + $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + } + catch { + throw "Knowledge file is not valid strict UTF-8: $Path" + } + return [pscustomobject]@{ + text = $text + bytes = $bytes.Length + sha256 = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes) + ).ToLowerInvariant() + } +} + +function Get-NormalizedKnowledgeVersions { + param([string[]] $Values) + + foreach ($value in $Values) { + if ($value -match '^"([^"]*)"$' -or $value -match "^'([^']*)'$") { + $Matches[1] + } + else { + $value + } + } +} + +function Get-KnowledgeMetadataProblem { + param([Collections.IDictionary] $Row) + + if ($Row['parsed'] -isnot [bool] -or -not $Row['parsed']) { + return 'unparsed frontmatter' + } + if ($Row['domain'] -isnot [string] -or + $Row['domain'] -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$') { + return 'missing/invalid domain' + } + foreach ($field in @('bc-version', 'technologies', 'countries', 'application-area', 'keywords')) { + if ($Row[$field] -isnot [array] -or -not $Row[$field].Count) { + return "missing/invalid $field" + } + foreach ($value in $Row[$field]) { + if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace($value)) { + return "invalid $field value" + } + } + } + foreach ($field in @('title', 'description')) { + if ($Row[$field] -isnot [string] -or + [string]::IsNullOrWhiteSpace($Row[$field]) -or + $Row[$field] -match '[\r\n]') { + return "missing/invalid $field" + } + } + + $versions = @(Get-NormalizedKnowledgeVersions -Values $Row['bc-version']) + if ($versions -ccontains 'all') { + if ($versions.Count -ne 1) { + return 'mixed bc-version sentinel' + } + } + elseif ($versions.Count -eq 1 -and $versions[0] -match '^(\d+)\.\.(\d+)?$') { + $start = [bigint]::Parse($Matches[1]) + if ($start -le 0 -or ($Matches[2] -and [bigint]::Parse($Matches[2]) -le 0)) { + return 'invalid bc-version range bound' + } + if ($Matches[2] -and $start -gt [bigint]::Parse($Matches[2])) { + return 'descending bc-version range' + } + } + else { + foreach ($version in $versions) { + if ($version -notmatch '^\d+$' -or [bigint]::Parse($version) -le 0) { + return 'invalid bc-version' + } + } + } + if (@($Row.technologies | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) { + return 'invalid technologies' + } + if ($Row.technologies -ccontains 'all') { + return 'invalid technologies sentinel' + } + if ($Row.countries -ccontains 'w1') { + if ($Row.countries.Count -ne 1) { + return 'mixed countries sentinel' + } + } + elseif (@($Row.countries | Where-Object { $_ -cnotmatch '^[a-z]{2}$' }).Count) { + return 'invalid countries' + } + if (@($Row['application-area'] | Where-Object { $_ -cnotmatch '^(all|[a-z0-9]+(-[a-z0-9]+)*)$' }).Count) { + return 'invalid application-area' + } + if ($Row['application-area'] -ccontains 'all' -and $Row['application-area'].Count -ne 1) { + return 'mixed application-area sentinel' + } + if (@($Row.keywords | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) { + return 'invalid keywords' + } + return '' +} + +function Get-PreparedManifestSha256 { + param( + [string[]] $Paths, + [Collections.Generic.Dictionary[string, object]] $ByPath + ) + + $hash = [Security.Cryptography.IncrementalHash]::CreateHash( + [Security.Cryptography.HashAlgorithmName]::SHA256 + ) + try { + $hash.AppendData([byte[]][char]'[') + for ($i = 0; $i -lt $Paths.Count; $i++) { + if ($i) { + $hash.AppendData([byte[]][char]',') + } + $row = [ordered]@{ + path = $Paths[$i] + sha256 = $ByPath[$Paths[$i]].sourceSha256 + } + $hash.AppendData([Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject $row -Depth 8 -Compress) + )) + } + $hash.AppendData([byte[]][char]']') + return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant() + } + finally { + $hash.Dispose() + } +} + +function Read-PreparedKnowledgeIndex { + param( + [string] $IndexPath, + [string] $Recovery + ) + + if (-not (Test-Path -LiteralPath $IndexPath -PathType Leaf)) { + throw "Knowledge index missing: $IndexPath. $Recovery" + } + $indexItem = Get-Item -LiteralPath $IndexPath -Force -ErrorAction Stop + if ($indexItem.PSProvider.Name -ne 'FileSystem' -or + ($indexItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Knowledge index must be an unlinked filesystem file: $IndexPath. $Recovery" + } + + $content = Read-KnowledgeText -Path $indexItem.FullName + try { + $index = $content.text.TrimStart([char]0xfeff) | + ConvertFrom-Json -AsHashtable -ErrorAction Stop + } + catch { + throw "Malformed knowledge index JSON: $($_.Exception.Message). $Recovery" + } + if ($index -isnot [Collections.IDictionary] -or + $index.version -ne 1 -or + $index.articles -isnot [array] -or + $index.articleCount -ne $index.articles.Count -or + $index.enabledLayers -isnot [array] -or + $index.knowledgeAllow -isnot [array] -or + $index.knowledgeDeny -isnot [array] -or + $index.sourceSnapshot -isnot [string] -or + $index.sourceSnapshot -cnotmatch '^[a-f0-9]{64}$') { + throw "Invalid knowledge index envelope. $Recovery" + } + + $generatedAt = [DateTimeOffset]::MinValue + if ($index.generatedAt -is [DateTime]) { + $generatedAt = [DateTimeOffset]$index.generatedAt + } + elseif (-not [DateTimeOffset]::TryParse( + [string]$index.generatedAt, + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$generatedAt + )) { + throw "Invalid knowledge index generatedAt. $Recovery" + } + if ($generatedAt -gt [DateTimeOffset]::UtcNow.AddMinutes(1)) { + throw "Invalid knowledge index generatedAt. $Recovery" + } + + $byPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($row in $index.articles) { + if ($row -isnot [Collections.IDictionary] -or $row.path -isnot [string]) { + throw "Index row has no exact path. $Recovery" + } + Assert-KnowledgePath -Path $row.path + if ($row.layer -cne $row.path.Split('/')[0] -or + $row.layer -cnotin @('microsoft', 'community', 'custom')) { + throw "Invalid index layer: $($row.path). $Recovery" + } + if ($row.sourceSha256 -isnot [string] -or + $row.sourceSha256 -cnotmatch '^[a-f0-9]{64}$') { + throw "Invalid source hash in knowledge index: $($row.path). $Recovery" + } + if (-not $byPath.TryAdd($row.path, $row)) { + throw "Duplicate index path: $($row.path). $Recovery" + } + } + + $paths = [string[]]@($byPath.Keys) + [Array]::Sort($paths, [StringComparer]::Ordinal) + if ((Get-PreparedManifestSha256 -Paths $paths -ByPath $byPath) -cne $index.sourceSnapshot) { + throw "Stale or internally inconsistent prepared index snapshot. $Recovery" + } + + return [pscustomobject]@{ + index = $index + content = $content + byPath = $byPath + paths = $paths + } +} + +function Assert-SampleLink { + param( + [string] $ArticleText, + [string] $SamplePath + ) + + $sampleName = [IO.Path]::GetFileName($SamplePath) + $expected = '[`' + $sampleName + '`](' + $sampleName + ')' + if (-not $ArticleText.Contains($expected, [StringComparison]::Ordinal)) { + throw "Sample is not linked by its article using the READ convention: $sampleName" + } +} diff --git a/tools/Search-Knowledge.ps1 b/tools/Search-Knowledge.ps1 new file mode 100644 index 0000000..ade280e --- /dev/null +++ b/tools/Search-Knowledge.ps1 @@ -0,0 +1,244 @@ +<# +.SYNOPSIS + Returns bounded pages of every domain/layer/READ-applicable catalog row. +.DESCRIPTION + Consumes Entry's prepared index read-only. Results are never ranked, sampled, + top-k limited, deduplicated by basename, or narrowed by query text. Omit an + unknown task dimension; an explicit empty array is a known empty set. +#> +#requires -Version 7.2 +[CmdletBinding()] +param( + [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent), + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] [string] $Domain, + [ValidateSet('microsoft', 'community', 'custom')] + [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'), + [ValidateRange(1, 2147483647)] [int] $BCVersion, + [AllowEmptyCollection()] [string[]] $Technologies, + [AllowEmptyCollection()] [string[]] $Countries, + [AllowEmptyCollection()] [string[]] $ApplicationAreas, + [switch] $ExcludeConditional, + [string] $IndexPath, + [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000, + [ValidateRange(0, 2147483647)] [int] $Offset = 0, + [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1') +. (Join-Path $PSScriptRoot 'Bounded-Results.ps1') + +$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot +if (-not $IndexPath) { + $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json' +} +if ($null -eq $EnabledLayers) { + throw 'EnabledLayers must be an array; use an empty array to disable all layers.' +} +if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or + @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) { + throw 'EnabledLayers must contain unique canonical lowercase layer names.' +} + +$context = [ordered]@{} +foreach ($pair in @( + @('BCVersion', 'bc-version'), + @('Technologies', 'technologies'), + @('Countries', 'countries'), + @('ApplicationAreas', 'application-area') +)) { + if (-not $PSBoundParameters.ContainsKey($pair[0])) { + continue + } + $value = $PSBoundParameters[$pair[0]] + if ($null -eq $value) { + throw "Omit unknown context; do not pass null for $($pair[0])." + } + if ($pair[0] -ne 'BCVersion') { + foreach ($entry in $value) { + if ([string]::IsNullOrWhiteSpace($entry) -or $entry -cne $entry.Trim()) { + throw "Invalid context value for $($pair[0]): '$entry'" + } + } + } + $context[$pair[1]] = $value +} +if ($context.Contains('technologies') -and $context['technologies'] -ccontains 'all') { + throw "Technologies has no 'all' sentinel. Omit unknown context." +} + +$recovery = "Run Entry preparation once before dispatch, or use READ's path-discovery fallback. Do not rebuild in a leaf." +$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery +$index = $preparedIndex.index +$indexContent = $preparedIndex.content +$byPath = $preparedIndex.byPath +$paths = $preparedIndex.paths + +# The v1 generator historically serialized an omitted EnabledLayers parameter as [null]. +$unrestricted = $index.enabledLayers.Count -eq 0 -or + ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0]) +$indexedLayers = @( + if ($unrestricted) { + 'microsoft', 'community', 'custom' + } + else { + $index.enabledLayers + } +) +if (@($indexedLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or + @($indexedLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count -or + @($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) { + throw "Index layer coverage does not cover EnabledLayers. $recovery" +} + +foreach ($path in $paths) { + $row = $byPath[$path] + if ($row.layer -cnotin $indexedLayers) { + throw "Index row layer is outside index coverage: $path. $recovery" + } + $problem = Get-KnowledgeMetadataProblem -Row $row + if ($problem) { + throw "Malformed knowledge index row at ${path}: $problem. $recovery" + } +} + +$defaults = [ordered]@{ + 'bc-version' = @('all') + technologies = @('al') + countries = @('w1') + 'application-area' = @('all') +} +$candidates = [Collections.Generic.List[object]]::new() +$excluded = [Collections.Generic.List[object]]::new() +foreach ($path in $paths) { + $row = $byPath[$path] + if ($row.domain -cne $Domain) { + continue + } + + $unknown = [Collections.Generic.List[string]]::new() + $matchesContext = $true + foreach ($field in $defaults.Keys) { + $values = $row[$field] + if ($field -eq 'bc-version') { + $values = @(Get-NormalizedKnowledgeVersions -Values $values) + } + $sentinel = switch ($field) { + 'bc-version' { 'all' } + 'countries' { 'w1' } + 'application-area' { 'all' } + default { '' } + } + if ($sentinel -and $values -ccontains $sentinel) { + continue + } + if (-not $context.Contains($field)) { + $unknown.Add($field) + continue + } + + $target = $context[$field] + $matched = $false + if ($field -eq 'bc-version') { + # Compare as bigint on both sides: metadata validation accepts bounds + # wider than Int32, and an int left operand would coerce them down. + $targetVersion = [bigint]$target + if ($values.Count -eq 1 -and $values[0] -match '^(\d+)\.\.(\d+)?$') { + $matched = $targetVersion -ge [bigint]::Parse($Matches[1]) -and + (-not $Matches[2] -or $targetVersion -le [bigint]::Parse($Matches[2])) + } + else { + $matched = @($values | Where-Object { [bigint]::Parse($_) -eq $targetVersion }).Count -gt 0 + } + } + else { + $matched = @($values | Where-Object { $target -ccontains $_ }).Count -gt 0 + } + if (-not $matched) { + $matchesContext = $false + break + } + } + if (-not $matchesContext -or ($ExcludeConditional -and $unknown.Count)) { + continue + } + $null = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path + + $candidate = [ordered]@{ + path = $path + layer = $row.layer + keywords = $row.keywords + title = $row.title + description = $row.description + } + foreach ($field in $defaults.Keys) { + if (($row[$field] -join "`0") -cne ($defaults[$field] -join "`0")) { + $candidate[$field] = $row[$field] + } + } + $candidate.applicability = if ($unknown.Count) { 'conditional' } else { 'applicable' } + $candidate.unknownDimensions = @($unknown) + if ($row.layer -cin $EnabledLayers) { + $candidates.Add($candidate) + } + else { + $excluded.Add($candidate) + } +} + +$header = [ordered]@{ + version = 2 + domain = $Domain + context = $context + enabledLayers = @($EnabledLayers) + indexedLayers = @($indexedLayers) + excludeConditional = [bool]$ExcludeConditional + defaults = $defaults + candidateCount = $candidates.Count + excludedByConfigurationCount = $excluded.Count +} + +function Get-CatalogSnapshot { + param( + [string] $PreparedIndexSha256, + [Collections.IDictionary] $Request, + [Collections.IDictionary] $Groups + ) + + $hash = [Security.Cryptography.IncrementalHash]::CreateHash( + [Security.Cryptography.HashAlgorithmName]::SHA256 + ) + try { + foreach ($value in @( + $PreparedIndexSha256, + (ConvertTo-Json -InputObject $Request -Depth 8 -Compress) + )) { + $hash.AppendData([Text.Encoding]::UTF8.GetBytes($value)) + $hash.AppendData([byte[]](10)) + } + foreach ($groupName in $Groups.Keys) { + $hash.AppendData([Text.Encoding]::UTF8.GetBytes("[$groupName]")) + $hash.AppendData([byte[]](10)) + foreach ($row in $Groups[$groupName]) { + $hash.AppendData([Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject $row -Depth 8 -Compress) + )) + $hash.AppendData([byte[]](10)) + } + } + return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant() + } + finally { + $hash.Dispose() + } +} + +$groups = [ordered]@{ + candidates = $candidates + excludedByConfiguration = $excluded +} +$header.snapshot = Get-CatalogSnapshot -PreparedIndexSha256 $indexContent.sha256 -Request $header -Groups $groups + +ConvertTo-BoundedPage -Header $header -Groups $groups -Offset $Offset -Snapshot $Snapshot -MaxBytes $MaxBytes diff --git a/tools/Test-KnowledgeRetrieval.ps1 b/tools/Test-KnowledgeRetrieval.ps1 new file mode 100644 index 0000000..ef65812 --- /dev/null +++ b/tools/Test-KnowledgeRetrieval.ps1 @@ -0,0 +1,788 @@ +<# +.SYNOPSIS + Validates lossless bounded catalog and exact-body retrieval. +#> +#requires -Version 7.2 +[CmdletBinding()] +param( + [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$Root = (Resolve-Path -LiteralPath $Root).Path + +$generator = Join-Path $Root 'tools/Build-KnowledgeIndex.ps1' +$search = Join-Path $Root 'tools/Search-Knowledge.ps1' +$getArticles = Join-Path $Root 'tools/Get-KnowledgeArticles.ps1' +$utf8 = [Text.UTF8Encoding]::new($false, $true) + +function Assert-True { + param([bool] $Condition, [string] $Message) + if (-not $Condition) { + throw "Assertion failed: $Message" + } +} + +function Assert-Equal { + param($Actual, $Expected, [string] $Message) + if ($Actual -cne $Expected) { + throw "Assertion failed: $Message. Expected '$Expected', got '$Actual'." + } +} + +function Assert-Sequence { + param($Actual, $Expected, [string] $Message) + $actualJson = ConvertTo-Json -InputObject @($Actual) -Compress + $expectedJson = ConvertTo-Json -InputObject @($Expected) -Compress + if ($actualJson -cne $expectedJson) { + throw "Assertion failed: $Message. Expected $expectedJson, got $actualJson." + } +} + +function Assert-Throws { + param([scriptblock] $Action, [string] $Pattern, [string] $Message) + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $Pattern) { + throw "Assertion failed: $Message. Wrong error: $($_.Exception.Message)" + } + return + } + throw "Assertion failed: $Message. No error was thrown." +} + +function Get-OutputByteCount { + param([string] $Text) + return [Text.Encoding]::UTF8.GetByteCount($Text) + + [Text.Encoding]::UTF8.GetByteCount([Environment]::NewLine) +} + +function Invoke-CatalogPages { + param( + [hashtable] $Arguments, + [int] $MaxBytes = 4096 + ) + + $allCandidates = [Collections.Generic.List[object]]::new() + $allExcluded = [Collections.Generic.List[object]]::new() + $offset = 0 + $snapshot = '' + $shared = '' + $pageCount = 0 + $lastPage = $null + do { + $pageArguments = @{} + $Arguments + $pageArguments.MaxBytes = $MaxBytes + $pageArguments.Offset = $offset + if ($snapshot) { + $pageArguments.Snapshot = $snapshot + } + $raw = & $search @pageArguments + Assert-True ($raw -is [string]) 'catalog helper emitted exactly one JSON string' + Assert-True ((Get-OutputByteCount -Text $raw) -le $MaxBytes) 'catalog page includes its newline in MaxBytes' + $page = $raw | ConvertFrom-Json + $pageCount++ + Assert-True ($pageCount -le 1000) 'catalog continuation terminates' + Assert-Equal $page.offset $offset 'catalog offset is exact' + Assert-Equal $page.returnedCount (@($page.candidates).Count + @($page.excludedByConfiguration).Count) 'page returnedCount matches rows' + Assert-Equal $page.remainingCount ($page.totalCount - $offset - $page.returnedCount) 'page remainingCount is exact' + + $currentShared = [ordered]@{ + version = $page.version + domain = $page.domain + context = $page.context + enabledLayers = $page.enabledLayers + indexedLayers = $page.indexedLayers + excludeConditional = $page.excludeConditional + defaults = $page.defaults + candidateCount = $page.candidateCount + excludedByConfigurationCount = $page.excludedByConfigurationCount + snapshot = $page.snapshot + totalCount = $page.totalCount + } | ConvertTo-Json -Depth 8 -Compress + if (-not $shared) { + $shared = $currentShared + $snapshot = $page.snapshot + } + else { + Assert-Equal $currentShared $shared 'catalog pages repeat shared context, defaults, totals, and snapshot' + } + + foreach ($row in @($page.candidates)) { + $allCandidates.Add($row) + } + foreach ($row in @($page.excludedByConfiguration)) { + $allExcluded.Add($row) + } + if (-not $page.complete) { + Assert-True ($null -ne $page.continuation) 'incomplete page has continuation' + Assert-Equal $page.continuation.snapshot $snapshot 'continuation is snapshot-bound' + Assert-True ($page.continuation.offset -gt $offset) 'continuation makes progress' + $offset = $page.continuation.offset + } + $lastPage = $page + } while (-not $page.complete) + + Assert-True ($null -eq $lastPage.continuation) 'final page has no continuation' + Assert-Equal $allCandidates.Count $lastPage.candidateCount 'candidate total survives paging' + Assert-Equal $allExcluded.Count $lastPage.excludedByConfigurationCount 'excluded total survives paging' + return [pscustomobject]@{ + candidates = @($allCandidates) + excluded = @($allExcluded) + pages = $pageCount + snapshot = $snapshot + lastPage = $lastPage + } +} + +function Test-BodyRoundTrip { + param( + [string[]] $Paths, + [string] $IndexPath, + [switch] $Samples + ) + + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + for ($start = 0; $start -lt $Paths.Count; $start += 8) { + $end = [Math]::Min($start + 7, $Paths.Count - 1) + $remaining = @($Paths[$start..$end]) + $snapshot = '' + do { + $arguments = @{ + BCQualityRoot = $Root + IndexPath = $IndexPath + Paths = $remaining + MaxArticles = 8 + MaxBytes = 16000 + } + if ($Samples) { + $arguments.Samples = $true + } + if ($snapshot) { + $arguments.Snapshot = $snapshot + } + $raw = & $getArticles @arguments + Assert-True ($raw -is [string]) 'article helper emitted exactly one JSON string' + Assert-True ((Get-OutputByteCount -Text $raw) -le 16000) 'article batch includes its newline in MaxBytes' + $batch = $raw | ConvertFrom-Json + Assert-True ($batch.returnedCount -gt 0) 'article batching makes progress' + Assert-Equal $batch.returnedCount @($batch.articles).Count 'article returnedCount matches rows' + Assert-Equal $batch.complete (@($batch.remainingPaths).Count -eq 0) 'article completion matches remaining paths' + if ($batch.complete) { + Assert-True ($null -eq $batch.continuation) 'complete article batch has no continuation' + } + else { + Assert-True ($batch.continuation.snapshot -match '^[a-f0-9]{64}$') 'article continuation is snapshot-bound' + } + + foreach ($article in @($batch.articles)) { + Assert-True ($seen.Add($article.path)) "body returned once: $($article.path)" + $fullPath = Join-Path $Root ($article.path.Replace('/', [IO.Path]::DirectorySeparatorChar)) + $bytes = [IO.File]::ReadAllBytes($fullPath) + $text = $utf8.GetString($bytes) + $hash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes) + ).ToLowerInvariant() + Assert-Equal $article.bytes $bytes.Length "byte count round-trips: $($article.path)" + Assert-Equal $article.sha256 $hash "SHA-256 round-trips: $($article.path)" + Assert-Equal $article.body $text "body round-trips: $($article.path)" + } + $remaining = @($batch.remainingPaths) + $snapshot = if ($batch.complete) { '' } else { $batch.continuation.snapshot } + } while ($remaining.Count) + } + Assert-Equal $seen.Count $Paths.Count 'every requested body round-trips without loss' +} + +function New-NeutralArticle { + param( + [string] $FixtureRoot, + [string] $Layer, + [string] $Slug, + [string] $Version = 'all', + [string] $Technology = 'al', + [string] $Country = 'w1', + [string] $Area = 'all', + [string] $Title = 'Neutral retrieval example', + [string] $Description = 'Neutral retrieval metadata for deterministic tests.' + ) + + $directory = Join-Path $FixtureRoot "$Layer\knowledge\neutral" + New-Item -ItemType Directory -Force -Path $directory | Out-Null + $content = @" +--- +bc-version: [$Version] +domain: neutral +keywords: [neutral, retrieval, deterministic] +technologies: [$Technology] +countries: [$Country] +application-area: [$Area] +--- + +# $Title + +## Description + +$Description +"@ + Set-Content -LiteralPath (Join-Path $directory "$Slug.md") -Value $content -Encoding utf8NoBOM +} + +function Test-InvalidSourceIndexing { + param( + [string] $FixtureRoot, + [string] $Field, + [string] $ValidValue, + [string] $InvalidValue + ) + + New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source + New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source + $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md' + $text = [IO.File]::ReadAllText($articlePath, $utf8) + $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue") + [IO.File]::WriteAllText($articlePath, $text, $utf8) + + $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$Field-index.json") + $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1) + $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] }) + Assert-Equal $warnings.Count 1 "scalar $Field source emits one omission warning" + Assert-True ( + $warnings[0].Message -match + "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source\.md': frontmatter field '$([regex]::Escape($Field))' must use non-empty bracket-array syntax\." + ) "scalar $Field warning identifies the exact path and reason" + $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + Assert-Equal $prepared.articleCount 1 "scalar $Field source is omitted while its valid sibling is indexed" + Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field index contains only the valid sibling" + $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral | + ConvertFrom-Json + Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field catalog retrieves the valid sibling" + $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath ` + -Paths 'microsoft/knowledge/neutral/valid-source.md' | + ConvertFrom-Json + Assert-True $valid.complete "scalar $Field valid sibling body retrieves completely" + Assert-Throws { + & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath ` + -Paths 'community/knowledge/neutral/invalid-source.md' + } 'Selected article is absent from the prepared index' "scalar $Field omitted source cannot be retrieved" +} + +function Test-InvalidSemanticIndexing { + param( + [string] $FixtureRoot, + [string] $CaseName, + [string] $Field, + [string] $ValidValue, + [string] $InvalidValue, + [string] $ExpectedReason + ) + + New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source + New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source + $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md' + $text = [IO.File]::ReadAllText($articlePath, $utf8) + $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue") + [IO.File]::WriteAllText($articlePath, $text, $utf8) + + $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$CaseName-index.json") + $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1) + $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] }) + Assert-Equal $warnings.Count 1 "$CaseName emits one omission warning" + Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source.md': $ExpectedReason." "$CaseName warning identifies exact path and reason" + + $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + Assert-Equal $prepared.articleCount 1 "$CaseName omits invalid source and retains valid sibling" + Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName index contains only valid sibling" + Assert-True ($prepared.sourceSnapshot -match '^[a-f0-9]{64}$') "$CaseName source snapshot remains valid" + $validRow = $prepared.articles[0] + $manifest = @( + [ordered]@{ path = $validRow.path; sha256 = $validRow.sourceSha256 } + ) + $manifestBytes = [Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject $manifest -Depth 8 -Compress) + ) + $expectedSnapshot = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($manifestBytes) + ).ToLowerInvariant() + Assert-Equal $prepared.sourceSnapshot $expectedSnapshot "$CaseName source snapshot covers only retained rows" + + $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral | + ConvertFrom-Json + Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName catalog retains valid sibling" + $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath ` + -Paths 'microsoft/knowledge/neutral/valid-source.md' | + ConvertFrom-Json + Assert-True $valid.complete "$CaseName valid sibling body retrieves" + Assert-Throws { + & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath ` + -Paths 'community/knowledge/neutral/invalid-source.md' + } 'Selected article is absent from the prepared index' "$CaseName invalid source cannot be retrieved" +} + +function Test-InvalidEnabledLayers { + param( + [string] $FixtureRoot, + [string] $CaseName, + $Layers, + [string] $ExpectedPattern + ) + + $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("layers-$CaseName.json") + $arguments = @{ + BCQualityRoot = $FixtureRoot + IndexPath = $indexPath + EnabledLayers = $Layers + } + Assert-Throws { + & $generator @arguments + } $ExpectedPattern "$CaseName EnabledLayers fails" + Assert-True (-not (Test-Path -LiteralPath $indexPath)) "$CaseName fails before index creation" +} + +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("bcquality_retrieval_" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Force -Path $tmp | Out-Null +try { + $indexPath = Join-Path $tmp 'knowledge-index.json' + & $generator -BCQualityRoot $Root -IndexPath $indexPath | Out-Null + $index = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $diskArticlePaths = @( + foreach ($layer in 'microsoft', 'community', 'custom') { + $knowledge = Join-Path $Root "$layer\knowledge" + if (Test-Path -LiteralPath $knowledge) { + Get-ChildItem -LiteralPath $knowledge -Recurse -File -Filter '*.md' | + ForEach-Object { + [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/') + } + } + } + ) | Sort-Object + Assert-Equal $index.articleCount $diskArticlePaths.Count 'index covers every current article' + Assert-True ($index.sourceSnapshot -match '^[a-f0-9]{64}$') 'index carries an exact source snapshot' + + $allCatalogRows = [Collections.Generic.List[object]]::new() + $domains = @($index.articles.domain | Sort-Object -Unique) + foreach ($domain in $domains) { + $catalog = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $Root + IndexPath = $indexPath + Domain = $domain + } + Assert-Equal $catalog.excluded.Count 0 "all layers enabled for $domain" + foreach ($row in $catalog.candidates) { + $allCatalogRows.Add($row) + } + } + + $expectedRows = @($index.articles | Sort-Object path) + $actualRows = @($allCatalogRows | Sort-Object path) + Assert-Equal $actualRows.Count $expectedRows.Count 'paged union has no top-k or query-based loss' + Assert-Sequence ($actualRows.path) ($expectedRows.path) 'paged union equals all READ-filtered candidates' + Assert-Equal @($actualRows.path | Sort-Object -Unique).Count $actualRows.Count 'catalog does not deduplicate distinct paths' + + $defaults = [ordered]@{ + 'bc-version' = @('all') + technologies = @('al') + countries = @('w1') + 'application-area' = @('all') + } + for ($i = 0; $i -lt $actualRows.Count; $i++) { + $actual = $actualRows[$i] + $expected = $expectedRows[$i] + Assert-Equal $actual.path $expected.path 'catalog preserves exact path' + Assert-Equal $actual.layer $expected.layer 'catalog preserves layer' + Assert-Sequence $actual.keywords $expected.keywords 'catalog preserves full keywords' + Assert-Equal $actual.title $expected.title 'catalog preserves title' + Assert-Equal $actual.description $expected.description 'catalog preserves one-line description' + + $unknown = [Collections.Generic.List[string]]::new() + foreach ($field in $defaults.Keys) { + $expectedValues = @($expected.$field) + $sentinel = switch ($field) { + 'bc-version' { 'all' } + 'countries' { 'w1' } + 'application-area' { 'all' } + default { '' } + } + if (-not $sentinel -or $expectedValues -notcontains $sentinel) { + $unknown.Add($field) + } + $hasField = $actual.PSObject.Properties.Name -ccontains $field + if (($expectedValues -join "`0") -ceq (@($defaults[$field]) -join "`0")) { + Assert-True (-not $hasField) "default field is inherited from page: $field" + } + else { + Assert-True $hasField "non-default field survives paging: $field" + Assert-Sequence $actual.$field $expectedValues "non-default field is exact: $field" + } + } + Assert-Equal $actual.applicability ($(if ($unknown.Count) { 'conditional' } else { 'applicable' })) 'applicability verdict is explicit' + Assert-Sequence $actual.unknownDimensions @($unknown) 'unknown dimensions are explicit' + } + + $performanceFirst = & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 | + ConvertFrom-Json + Assert-True (-not $performanceFirst.complete) 'large domain produces deterministic continuation' + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 -Offset $performanceFirst.continuation.offset + } 'Continuation requires Snapshot' 'continuation without snapshot fails' + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset $performanceFirst.totalCount + } 'Invalid Offset' 'offset at total fails' + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset 1 -Snapshot ('0' * 64) + } 'Snapshot changed' 'wrong snapshot fails' + + $changedRawIndex = Join-Path $tmp 'changed-raw-index.json' + $changedRaw = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $changedRaw.generatedAt = [DateTimeOffset]::UtcNow.ToString('O') + $changedRaw | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $changedRawIndex -Encoding utf8NoBOM -NoNewline + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $changedRawIndex -Domain performance ` + -MaxBytes 4096 -Offset $performanceFirst.continuation.offset ` + -Snapshot $performanceFirst.continuation.snapshot + } 'Snapshot changed' 'continuation is bound to the exact prepared index bytes' + + $malformedIndex = Join-Path $tmp 'malformed.json' + Set-Content -LiteralPath $malformedIndex -Value '{not-json' -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $malformedIndex -Domain performance + } 'Malformed knowledge index JSON' 'malformed JSON fails' + + $unsafeIndex = Join-Path $tmp 'unsafe.json' + $unsafe = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $unsafe.articles[0].path = '../outside.md' + $unsafe | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $unsafeIndex -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $unsafeIndex -Domain performance + } 'Invalid knowledge path' 'unsafe indexed path fails' + + $invalidRowIndex = Join-Path $tmp 'invalid-row.json' + $invalidRow = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $invalidRow.articles[0].keywords = @() + $invalidRow | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $invalidRowIndex -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $invalidRowIndex -Domain performance + } 'Malformed knowledge index row' 'malformed index row fails' + + $semanticCorruptIndex = Join-Path $tmp 'semantic-corrupt-row.json' + $semanticCorrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $semanticCorrupt.articles[0].countries = @('usa') + $semanticCorrupt | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $semanticCorruptIndex -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $semanticCorruptIndex -Domain performance + } 'Malformed knowledge index row.*invalid countries' 'search rejects semantically invalid external index rows' + + $corruptSemanticCases = @( + @{ name = 'uppercase-all'; field = 'bc-version'; value = @('ALL'); reason = 'invalid bc-version' }, + @{ name = 'uppercase-w1'; field = 'countries'; value = @('W1'); reason = 'invalid countries' }, + @{ name = 'zero-open-range'; field = 'bc-version'; value = @('"0.."'); reason = 'invalid bc-version range bound' }, + @{ name = 'zero-closed-range'; field = 'bc-version'; value = @('"0..0"'); reason = 'invalid bc-version range bound' } + ) + foreach ($case in $corruptSemanticCases) { + $corruptPath = Join-Path $tmp ("corrupt-$($case.name).json") + $corrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + $corrupt.articles[0].PSObject.Properties[$case.field].Value = $case.value + $corrupt | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath $corruptPath -Encoding utf8NoBOM + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $corruptPath -Domain performance + } "Malformed knowledge index row.*$([regex]::Escape($case.reason))" "search rejects $($case.name) in an external index" + } + + $invalidUtf8Root = Join-Path $tmp 'invalid-utf8-source' + New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer microsoft -Slug valid-catalog + New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer community -Slug invalid-utf8-source + $invalidUtf8Article = Join-Path $invalidUtf8Root 'community\knowledge\neutral\invalid-utf8-source.md' + $validBytes = [IO.File]::ReadAllBytes($invalidUtf8Article) + [IO.File]::WriteAllBytes($invalidUtf8Article, [byte[]]@($validBytes + @(0xc3, 0x28))) + $invalidUtf8Index = Join-Path $tmp 'invalid-utf8-index.json' + $generation = @(& $generator -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index 3>&1) + $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] }) + Assert-Equal $warnings.Count 1 'malformed UTF-8 source emits one omission warning' + Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-utf8-source.md': invalid UTF-8." 'malformed UTF-8 warning identifies the exact path and reason' + $invalidUtf8Prepared = Get-Content -LiteralPath $invalidUtf8Index -Raw -Encoding utf8 | + ConvertFrom-Json + Assert-Equal $invalidUtf8Prepared.articleCount 1 'malformed UTF-8 source is omitted while its valid sibling is indexed' + Assert-Sequence $invalidUtf8Prepared.articles.path @('microsoft/knowledge/neutral/valid-catalog.md') 'malformed UTF-8 index contains only the valid sibling' + $validCatalog = & $search -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index -Domain neutral | + ConvertFrom-Json + Assert-Sequence $validCatalog.candidates.path @('microsoft/knowledge/neutral/valid-catalog.md') 'catalog retrieves the valid sibling after malformed UTF-8 omission' + $validBody = & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index ` + -Paths 'microsoft/knowledge/neutral/valid-catalog.md' | + ConvertFrom-Json + Assert-True $validBody.complete 'valid sibling body retrieves after malformed UTF-8 omission' + Assert-Throws { + & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index ` + -Paths 'community/knowledge/neutral/invalid-utf8-source.md' + } 'Selected article is absent from the prepared index' 'omitted malformed UTF-8 source cannot be retrieved' + + $scalarCases = @( + @{ field = 'bc-version'; valid = '[all]'; invalid = 'all' }, + @{ field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = 'neutral' }, + @{ field = 'technologies'; valid = '[al]'; invalid = 'al' }, + @{ field = 'countries'; valid = '[w1]'; invalid = 'w1' }, + @{ field = 'application-area'; valid = '[all]'; invalid = 'all' } + ) + foreach ($case in $scalarCases) { + Test-InvalidSourceIndexing -FixtureRoot (Join-Path $tmp "scalar-$($case.field)") ` + -Field $case.field -ValidValue $case.valid -InvalidValue $case.invalid + } + + $semanticCases = @( + @{ name = 'mixed-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[all, 27]'; reason = 'mixed bc-version sentinel' }, + @{ name = 'invalid-country'; field = 'countries'; valid = '[w1]'; invalid = '[usa]'; reason = 'invalid countries' }, + @{ name = 'descending-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["28..27"]'; reason = 'descending bc-version range' }, + @{ name = 'malformed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '[twenty-seven]'; reason = 'invalid bc-version' }, + @{ name = 'malformed-keyword'; field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = '[neutral, Bad_Token, deterministic]'; reason = 'invalid keywords' }, + @{ name = 'malformed-technology'; field = 'technologies'; valid = '[al]'; invalid = '[AL]'; reason = 'invalid technologies' }, + @{ name = 'malformed-application-area'; field = 'application-area'; valid = '[all]'; invalid = '[finance_]'; reason = 'invalid application-area' }, + @{ name = 'uppercase-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[ALL]'; reason = 'invalid bc-version' }, + @{ name = 'uppercase-country-sentinel'; field = 'countries'; valid = '[w1]'; invalid = '[W1]'; reason = 'invalid countries' }, + @{ name = 'zero-open-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0.."]'; reason = 'invalid bc-version range bound' }, + @{ name = 'zero-closed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0..0"]'; reason = 'invalid bc-version range bound' } + ) + foreach ($case in $semanticCases) { + Test-InvalidSemanticIndexing -FixtureRoot (Join-Path $tmp "semantic-$($case.name)") ` + -CaseName $case.name -Field $case.field -ValidValue $case.valid ` + -InvalidValue $case.invalid -ExpectedReason $case.reason + } + + Assert-Throws { + & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain ('x' * 2000) -MaxBytes 1024 + } 'Page envelope exceeds' 'oversized page envelope fails' + + $articlePaths = @($index.articles.path | Sort-Object) + Assert-Sequence $articlePaths $diskArticlePaths 'exact article path union matches disk' + Assert-Throws { + & $getArticles -BCQualityRoot $Root -IndexPath $indexPath -Paths @($articlePaths[0..8]) + } 'exceeds MaxArticles=8' 'exact retrieval rejects path batches larger than eight' + $samplePaths = @( + foreach ($layer in 'microsoft', 'community', 'custom') { + $knowledge = Join-Path $Root "$layer\knowledge" + if (Test-Path -LiteralPath $knowledge) { + Get-ChildItem -LiteralPath $knowledge -Recurse -File | + Where-Object Name -Match '\.(good|bad)\.[a-z0-9]+$' | + ForEach-Object { + [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/') + } + } + } + ) | Sort-Object + Test-BodyRoundTrip -Paths $articlePaths -IndexPath $indexPath + Test-BodyRoundTrip -Paths $samplePaths -IndexPath $indexPath -Samples + + $fixtureRoot = Join-Path $tmp 'neutral' + New-NeutralArticle -FixtureRoot $fixtureRoot -Layer microsoft -Slug default + New-NeutralArticle -FixtureRoot $fixtureRoot -Layer community -Slug versioned -Version '"27.."' -Technology javascript -Country dk -Area finance -Title 'Versioned neutral example' + New-NeutralArticle -FixtureRoot $fixtureRoot -Layer custom -Slug localized -Version 28 -Technology al -Country de -Area service -Title 'Localized neutral example' + $fixtureIndex = Join-Path $tmp 'neutral-index.json' + & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null + + foreach ($case in @( + @{ name = 'uppercase'; layers = @('Microsoft'); pattern = 'unique canonical lowercase layer names' }, + @{ name = 'duplicate'; layers = @('microsoft', 'microsoft'); pattern = 'unique canonical lowercase layer names' }, + @{ name = 'unknown'; layers = @('partner'); pattern = 'unique canonical lowercase layer names' }, + @{ name = 'null'; layers = $null; pattern = 'must be an array' } + )) { + Test-InvalidEnabledLayers -FixtureRoot $fixtureRoot -CaseName $case.name ` + -Layers $case.layers -ExpectedPattern $case.pattern + } + $subsetIndex = Join-Path $tmp 'community-only-index.json' + & $generator -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex ` + -EnabledLayers @('community') | Out-Null + $subset = & $search -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex ` + -Domain neutral -EnabledLayers @('community') | + ConvertFrom-Json + Assert-Equal $subset.candidateCount 1 'valid EnabledLayers subset builds and is consumable' + Assert-Sequence $subset.candidates.path @('community/knowledge/neutral/versioned.md') 'valid subset contains only its exact layer' + + $applicable = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $fixtureRoot + IndexPath = $fixtureIndex + Domain = 'neutral' + BCVersion = 28 + Technologies = @('al', 'javascript') + Countries = @('dk', 'de') + ApplicationAreas = @('finance', 'service') + } -MaxBytes 16000 + Assert-Equal $applicable.candidates.Count 3 'neutral layer/version rows all survive matching context' + Assert-True (@($applicable.candidates | Where-Object applicability -CEQ applicable).Count -eq 3) 'matching rows are applicable' + $versioned = $applicable.candidates | Where-Object path -CEQ 'community/knowledge/neutral/versioned.md' + Assert-Equal $versioned.layer community 'non-default layer survives' + Assert-Sequence $versioned.'bc-version' @('"27.."') 'original version metadata survives' + Assert-Equal $versioned.applicability applicable 'lowercase sentinels and positive open range remain applicable' + Assert-Sequence $versioned.technologies @('javascript') 'non-default technology survives' + Assert-Sequence $versioned.countries @('dk') 'non-default country survives' + Assert-Sequence $versioned.'application-area' @('finance') 'non-default application area survives' + + # Metadata validation accepts range bounds wider than Int32, so version + # matching must compare as bigint rather than coercing the bound down. + $wideRoot = Join-Path $tmp 'wide-version' + New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-closed -Version '"1..99999999999"' + New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-open -Version '"99999999999.."' + $wideIndex = Join-Path $tmp 'wide-version-index.json' + & $generator -BCQualityRoot $wideRoot -IndexPath $wideIndex | Out-Null + $wide = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $wideRoot + IndexPath = $wideIndex + Domain = 'neutral' + BCVersion = 28 + } -MaxBytes 16000 + Assert-Sequence $wide.candidates.path @('microsoft/knowledge/neutral/wide-closed.md') 'bc-version bounds beyond Int32 compare without overflow' + + $conditional = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $fixtureRoot + IndexPath = $fixtureIndex + Domain = 'neutral' + BCVersion = 28 + Technologies = @('al', 'javascript') + } -MaxBytes 16000 + $conditionalVersioned = $conditional.candidates | + Where-Object path -CEQ 'community/knowledge/neutral/versioned.md' + Assert-Equal $conditionalVersioned.applicability conditional 'unknown context produces conditional verdict' + Assert-Sequence $conditionalVersioned.unknownDimensions @('countries', 'application-area') 'unknown dimensions survive' + + $layerFiltered = Invoke-CatalogPages -Arguments @{ + BCQualityRoot = $fixtureRoot + IndexPath = $fixtureIndex + Domain = 'neutral' + EnabledLayers = @('microsoft') + } -MaxBytes 16000 + Assert-Equal $layerFiltered.candidates.Count 1 'enabled layer remains a candidate' + Assert-Equal $layerFiltered.excluded.Count 2 'disabled layers remain explicit' + Assert-Sequence ($layerFiltered.excluded.layer | Sort-Object) @('community', 'custom') 'excluded rows preserve layer' + + $oldSnapshot = $conditional.snapshot + Add-Content -LiteralPath (Join-Path $fixtureRoot 'community\knowledge\neutral\versioned.md') -Value ' ' -Encoding utf8NoBOM + $preparedCatalog = & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral | + ConvertFrom-Json + Assert-Equal $preparedCatalog.candidateCount 3 'catalog uses the prepared index without rehashing article bodies' + Assert-Throws { + & $getArticles -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex ` + -Paths 'community/knowledge/neutral/versioned.md' + } 'Selected article hash does not match the prepared index' 'exact retrieval detects selected article changes' + & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null + Assert-Throws { + & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral -Offset 1 -Snapshot $oldSnapshot + } 'Snapshot changed' 'continuation cannot cross rebuilt snapshots' + + $largeRoot = Join-Path $tmp 'large-catalog' + New-NeutralArticle -FixtureRoot $largeRoot -Layer microsoft -Slug huge-title -Title ('T' * 3000) + $largeIndex = Join-Path $tmp 'large-index.json' + & $generator -BCQualityRoot $largeRoot -IndexPath $largeIndex | Out-Null + Assert-Throws { + & $search -BCQualityRoot $largeRoot -IndexPath $largeIndex -Domain neutral -MaxBytes 1024 + } 'One complete candidates row|Page envelope exceeds' 'oversized catalog row fails without clipping' + + # The shared pager reports the oversized row's identity for any row shape; + # a row without a path must still reach its explicit offset-based failure. + . (Join-Path $Root 'tools/Bounded-Results.ps1') + $pagerHeader = [ordered]@{ version = 2; snapshot = ('0' * 64) } + foreach ($shape in @( + @{ name = 'dictionary'; row = [ordered]@{ blob = ('x' * 3000) } }, + @{ name = 'object'; row = [pscustomobject]@{ blob = ('x' * 3000) } } + )) { + Assert-Throws { + ConvertTo-BoundedPage -Header $pagerHeader ` + -Groups ([ordered]@{ rows = @($shape.row) }) -MaxBytes 1024 + } 'One complete rows row plus envelope exceeds MaxBytes=1024 at Offset=0' "oversized pathless $($shape.name) row fails with its offset identity" + } + + $bodyRoot = Join-Path $tmp 'body-failures' + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug huge-body -Description ('x' * 3000) + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug broken-link + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-one -Description ('a' * 300) + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-two -Description ('b' * 300) + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug invalid-utf8 + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-one + New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-two + Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.md') ` + -Value '[`sample-one.good.al`](sample-one.good.al)' -Encoding utf8NoBOM + Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.md') ` + -Value '[`sample-two.good.al`](sample-two.good.al)' -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.good.al') ` + -Value ('a' * 900) -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') ` + -Value ('b' * 900) -Encoding utf8NoBOM + $bodyIndex = Join-Path $tmp 'body-index.json' + & $generator -BCQualityRoot $bodyRoot -IndexPath $bodyIndex | Out-Null + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths 'microsoft/knowledge/neutral/huge-body.md' -MaxBytes 1024 + } 'No complete body plus continuation fits' 'oversized body fails without truncation' + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths '../outside.md' + } 'Invalid knowledge path' 'unsafe requested path fails' + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths 'microsoft/knowledge/neutral/huge-body.md' -EnabledLayers community + } 'Layer disabled' 'disabled article layer fails' + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths @( + 'microsoft/knowledge/neutral/huge-body.md', + 'microsoft/knowledge/neutral/huge-body.md' + ) + } 'Duplicate requested path' 'duplicate exact paths fail' + + $brokenSample = Join-Path $bodyRoot 'microsoft\knowledge\neutral\broken-link.good.al' + Set-Content -LiteralPath $brokenSample -Value 'codeunit 1 Neutral { }' -Encoding utf8NoBOM + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths 'microsoft/knowledge/neutral/broken-link.good.al' -Samples + } 'Sample is not linked' 'unlinked sample fails' + + $sampleContinuationPaths = @( + 'microsoft/knowledge/neutral/sample-one.good.al', + 'microsoft/knowledge/neutral/sample-two.good.al' + ) + $firstSamplePage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths $sampleContinuationPaths -Samples -MaxBytes 1600 | + ConvertFrom-Json + Assert-True (-not $firstSamplePage.complete) 'bounded sample batch produces continuation' + Assert-Sequence $firstSamplePage.remainingPaths @('microsoft/knowledge/neutral/sample-two.good.al') 'sample continuation preserves pending path' + Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') ` + -Value 'changed' -Encoding utf8NoBOM + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths @($firstSamplePage.remainingPaths) -Samples ` + -Snapshot $firstSamplePage.continuation.snapshot + } 'Article snapshot changed' 'sample continuation rejects a changed pending sample' + + $continuationPaths = @( + 'microsoft/knowledge/neutral/continuation-one.md', + 'microsoft/knowledge/neutral/continuation-two.md' + ) + $firstBodyPage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths $continuationPaths -MaxBytes 1300 | + ConvertFrom-Json + Assert-True (-not $firstBodyPage.complete) 'bounded article batch produces continuation' + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths @($firstBodyPage.remainingPaths) -Snapshot ('0' * 64) + } 'Article snapshot changed' 'wrong article continuation snapshot fails' + Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\continuation-two.md') -Value 'changed' -Encoding utf8NoBOM + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths @($firstBodyPage.remainingPaths) -Snapshot $firstBodyPage.continuation.snapshot + } 'Selected article hash does not match the prepared index' 'article continuation rejects a changed remaining body' + + $invalidUtf8 = Join-Path $bodyRoot 'microsoft\knowledge\neutral\invalid-utf8.md' + $indexedBytes = [IO.File]::ReadAllBytes($invalidUtf8) + [IO.File]::WriteAllBytes($invalidUtf8, [byte[]]@($indexedBytes + @(0xc3, 0x28))) + Assert-Throws { + & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex ` + -Paths 'microsoft/knowledge/neutral/invalid-utf8.md' + } 'Knowledge file is not valid strict UTF-8' 'invalid UTF-8 fails' + + Write-Host "Knowledge retrieval check PASSED: $($articlePaths.Count) articles and $($samplePaths.Count) samples round-tripped; catalog union was lossless and bounded." -ForegroundColor Green +} +finally { + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue +} From 35d0966a8d45e8d4a7c2b0238afbffe338a31d2b Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 11 Sep 2026 15:24:20 +0200 Subject: [PATCH 08/51] Normalize recoverable leaf finding ranges (#180) * Normalize recoverable leaf ranges Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Run contract checks with review fixtures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/contributing.md | 7 +- docs/standalone-runner.md | 20 ++- microsoft/skills/review/al-code-review.md | 13 +- skills/do.md | 57 ++++++-- tools/Test-ReviewContract.ps1 | 171 ++++++++++++++++++++++ tools/Test-ReviewFixtures.ps1 | 1 + 6 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 tools/Test-ReviewContract.ps1 diff --git a/docs/contributing.md b/docs/contributing.md index 27da177..51f6d0f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -161,12 +161,15 @@ If PyYAML is not installed in your development environment, install it with ```powershell python .github\scripts\validate_frontmatter.py --root . pwsh .\tools\Test-ReviewFixtures.ps1 -Root . +pwsh .\tools\Test-ReviewContract.ps1 -Root . ``` The first command checks schema, sections, naming, sample references, and skill registration. The second checks that every review leaf has a valid -positive/clean sample pair. Neither proves a model will find every defect. -See [evaluation](../evaluation/README.md) for optional model-based scoring. +positive/clean sample pair. The third checks the cross-surface findings-report +contract and its bounded range-normalization cases. None proves a model will +find every defect. See [evaluation](../evaluation/README.md) for optional +model-based scoring. In the PR description, explain the mistake being prevented, supporting evidence, applicable BC versions, and why the chosen domain owns it. For a diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 61ecb55..5829e4e 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -52,10 +52,17 @@ only result. 4. When an action skill declares `sub-skills`, execute every relevant leaf as a discrete invocation. Leaves are independent and may be scheduled serially or concurrently. -5. Collect each complete findings-report into `sub-results` in the declared +5. Capture the exact Task return as the immutable raw audit payload and primary + transport. Preserve it unchanged in private artifacts or host logs. Before + the full DO acceptance gate, create a normalized candidate only for DO's + bounded optional-range case, record that normalization separately in private + telemetry, and accept the candidate only if the entire copy passes the + unchanged strict gate. The accepted report contains no undeclared telemetry + fields. +6. Collect each accepted findings-report into `sub-results` in the declared `sub-skills` order, not completion order. Run the super-skill self-review only after all leaves have finished. -6. Apply the DO composition, failure, deduplication, reference-integrity, and +7. Apply the DO composition, failure, deduplication, reference-integrity, and outcome rules. Return strict JSON before rendering it for people or another system. @@ -86,6 +93,15 @@ A compatible runner: - invokes every worklisted leaf exactly once unless a documented retry replaces a failed attempt; - keeps leaf contexts isolated and passes only the inputs they declare; +- preserves each raw Task return unchanged for audit and distinguishes it from + any normalized accepted copy; +- removes only an optional range whose positive integer bounds contain the + primary line but start before it, and only when the complete report has no + other defect and the finding has no `suggested-code`; +- records normalization only in private runner telemetry and never adds fields + to the findings-report; +- rejects reversed, invalid, or out-of-bounds ranges, range mismatches attached + to `suggested-code`, and every repair outside DO's bounded exception; - preserves every leaf report, including failed reports, in `sub-results`; - excludes unreliable findings from failed leaves and returns `partial` when only part of the review is reliable; diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 6a577ba..9239220 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -67,7 +67,7 @@ The Action step consists of **discrete leaf invocations**, not one combined gene - **Isolate leaf invocations when the host supports it.** Each sub-skill SHOULD run in a fresh model call or child context containing only its assigned source paths, READ/DO contracts, the leaf instructions, the complete bounded domain catalog per READ, and articles that leaf worklists. Preserve each catalog row's exact `path`; the leaf must copy references from that catalog. - **Keep run artifacts private.** Before dispatch, allocate a new GUID-named directory under the current session's artifact directory and a distinct scratch/report child directory for every leaf. Pass a leaf only its own assigned source paths and child directory, never the run root or sibling paths. A leaf MUST NOT discover, enumerate, read, modify, or delete sibling artifacts. Do not reuse a prior run directory, and do not clean up any run artifact until every leaf has finished and consolidation is complete. -- **Use the exact Task return as the report.** Capture each leaf's exact return as the primary transport and apply DO's consumer acceptance gate before rollup. Worker-side persistence of the same report in its private directory is optional and redundant; a missing report file does not invalidate an otherwise valid exact return. +- **Keep raw Task transport distinct from the accepted copy.** Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in the leaf's private artifacts or host log. Then apply DO's bounded pre-gate range normalization, when eligible, and its full consumer acceptance gate. The report accepted for rollup is the exact return when no normalization occurred, or the normalized candidate copy when DO permits it; worker-side persistence of another report file is optional and redundant. - **Treat automatic output spills as host-owned.** If the host reports that a Task return was automatically spilled, the coordinator MAY read that file read-only only at the exact path returned by the tool. Never modify, delete, enumerate around, or reuse an automatic spill path. Never bypass a content-exclusion or access denial. - Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source → Relevance → Worklist → Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report independently. - Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches). @@ -80,7 +80,7 @@ The Action step consists of **discrete leaf invocations**, not one combined gene For each sub-skill in the worklist: 1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`. -2. Capture the exact Task return and validate it against DO's consumer acceptance gate before accepting it. Preserve an invalid raw return unchanged in the leaf's private artifacts or host log; do not reconstruct or repair it. Record a separate failed validation result with no findings for rollup. +2. Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in the leaf's private artifacts or host log before deriving a candidate. Apply only DO's bounded pre-gate normalization: when the complete raw report has no other defect, a finding has positive-integer `line`, `start-line`, and `end-line`, `start-line <= line <= end-line`, `start-line != line`, and no `suggested-code` field, copy the complete report and remove only that finding's optional `location.range`. Record the normalization separately in private run telemetry or artifacts, never in the findings-report. Validate the entire candidate through DO's existing strict acceptance gate. Accept the exact return when unchanged or the normalized candidate when it passes; otherwise record a separate failed validation result with no findings for rollup. Do not reconstruct JSON, infer fields, alter paths or references, clamp lines, normalize reversed or out-of-bounds ranges, remove a range associated with `suggested-code`, or salvage individual findings. 3. Append the accepted findings-report, or the separate failed validation result, to `sub-results`. If its `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`. 4. Otherwise, compare each entry from the sub-skill's `findings[]` with findings already rolled up. Two findings are duplicates when they point to the same file and overlapping line/range and prescribe materially the same correction, even when their knowledge-file IDs differ. Merge duplicates instead of appending both: keep the more specific domain owner, preserve that finding's optional `domain` field verbatim (including its absence), use its reference as `references[0]` and therefore as `id`, append the other references as supporting references, keep the highest severity and confidence justified by either report, and preserve one self-contained message. Article and leaf ownership notes decide specificity; do not choose by execution order. 5. Append each non-duplicate finding, setting `from-sub-skill` to the sub-skill's `skill.id` and preserving its optional `domain` field verbatim, including its absence. For non-citation findings (those whose `id` is a skill-defined slug rather than a reference path), prefix `id` with `:` to prevent collisions across sub-skills. Other finding fields are preserved. @@ -126,9 +126,12 @@ Calculate `summary.counts` from the final top-level `findings[]`, after failed s Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."* Before emitting the rollup, apply DO's consumer acceptance gate to every nested -and top-level finding. Treat an invalid sub-result as failed and exclude all of -its findings from the top-level rollup. Preserve its exact raw payload -separately; never reconstruct it into a success-shaped report. +and top-level finding. A leaf's nested report is its accepted exact return or +its accepted normalized candidate copy; its exact Task return remains the +separate immutable raw audit payload. Treat an invalid sub-result as failed and +exclude all of its findings from the top-level rollup. Never reconstruct it +into a success-shaped report or perform normalization beyond DO's bounded +exception. ## Output diff --git a/skills/do.md b/skills/do.md index f86509b..9abdf58 100644 --- a/skills/do.md +++ b/skills/do.md @@ -161,13 +161,49 @@ AL source is the common failure case. Quoted identifiers (for example `Rec."No." ### Consumer acceptance gate -The exact action-skill return is the primary report transport. Before accepting -it as a findings-report, a coordinator or host MUST validate it -deterministically: +Capture the exact Task return as the immutable raw audit payload and primary +transport. Preserve it unchanged in private run artifacts or host logs before +creating any derived value. The accepted findings-report is either that exact +return or the bounded normalized candidate described below; the raw audit +payload never changes. -1. Parse the exact return as strict JSON and validate every required field, - enum, type, conditional requirement, summary count, coverage value, and - leaf/super-skill constraint against this output contract. +Before the full acceptance gate, a coordinator MAY create a normalized +candidate copy only through this deterministic procedure: + +1. Parse the exact return as strict JSON and provisionally check the complete + report without mutating it. Every acceptance rule below MUST already pass + except for one or more findings whose optional `location.range` has + `start-line != line`. +2. Each such finding is eligible only when `location.line`, + `location.range.start-line`, and `location.range.end-line` are positive + integers, `start-line <= line <= end-line`, and the finding does not contain + the `suggested-code` field. Field presence disqualifies normalization even + if its value is empty because suggested code may be bound to the reported + range. +3. Deep-copy the complete parsed report. In the candidate copy, remove only + `location.range` from every eligible finding. Retain `location.line` and + every other value unchanged. Do not add normalization metadata to the + findings-report. +4. Record each removed range separately in private run telemetry or artifacts, + associated with the immutable raw audit payload. This record is + runner-owned and is not part of the declared report schema. +5. Validate the entire normalized candidate with the existing full consumer + acceptance gate below. Only a candidate that passes every rule becomes the + accepted copy used for rollup. If any other validation defect exists, or + full validation fails, discard the candidate, preserve the raw payload, and + fail the complete leaf as before. + +This exception does not infer missing fields, alter references or paths, clamp +line numbers, repair JSON, normalize a reversed or out-of-bounds range, remove +a range from a finding containing `suggested-code`, or salvage arbitrary +individual findings. + +Before accepting either the exact return or an eligible normalized candidate +as a findings-report, a coordinator or host MUST validate it deterministically: + +1. Validate every required field, enum, type, conditional requirement, summary + count, coverage value, and leaf/super-skill constraint against this output + contract. 2. For every knowledge-backed finding, verify each `references[].path` is an exact repo-relative knowledge path that exists in the live BCQuality snapshot, and verify `findings[].id` exactly equals @@ -183,11 +219,10 @@ deterministically: Validation failure invalidates the complete return; consumers MUST NOT salvage individual findings, infer missing fields, reconstruct JSON, clamp ranges, rewrite paths, or otherwise silently repair model output. Preserve the invalid -raw payload unchanged in private run artifacts or host logs. Record a separate -failed validation result for that leaf with no findings, and derive the -super-skill outcome as `partial` or `failed` using the normal rollup rules. -Worker-side report-file persistence is optional and never replaces validation -of the exact return. +raw payload unchanged. Record a separate failed validation result for that leaf +with no findings, and derive the super-skill outcome as `partial` or `failed` +using the normal rollup rules. Worker-side report-file persistence is optional +and never replaces validation of the accepted exact or normalized copy. ### Field semantics diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1 new file mode 100644 index 0000000..1d40058 --- /dev/null +++ b/tools/Test-ReviewContract.ps1 @@ -0,0 +1,171 @@ +<# +.SYNOPSIS + Validates the bounded leaf-range normalization contract. + +.DESCRIPTION + BCQuality has no executable findings-report consumer. These assertions keep + the normative DO contract, AL coordinator, and standalone runner aligned + while exercising the exact normalization predicate against representative + safe and ambiguous inputs. +#> +[CmdletBinding()] +param( + [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Root = (Resolve-Path -LiteralPath $Root).Path + +function Assert-True { + param( + [bool] $Condition, + [string] $Message + ) + + if (-not $Condition) { + throw "Assertion failed: $Message" + } +} + +function Assert-Contains { + param( + [string] $Text, + [string] $Expected, + [string] $Message + ) + + Assert-True $Text.Contains($Expected) $Message +} + +function Test-PositiveInteger { + param([object] $Value) + + if (($null -eq $Value) -or ($Value -is [bool]) -or ($Value -isnot [ValueType])) { + return $false + } + + $number = [double]$Value + return [double]::IsFinite($number) -and ($number -gt 0) -and ([math]::Truncate($number) -eq $number) +} + +function Test-RangeNormalizationEligibility { + param([pscustomobject] $Finding) + + if ($Finding.PSObject.Properties.Name -contains 'suggested-code') { + return $false + } + if (-not ($Finding.PSObject.Properties.Name -contains 'location')) { + return $false + } + if (-not ($Finding.location.PSObject.Properties.Name -contains 'line')) { + return $false + } + if (-not ($Finding.location.PSObject.Properties.Name -contains 'range')) { + return $false + } + + $range = $Finding.location.range + if (-not ($range.PSObject.Properties.Name -contains 'start-line') -or + -not ($range.PSObject.Properties.Name -contains 'end-line')) { + return $false + } + + $line = $Finding.location.line + $startLine = $range.'start-line' + $endLine = $range.'end-line' + if (-not (Test-PositiveInteger $line) -or + -not (Test-PositiveInteger $startLine) -or + -not (Test-PositiveInteger $endLine)) { + return $false + } + + return ($startLine -le $line) -and ($line -le $endLine) -and ($startLine -ne $line) +} + +$transportSentence = 'Capture the exact Task return as the immutable raw audit payload and primary transport.' +$doContract = Get-Content -LiteralPath (Join-Path $Root 'skills/do.md') -Raw +$coordinatorContract = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-code-review.md') -Raw +$runnerContract = Get-Content -LiteralPath (Join-Path $Root 'docs/standalone-runner.md') -Raw + +foreach ($surface in @( + [pscustomobject]@{ Name = 'DO'; Text = ($doContract -replace '\s+', ' ') } + [pscustomobject]@{ Name = 'AL coordinator'; Text = ($coordinatorContract -replace '\s+', ' ') } + [pscustomobject]@{ Name = 'standalone runner'; Text = ($runnerContract -replace '\s+', ' ') } +)) { + Assert-Contains $surface.Text $transportSentence "$($surface.Name) preserves exact Task transport wording" +} + +$normalizedDoContract = $doContract -replace '\s+', ' ' +foreach ($expected in @( + 'positive integers', + 'start-line <= line <= end-line', + 'does not contain the `suggested-code` field', + 'remove only', + 'private run telemetry or artifacts', + 'Validate the entire normalized candidate', + 'If any other validation defect exists', + 'salvage arbitrary individual findings' +)) { + Assert-Contains $normalizedDoContract $expected "DO documents '$expected'" +} + +$cases = @( + [pscustomobject]@{ + Name = 'contained mismatched range without suggested code' + Expected = $true + Finding = '{"message":"keep me","location":{"file":"src/codeunit.al","line":37,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'aligned range' + Expected = $false + Finding = '{"location":{"line":37,"range":{"start-line":37,"end-line":38}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'suggested code present' + Expected = $false + Finding = '{"location":{"line":37,"range":{"start-line":36,"end-line":38}},"suggested-code":""}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'line outside range' + Expected = $false + Finding = '{"location":{"line":39,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'reversed range' + Expected = $false + Finding = '{"location":{"line":37,"range":{"start-line":38,"end-line":36}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'zero bound' + Expected = $false + Finding = '{"location":{"line":1,"range":{"start-line":0,"end-line":2}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'fractional primary line' + Expected = $false + Finding = '{"location":{"line":37.5,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json + } + [pscustomobject]@{ + Name = 'missing end line' + Expected = $false + Finding = '{"location":{"line":37,"range":{"start-line":36}}}' | ConvertFrom-Json + } +) + +foreach ($case in $cases) { + $actual = Test-RangeNormalizationEligibility $case.Finding + Assert-True ($actual -eq $case.Expected) "$($case.Name) eligibility is $($case.Expected)" +} + +$rawFinding = $cases[0].Finding +$candidateFinding = $rawFinding | ConvertTo-Json -Depth 10 | ConvertFrom-Json +$candidateFinding.location.PSObject.Properties.Remove('range') + +Assert-True ($rawFinding.location.PSObject.Properties.Name -contains 'range') 'raw finding remains unchanged' +Assert-True (-not ($candidateFinding.location.PSObject.Properties.Name -contains 'range')) 'candidate removes only the optional range' +Assert-True ($candidateFinding.location.line -eq $rawFinding.location.line) 'candidate preserves the primary line' +Assert-True ($candidateFinding.message -ceq $rawFinding.message) 'candidate preserves all other finding content' + +Write-Output "Review contract validation passed ($($cases.Count) normalization cases)." diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index a9912b7..c4b0bf1 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -434,6 +434,7 @@ if ($PrepareDirectory) { } if (-not $ResultsPath -and -not $ResultsDirectory) { + & (Join-Path $PSScriptRoot 'Test-ReviewContract.ps1') -Root $Root Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $($leafDomains.Count) leaf domains." -ForegroundColor Green exit 0 } From 45ac371e7a8252f2ce7176060a640ea9506b320c Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:42:45 +0200 Subject: [PATCH 09/51] Add AL-focused AppSource validation guidance (#142) * knowledge(appsource): add AL validation guidance * Address Marketplace review feedback * Address remaining Marketplace review feedback * Align AppSource review applicability outcome --- .../define-profiles-as-al-objects.bad.al | 12 +++++++++ .../define-profiles-as-al-objects.good.al | 6 +++++ .../define-profiles-as-al-objects.md | 26 +++++++++++++++++++ .../do-not-hard-code-time-zone-offsets.bad.al | 7 +++++ ...do-not-hard-code-time-zone-offsets.good.al | 7 +++++ .../do-not-hard-code-time-zone-offsets.md | 26 +++++++++++++++++++ ...-web-service-paths-free-of-ui-calls.bad.al | 21 +++++++++++++++ ...web-service-paths-free-of-ui-calls.good.al | 15 +++++++++++ ...keep-web-service-paths-free-of-ui-calls.md | 26 +++++++++++++++++++ ...on-actions-with-addfirst-or-addlast.bad.al | 15 +++++++++++ ...n-actions-with-addfirst-or-addlast.good.al | 15 +++++++++++ ...ension-actions-with-addfirst-or-addlast.md | 26 +++++++++++++++++++ ...category-on-searchable-entry-points.bad.al | 20 ++++++++++++++ ...ategory-on-searchable-entry-points.good.al | 21 +++++++++++++++ ...sagecategory-on-searchable-entry-points.md | 26 +++++++++++++++++++ .../use-invariant-date-literals.bad.al | 10 +++++++ .../use-invariant-date-literals.good.al | 7 +++++ .../appsource/use-invariant-date-literals.md | 26 +++++++++++++++++++ .../skills/review/al-appsource-review.md | 14 +++++++--- 19 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 community/knowledge/appsource/define-profiles-as-al-objects.bad.al create mode 100644 community/knowledge/appsource/define-profiles-as-al-objects.good.al create mode 100644 community/knowledge/appsource/define-profiles-as-al-objects.md create mode 100644 community/knowledge/appsource/do-not-hard-code-time-zone-offsets.bad.al create mode 100644 community/knowledge/appsource/do-not-hard-code-time-zone-offsets.good.al create mode 100644 community/knowledge/appsource/do-not-hard-code-time-zone-offsets.md create mode 100644 community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.bad.al create mode 100644 community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.good.al create mode 100644 community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.md create mode 100644 community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.bad.al create mode 100644 community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.good.al create mode 100644 community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.md create mode 100644 community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.bad.al create mode 100644 community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.good.al create mode 100644 community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.md create mode 100644 community/knowledge/appsource/use-invariant-date-literals.bad.al create mode 100644 community/knowledge/appsource/use-invariant-date-literals.good.al create mode 100644 community/knowledge/appsource/use-invariant-date-literals.md diff --git a/community/knowledge/appsource/define-profiles-as-al-objects.bad.al b/community/knowledge/appsource/define-profiles-as-al-objects.bad.al new file mode 100644 index 0000000..20438c8 --- /dev/null +++ b/community/knowledge/appsource/define-profiles-as-al-objects.bad.al @@ -0,0 +1,12 @@ +codeunit 50100 "Rental Profile Install" +{ + Subtype = Install; + + trigger OnInstallAppPerDatabase() + var + RentalProfile: Record Profile; + begin + RentalProfile.Init(); + RentalProfile.Insert(true); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/define-profiles-as-al-objects.good.al b/community/knowledge/appsource/define-profiles-as-al-objects.good.al new file mode 100644 index 0000000..23ed6bf --- /dev/null +++ b/community/knowledge/appsource/define-profiles-as-al-objects.good.al @@ -0,0 +1,6 @@ +profile "RENTAL MANAGER" +{ + Caption = 'Rental Manager'; + Description = 'Manages rental agreements and equipment availability.'; + RoleCenter = "Business Manager Role Center"; +} \ No newline at end of file diff --git a/community/knowledge/appsource/define-profiles-as-al-objects.md b/community/knowledge/appsource/define-profiles-as-al-objects.md new file mode 100644 index 0000000..6f34aee --- /dev/null +++ b/community/knowledge/appsource/define-profiles-as-al-objects.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [profile-object, profile-table, install-codeunit, role-center, page-customization] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Define profiles as AL objects + +## Description + +Profiles delivered by a Marketplace extension must be declared as AL `profile` objects. A profile object is validated with its Role Center and page customizations when the extension is compiled and is registered through extension synchronization. Inserting profile-table records from install or setup code bypasses that object lifecycle. + +## Best Practice + +Declare each app-owned profile with the `profile` object and set its `RoleCenter`, user-facing caption, and optional customizations in AL. Let installation and synchronization register the object. + +See sample: [`define-profiles-as-al-objects.good.al`](define-profiles-as-al-objects.good.al). + +## Anti Pattern + +Install, upgrade, or setup code that creates an app-owned profile by inserting a `Profile` table record. Detection signal: a `Record Profile` variable followed by `Insert` in profile provisioning code. + +See sample: [`define-profiles-as-al-objects.bad.al`](define-profiles-as-al-objects.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.bad.al b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.bad.al new file mode 100644 index 0000000..4ec258b --- /dev/null +++ b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.bad.al @@ -0,0 +1,7 @@ +codeunit 50100 "Rental Audit" +{ + procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement") + begin + RentalAgreement."Created At" := CurrentDateTime() + 7200000; + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.good.al b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.good.al new file mode 100644 index 0000000..c4e155e --- /dev/null +++ b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.good.al @@ -0,0 +1,7 @@ +codeunit 50100 "Rental Audit" +{ + procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement") + begin + RentalAgreement."Created At" := CurrentDateTime(); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.md b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.md new file mode 100644 index 0000000..935b4d5 --- /dev/null +++ b/community/knowledge/appsource/do-not-hard-code-time-zone-offsets.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [datetime, time-zone, utc, currentdatetime, locale, regional-settings] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not hard-code time-zone offsets + +## Description + +Marketplace extensions run for users and services in many time zones. Adding a fixed offset to a `DateTime` assumes one locale, ignores daylight-saving transitions, and changes an absolute timestamp into an incorrect value for other regions. + +## Best Practice + +Store and compare `DateTime` values without a manually applied regional offset. Business Central stores `DateTime` values in UTC and presents them according to the client time zone. Keep service contracts time-zone explicit and perform a conversion only when the business requirement identifies a particular zone. + +See sample: [`do-not-hard-code-time-zone-offsets.good.al`](do-not-hard-code-time-zone-offsets.good.al). + +## Anti Pattern + +Adding or subtracting a fixed duration solely to convert `CurrentDateTime` or another timestamp to an assumed local time. Detection signals include fixed hour-sized millisecond values near `DateTime` assignments and comments naming a specific time zone; confirm the duration is an offset rather than a legitimate deadline or schedule interval. + +See sample: [`do-not-hard-code-time-zone-offsets.bad.al`](do-not-hard-code-time-zone-offsets.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.bad.al b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.bad.al new file mode 100644 index 0000000..d062837 --- /dev/null +++ b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.bad.al @@ -0,0 +1,21 @@ +codeunit 50100 "Rental Service" +{ + [ServiceEnabled] + procedure CloseAgreement(AgreementNo: Code[20]): Boolean + var + RentalAgreement: Record "Rental Agreement"; + begin + if not Confirm(CloseAgreementQst, false, AgreementNo) then + exit(false); + + RentalAgreement.Get(AgreementNo); + RentalAgreement.Closed := true; + RentalAgreement.Modify(true); + Message(AgreementClosedMsg, AgreementNo); + exit(true); + end; + + var + CloseAgreementQst: Label 'Close rental agreement %1?'; + AgreementClosedMsg: Label 'Rental agreement %1 was closed.'; +} \ No newline at end of file diff --git a/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.good.al b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.good.al new file mode 100644 index 0000000..c990850 --- /dev/null +++ b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.good.al @@ -0,0 +1,15 @@ +codeunit 50100 "Rental Service" +{ + [ServiceEnabled] + procedure CloseAgreement(AgreementNo: Code[20]): Boolean + var + RentalAgreement: Record "Rental Agreement"; + begin + if not RentalAgreement.Get(AgreementNo) then + exit(false); + + RentalAgreement.Closed := true; + RentalAgreement.Modify(true); + exit(true); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.md b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.md new file mode 100644 index 0000000..45b06b2 --- /dev/null +++ b/community/knowledge/appsource/keep-web-service-paths-free-of-ui-calls.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [web-service, serviceenabled, guiallowed, message, confirm, strmenu] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep web-service paths free of UI calls + +## Description + +Pages and codeunits exposed as web services run without an interactive client. Calls that require a UI callback, including `Confirm`, `StrMenu`, and modal pages, can terminate the service request instead of completing the operation. `Message` does not raise the callback error: the message is suppressed and logged, making it ineffective for communicating a service result. + +## Best Practice + +Keep service entry points and every procedure they call free of interactive UI. Return data through the service contract and report validation failures with service-safe error handling. When a procedure is shared with an interactive client, guard UI-only behavior with `GuiAllowed` while preserving the underlying operation. + +See sample: [`keep-web-service-paths-free-of-ui-calls.good.al`](keep-web-service-paths-free-of-ui-calls.good.al). + +## Anti Pattern + +A web-service-exposed page or codeunit calls an interactive UI method directly or indirectly. Detection signals include `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, and confirmation-dialog pages on a service call path. Treat `Message` as suppressed and ineffective, not as a callback failure. Do not flag a controlled `Error` solely because it returns a service fault. + +See sample: [`keep-web-service-paths-free-of-ui-calls.bad.al`](keep-web-service-paths-free-of-ui-calls.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.bad.al b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.bad.al new file mode 100644 index 0000000..81e8f83 --- /dev/null +++ b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.bad.al @@ -0,0 +1,15 @@ +pageextension 50100 "Rental Customer List" extends "Customer List" +{ + actions + { + addafter("Customer Ledger Entries") + { + action(OpenRentalAgreements) + { + ApplicationArea = All; + Caption = 'Rental Agreements'; + RunObject = page "Rental Agreement List"; + } + } + } +} \ No newline at end of file diff --git a/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.good.al b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.good.al new file mode 100644 index 0000000..155a211 --- /dev/null +++ b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.good.al @@ -0,0 +1,15 @@ +pageextension 50100 "Rental Customer List" extends "Customer List" +{ + actions + { + addlast(Processing) + { + action(OpenRentalAgreements) + { + ApplicationArea = All; + Caption = 'Rental Agreements'; + RunObject = page "Rental Agreement List"; + } + } + } +} \ No newline at end of file diff --git a/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.md b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.md new file mode 100644 index 0000000..4967645 --- /dev/null +++ b/community/knowledge/appsource/place-page-extension-actions-with-addfirst-or-addlast.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [pageextension, actions, addfirst, addlast, addbefore, addafter] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Place page extension actions with addfirst or addlast + +## Description + +Place new page-extension actions at the beginning or end of an existing action group with `addfirst` or `addlast`. Anchoring a new action relative to a specific base-app action with `addbefore` or `addafter` couples the extension to an implementation detail that can move or disappear between Business Central releases. + +## Best Practice + +Choose the semantic action area or group and append or prepend the extension's actions. This keeps placement deterministic without depending on the continued existence of one neighboring action. + +See sample: [`place-page-extension-actions-with-addfirst-or-addlast.good.al`](place-page-extension-actions-with-addfirst-or-addlast.good.al). + +## Anti Pattern + +Using `addbefore` or `addafter` to place newly added actions next to a specific action from another app. The syntax is valid AL, but the placement anchor is brittle for a Marketplace extension. + +See sample: [`place-page-extension-actions-with-addfirst-or-addlast.bad.al`](place-page-extension-actions-with-addfirst-or-addlast.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.bad.al b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.bad.al new file mode 100644 index 0000000..d648f4a --- /dev/null +++ b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.bad.al @@ -0,0 +1,20 @@ +page 50100 "Rental Agreement List" +{ + PageType = List; + SourceTable = "Rental Agreement"; + ApplicationArea = All; + + layout + { + area(Content) + { + repeater(Agreements) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + } + } + } + } +} \ No newline at end of file diff --git a/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.good.al b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.good.al new file mode 100644 index 0000000..97fe848 --- /dev/null +++ b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.good.al @@ -0,0 +1,21 @@ +page 50100 "Rental Agreement List" +{ + PageType = List; + SourceTable = "Rental Agreement"; + ApplicationArea = All; + UsageCategory = Lists; + + layout + { + area(Content) + { + repeater(Agreements) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + } + } + } + } +} \ No newline at end of file diff --git a/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.md b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.md new file mode 100644 index 0000000..9241236 --- /dev/null +++ b/community/knowledge/appsource/set-usagecategory-on-searchable-entry-points.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [usagecategory, tell-me, search, page, report, discoverability] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set UsageCategory on searchable entry points + +## Description + +Pages and reports that users are expected to open directly must set `UsageCategory`. Without it, the object is absent from Tell Me and users cannot bookmark it from the web client. Supporting objects such as list parts, dialogs, API pages, and objects reached only through another page do not need to be searchable entry points. + +## Best Practice + +Set `UsageCategory` to the category that matches the entry point, such as `Lists`, `Tasks`, `ReportsAndAnalysis`, or `Documents`. Also set the appropriate object-level `ApplicationArea` so search results respect feature visibility. + +See sample: [`set-usagecategory-on-searchable-entry-points.good.al`](set-usagecategory-on-searchable-entry-points.good.al). + +## Anti Pattern + +A user-facing page or report intended for direct discovery omits `UsageCategory` or sets it to `None`. Do not infer intent from the object type alone; require evidence that the object is a direct user entry point. + +See sample: [`set-usagecategory-on-searchable-entry-points.bad.al`](set-usagecategory-on-searchable-entry-points.bad.al). \ No newline at end of file diff --git a/community/knowledge/appsource/use-invariant-date-literals.bad.al b/community/knowledge/appsource/use-invariant-date-literals.bad.al new file mode 100644 index 0000000..437083d --- /dev/null +++ b/community/knowledge/appsource/use-invariant-date-literals.bad.al @@ -0,0 +1,10 @@ +codeunit 50100 "Rental Period Defaults" +{ + procedure GetPolicyStartDate(): Date + var + PolicyStartDate: Date; + begin + Evaluate(PolicyStartDate, '01/31/2025'); + exit(PolicyStartDate); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/use-invariant-date-literals.good.al b/community/knowledge/appsource/use-invariant-date-literals.good.al new file mode 100644 index 0000000..07b19c4 --- /dev/null +++ b/community/knowledge/appsource/use-invariant-date-literals.good.al @@ -0,0 +1,7 @@ +codeunit 50100 "Rental Period Defaults" +{ + procedure GetPolicyStartDate(): Date + begin + exit(20250131D); + end; +} \ No newline at end of file diff --git a/community/knowledge/appsource/use-invariant-date-literals.md b/community/knowledge/appsource/use-invariant-date-literals.md new file mode 100644 index 0000000..c38476c --- /dev/null +++ b/community/knowledge/appsource/use-invariant-date-literals.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: appsource +keywords: [date-literal, invariant-date, dateformula, localization, appsourcecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use invariant date literals + +## Description + +Write fixed dates in AL with the invariant `yyyymmddD` syntax. A locale-dependent text value parsed with `Evaluate` can change meaning or fail under another user's regional settings, which makes the Marketplace extension unreliable across markets. + +## Best Practice + +Represent a fixed date directly as an AL date literal, such as `20250131D`. Use `CalcDate` with a date formula when the value is relative rather than fixed. + +See sample: [`use-invariant-date-literals.good.al`](use-invariant-date-literals.good.al). + +## Anti Pattern + +Building a fixed date by passing localized text such as `01/02/2025` to `Evaluate`. Detection signal: `Evaluate` converting a hard-coded or label-backed formatted string into a `Date`. + +See sample: [`use-invariant-date-literals.bad.al`](use-invariant-date-literals.bad.al). \ No newline at end of file diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index c851ea2..3f6a221 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source and app metadata changes against the `appsource` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply to AppSource-facing metadata and complete permission coverage that requires repository context. Mechanical AppSourceCop diagnostics are intentionally outside this skill. The skill returns `not-applicable` when none of those surfaces apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. AppSource findings are narrow by design — they apply to Marketplace-facing metadata, complete permission coverage that requires repository context, and contextual AL constructs covered by Marketplace submission requirements. Mechanical compiler and analyzer diagnostics are intentionally outside this skill. The skill returns `not-applicable` when none of those surfaces apply. ## Source @@ -37,14 +37,20 @@ Discard files that are not applicable. Retain conditionally applicable files (an Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: -- The changed files and AL object types — especially `app.json`, permission-set objects, setup and usage entry points, and AppSource-facing help metadata. -- Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). +- The changed files and AL object types — especially `app.json`, permission-set and profile objects, setup and usage entry points, service-enabled procedures, user-facing pages and reports, and AppSource-facing help metadata. +- Tokens extracted from the diff that relate to AppSource (`permissionset`, `Assignable`, `Permissions`, `SUPER`, `tabledata`, `execute`, `profile`, `Record Profile`, `Evaluate`, `Date`, `DateTime`, `CurrentDateTime`, `UsageCategory`, `PageType`, `addfirst`, `addlast`, `addbefore`, `addafter`, `ServiceEnabled`, `GuiAllowed`, `Message`, `Confirm`, `StrMenu`, `RunModal`, `app.json`, `help`, `ContextSensitiveHelpPage`, `Copilot`, `https`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no AppSource-related source or metadata changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. - The app has no assignable permission set covering its setup and usage paths, omits visible object/tabledata grants, or requires `SUPER` for normal operation — `permission-sets-cover-setup-and-usage-without-super`. Require repository-level app context; one isolated permission-set object cannot prove complete coverage. +- Install, upgrade, or setup code provisions an app-owned profile through `Record Profile` and `Insert` instead of declaring a `profile` object — `define-profiles-as-al-objects`. +- A hard-coded or label-backed formatted string is converted to `Date` with `Evaluate` — `use-invariant-date-literals`. Do not select this article for variable external input whose format must be validated at runtime. +- A page extension uses `addbefore` or `addafter` to place a newly added action relative to a specific action owned by another app — `place-page-extension-actions-with-addfirst-or-addlast`. Do not flag those keywords in layouts or placement relative to an action owned by the same extension. +- A page or codeunit web-service entry point, including a `[ServiceEnabled]` procedure, contains or reaches `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, or a confirmation-dialog page without an effective non-GUI guard — `keep-web-service-paths-free-of-ui-calls`. Treat `Message` as suppressed and logged, making it ineffective as a service response; treat the other UI calls as callback-failure risks. Do not treat a controlled `Error` as interactive UI solely because it returns a service fault. +- A page or report that repository context identifies as a direct user entry point omits `UsageCategory` or sets it to `None` — `set-usagecategory-on-searchable-entry-points`. Do not select this article based only on object type; exclude supporting parts, dialogs, API pages, and objects intentionally reached through another page. +- A `DateTime` assignment adds or subtracts a fixed duration to represent an assumed regional offset — `do-not-hard-code-time-zone-offsets`. Require contextual evidence such as an hour-sized constant, offset-oriented name, or time-zone comment; do not flag deadlines, schedules, or elapsed-time calculations. - For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. @@ -75,7 +81,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable AppSource knowledge survived filtering. -- `not-applicable` — the diff touches no AppSource permission or app-metadata surface. +- `not-applicable` — the diff touches no Marketplace-related source, permission, or app-metadata surface. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. From 8c26ba4e7640fd613e60a40734c744f0f3bda40b Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:44:30 +0200 Subject: [PATCH 10/51] Add Job Queue reliability and scheduling guidance (#148) * knowledge(performance): add job queue reliability guidance * Address Job Queue review feedback * Address Job Queue routing review feedback * Encode job queue conflict in fixtures --- evaluation/README.md | 4 +- evaluation/review-fixtures.json | 10 ++- ...nt-inside-write-transaction-holds-locks.md | 2 +- ...ry-code-serializes-conflicting-jobs.bad.al | 11 +++ ...y-code-serializes-conflicting-jobs.good.al | 18 +++++ ...tegory-code-serializes-conflicting-jobs.md | 28 +++++++ ...external-effects-must-be-idempotent.bad.al | 57 ++++++++++++++ ...xternal-effects-must-be-idempotent.good.al | 65 ++++++++++++++++ ...eue-external-effects-must-be-idempotent.md | 30 ++++++++ ...-queue-handlers-must-not-require-ui.bad.al | 17 ++++ ...queue-handlers-must-not-require-ui.good.al | 14 ++++ .../job-queue-handlers-must-not-require-ui.md | 28 +++++++ ...ue-handlers-must-propagate-failures.bad.al | 23 ++++++ ...e-handlers-must-propagate-failures.good.al | 16 ++++ ...-queue-handlers-must-propagate-failures.md | 28 +++++++ ...-on-hold-does-not-stop-running-work.bad.al | 18 +++++ ...on-hold-does-not-stop-running-work.good.al | 49 ++++++++++++ ...ueue-on-hold-does-not-stop-running-work.md | 28 +++++++ ...ncompanyopen-subscribers-must-not-do-io.md | 2 +- ...ed-task-id-to-avoid-duplicate-tasks.bad.al | 15 ++++ ...d-task-id-to-avoid-duplicate-tasks.good.al | 23 ++++++ ...eduled-task-id-to-avoid-duplicate-tasks.md | 28 +++++++ .../skills/review/al-performance-review.md | 8 +- tools/Test-ReviewFixtures.ps1 | 77 ++++++++++++++----- 24 files changed, 574 insertions(+), 25 deletions(-) create mode 100644 microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.good.al create mode 100644 microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md create mode 100644 microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.good.al create mode 100644 microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.good.al create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.good.al create mode 100644 microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md create mode 100644 microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.bad.al create mode 100644 microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.good.al create mode 100644 microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md create mode 100644 microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al create mode 100644 microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.good.al create mode 100644 microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md diff --git a/evaluation/README.md b/evaluation/README.md index 7be55b4..2125ce3 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -2,7 +2,7 @@ The evaluation is convention-driven. The harness discovers every `/skills/review/al--review.md` leaf across the enabled `microsoft`, `community`, and `custom` layers. Duplicate domains resolve with `custom > community > microsoft` precedence. For each selected leaf, the harness finds paired knowledge across the same layers, applies the same precedence to duplicate article slugs, selects the first article (by filename) with both `.bad.al` and `.good.al` companions, and derives the expected positive and clean control automatically. Adding a conforming leaf requires no scoring-contract edit. -`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different article or add context when the generic convention cannot express a scenario. It should remain empty in the normal case. +`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different `article`, add context when the generic convention cannot express a scenario, or use an `articles` array when one domain needs explicit regression coverage for several paired articles. Specify either `article` or `articles`, not both. The first selected article retains the stable `-bad` and `-good` manifest IDs; additional articles use slug-qualified IDs. Overrides should remain empty in the normal case. Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer. @@ -26,7 +26,7 @@ This credential-free check proves every selected leaf maps to a same-named knowl 2. For a fast/small model, use one fresh invocation per `request-case-*.json`. Each request embeds the exact leaf instructions, that domain's candidate index rows with authoritative paths, and one opaque case. The model opens only matching articles and copies finding IDs from `candidateArticles[].path`. Save each response with the matching `result-case-*.json` name in the same directory. - `request-.json` files provide optional two-case leaf batches and identify the selected layer-owned skill path; save those as `result-.json`. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile. + `request-.json` files provide optional leaf batches containing every selected case for that domain and identify the selected layer-owned skill path; save those as `result-.json`. A normal convention-selected domain has one bad/good pair, while an `articles` override contributes one pair per listed article. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile. 3. Save only this result shape: diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index e11508a..352fccf 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -17,7 +17,15 @@ "article": "set-defaultimplementation-on-enum" }, "performance": { - "article": "use-isempty-for-existence-check" + "articles": [ + "use-isempty-for-existence-check", + "job-queue-category-code-serializes-conflicting-jobs", + "job-queue-external-effects-must-be-idempotent", + "job-queue-handlers-must-not-require-ui", + "job-queue-handlers-must-propagate-failures", + "job-queue-on-hold-does-not-stop-running-work", + "store-scheduled-task-id-to-avoid-duplicate-tasks" + ] }, "privacy": { "article": "no-pii-in-telemetry-message-string" diff --git a/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md index 21a2c04..88d0ff7 100644 --- a/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md +++ b/microsoft/knowledge/performance/httpclient-inside-write-transaction-holds-locks.md @@ -17,7 +17,7 @@ The first database write opens an AL write transaction that the runtime holds un ## Best Practice -Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. Make the external operation idempotent because a failure after a successful HTTP response can cause the work item to be retried. +Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. The separate retry-safety requirement is covered by `job-queue-external-effects-must-be-idempotent.md`. A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood. diff --git a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.bad.al b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.bad.al new file mode 100644 index 0000000..fbd0b39 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.bad.al @@ -0,0 +1,11 @@ +codeunit 50113 "Job Queue Category Bad" +{ + procedure ConfigureJobsForSharedExclusiveResource(var SalesPostingJob: Record "Job Queue Entry"; var PurchasePostingJob: Record "Job Queue Entry"; ExclusiveResourceId: Text[250]) + begin + // Both jobs update the same posting resources, but nothing prevents overlap. + SalesPostingJob.Validate("Parameter String", ExclusiveResourceId); + PurchasePostingJob.Validate("Parameter String", ExclusiveResourceId); + SalesPostingJob.Validate("Job Queue Category Code", ''); + PurchasePostingJob.Validate("Job Queue Category Code", ''); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.good.al b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.good.al new file mode 100644 index 0000000..f77200b --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.good.al @@ -0,0 +1,18 @@ +codeunit 50113 "Job Queue Category Good" +{ + procedure ConfigureJobsForSharedExclusiveResource(var SalesPostingJob: Record "Job Queue Entry"; var PurchasePostingJob: Record "Job Queue Entry"; ExclusiveResourceId: Text[250]) + var + JobQueueCategory: Record "Job Queue Category"; + begin + if not JobQueueCategory.Get('POSTING') then begin + JobQueueCategory.Code := 'POSTING'; + JobQueueCategory.Insert(); + end; + + // The shared category lets only one conflicting posting job run at a time. + SalesPostingJob.Validate("Parameter String", ExclusiveResourceId); + PurchasePostingJob.Validate("Parameter String", ExclusiveResourceId); + SalesPostingJob.Validate("Job Queue Category Code", 'POSTING'); + PurchasePostingJob.Validate("Job Queue Category Code", 'POSTING'); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md new file mode 100644 index 0000000..d92647e --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, category-code, concurrency, waiting, serialization, locking] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use a job queue category to serialize conflicting jobs + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Different job queue entries can run at the same time. When two jobs update the same exclusive resource, concurrent execution can cause lock contention, deadlocks, or conflicting results. Within one company, entries with the same Job Queue Category Code are serialized: while one runs, another entry in that category waits. + +## Best Practice + +Assign the same non-empty Job Queue Category Code to job queue entries in the same company that must not overlap, regardless of which codeunit they run. Define categories around the shared resource or exclusivity requirement, not merely around object names. Leave independent jobs in different categories so they can still run concurrently. A category does not serialize work across companies or environments, or coordinate workers outside the job queue dispatcher. Protect shared external or cross-company resources with a separate application-level locking mechanism. + +See sample: `job-queue-category-code-serializes-conflicting-jobs.good.al`. + +## Anti Pattern + +Creating or configuring multiple job queue entries that update the same exclusive resource while leaving their Job Queue Category Code empty or different. Do not flag jobs merely because they touch the same tables; the rule applies when their operation requires mutual exclusion. + +See sample: `job-queue-category-code-serializes-conflicting-jobs.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.bad.al b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.bad.al new file mode 100644 index 0000000..8f92f10 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.bad.al @@ -0,0 +1,57 @@ +table 50112 "Queued Export Bad" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + } + field(2; Payload; Text[250]) + { + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} + +codeunit 50112 "Queued Export Worker Bad" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + var + QueuedExport: Record "Queued Export Bad"; + Client: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + if not QueuedExport.FindFirst() then + exit; + + Content.WriteFrom(QueuedExport.Payload); + Client.Post('https://example.local/exports', Content, Response); + if not Response.IsSuccessStatusCode() then + Error('Export failed with HTTP status %1.', Response.HttpStatusCode()); + + // If this local step fails, the external export exists but this row is retried. + UpdateLocalStatus(); + FinalizeExport(QueuedExport); + end; + + local procedure UpdateLocalStatus() + begin + end; + + local procedure FinalizeExport(var QueuedExport: Record "Queued Export Bad") + begin + QueuedExport.Delete(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.good.al b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.good.al new file mode 100644 index 0000000..d161089 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.good.al @@ -0,0 +1,65 @@ +table 50112 "Queued Export Good" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + } + field(2; Payload; Text[250]) + { + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } + +} + +codeunit 50112 "Queued Export Worker Good" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + var + QueuedExport: Record "Queued Export Good"; + Client: HttpClient; + Content: HttpContent; + ContentHeaders: HttpHeaders; + JsonPayload: JsonObject; + RequestBody: Text; + Response: HttpResponseMessage; + begin + if not QueuedExport.FindFirst() then + exit; + + JsonPayload.Add('idempotencyKey', Format(QueuedExport.SystemId)); + JsonPayload.Add('payload', QueuedExport.Payload); + JsonPayload.WriteTo(RequestBody); + + Content.WriteFrom(RequestBody); + Content.GetHeaders(ContentHeaders); + ContentHeaders.Clear(); + ContentHeaders.Add('Content-Type', 'application/json'); + Client.Post('https://example.local/exports', Content, Response); + if not Response.IsSuccessStatusCode() then + Error('Export failed with HTTP status %1.', Response.HttpStatusCode()); + + // The external service must atomically create a record only when idempotencyKey + // does not exist. When the key already exists, it must return the existing record + // without repeating the side effect. + UpdateLocalStatus(); + QueuedExport.Delete(); + end; + + local procedure UpdateLocalStatus() + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md new file mode 100644 index 0000000..a5932df --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, idempotency, retry, outbox, httpclient, external-effect] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Job queue external effects must be idempotent + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A job queue handler can successfully create something in an external system and then fail while updating Business Central. Business Central rolls back its database changes, but it cannot roll back the external request. The same work can later run again through configured retries, recurrence, rescheduling, or manual restart. Without a way for the external system to recognize the repeated request, a later run can create a duplicate shipment, payment, notification, or other side effect. + +## Best Practice + +Use a stable request ID that exists before the job queue processes the outbox row. For example, include the outbox record's `SystemId` as an `idempotencyKey` value in the JSON body of every POST attempt. The external service must enforce uniqueness on that value: when it receives the key again, it returns the existing record instead of creating another one. Delete the outbox row only after the external call and all required local updates succeed. + +A `Processed` flag set after the external call does not solve this failure window. If a later AL error rolls back that flag, the outbox row again looks unprocessed even though the external operation already happened. + +See sample: `job-queue-external-effects-must-be-idempotent.good.al`. + +## Anti Pattern + +Sending a state-changing request from a job queue handler with no stable request ID understood by the external API. Specifically, look for this sequence: read an outbox row, call `HttpClient.Post` or another side-effecting API, update or delete local data, and propagate an error after which the same outbox row can be processed again. The key may be part of the request body, URI, headers, or an existing business key; a naturally idempotent remote operation is already safe and should not be flagged. + +See sample: `job-queue-external-effects-must-be-idempotent.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.bad.al b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.bad.al new file mode 100644 index 0000000..eecfeaf --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.bad.al @@ -0,0 +1,17 @@ +codeunit 50110 "Job Queue UI Bad" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + if not Confirm('Process the queued export now?') then + exit; + + ProcessExport(Rec."Parameter String"); + Message('The queued export completed.'); + end; + + local procedure ProcessExport(ParameterString: Text) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.good.al b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.good.al new file mode 100644 index 0000000..490720e --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.good.al @@ -0,0 +1,14 @@ +codeunit 50110 "Job Queue UI Good" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + Rec.TestField("Parameter String"); + ProcessExport(Rec."Parameter String"); + end; + + local procedure ProcessExport(ParameterString: Text) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md new file mode 100644 index 0000000..f780ab5 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, background-session, guiallowed, confirm, runmodal, client-callback] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Job queue handlers must not require user interaction + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A job queue handler runs in a background session with no client UI. Calls that require a client callback, such as `Confirm`, `Page.RunModal`, `Report.RunModal`, upload, or download, can stop the job with a non-retriable callback error. `Message` is suppressed and logged by the server, so it cannot communicate a result to the user who scheduled the job. + +## Best Practice + +Make a dedicated job queue entry point non-interactive. Validate parameters and data in AL, persist business-visible status when needed, and let failures propagate to the job queue log. If one procedure genuinely serves both foreground and background callers, isolate optional UI-only behavior behind `GuiAllowed`; do not use the guard to silently skip a decision that the operation requires. + +See sample: `job-queue-handlers-must-not-require-ui.good.al`. + +## Anti Pattern + +Calling `Confirm`, `Page.Run`, `Page.RunModal`, `Report.Run`, `Report.RunModal`, `Hyperlink`, `File.Upload`, or `File.Download` from a codeunit run by the job queue. Another signal is using `Message` as the only success or failure notification: no user is attached to receive it. + +See sample: `job-queue-handlers-must-not-require-ui.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.bad.al b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.bad.al new file mode 100644 index 0000000..550506a --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.bad.al @@ -0,0 +1,23 @@ +codeunit 50111 "Job Queue Failure Bad" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + if not TryProcessCustomer(Rec."Parameter String") then + exit; + end; + + [TryFunction] + local procedure TryProcessCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + Customer.Get(CustomerNo); + ProcessCustomer(Customer); + end; + + local procedure ProcessCustomer(Customer: Record Customer) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.good.al b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.good.al new file mode 100644 index 0000000..8a99875 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.good.al @@ -0,0 +1,16 @@ +codeunit 50111 "Job Queue Failure Good" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + var + Customer: Record Customer; + begin + Customer.Get(Rec."Parameter String"); + ProcessCustomer(Customer); + end; + + local procedure ProcessCustomer(Customer: Record Customer) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md new file mode 100644 index 0000000..1c7b83f --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, error-propagation, tryfunction, retry, dispatcher, job-queue-log] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Job queue handlers must propagate execution failures + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The job queue dispatcher can mark an entry as failed, record the error, and apply its configured retry behavior only when the handler terminates with an error. A handler that catches a failed `TryFunction` or Boolean-returning operation and then returns normally reports success to the dispatcher, even though its work did not complete. + +## Best Practice + +Let an error that invalidates the whole run propagate out of the job queue entry point. Add context only when it helps an operator diagnose the failure and does not expose sensitive data. Per-item failures may be collected deliberately, but the batch must persist or emit an observable aggregate outcome instead of silently treating incomplete work as success. + +See sample: `job-queue-handlers-must-propagate-failures.good.al`. + +## Anti Pattern + +Calling a `TryFunction`, `Codeunit.Run`, or another Boolean-returning operation from a job queue handler and using `exit` or normal fall-through on failure without recording an intentional partial-success outcome. The dispatcher sees a successful return, so the entry's status and log do not represent the failed work and configured retries are not applied. + +See sample: `job-queue-handlers-must-propagate-failures.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.bad.al b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.bad.al new file mode 100644 index 0000000..434f2b9 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.bad.al @@ -0,0 +1,18 @@ +codeunit 50114 "Job Queue On Hold Bad" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + repeat + if not ProcessNextBatch() then + exit; + Rec.Get(Rec.ID); + until Rec.Status = Rec.Status::"On Hold"; + end; + + local procedure ProcessNextBatch(): Boolean + begin + exit(false); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.good.al b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.good.al new file mode 100644 index 0000000..b924297 --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.good.al @@ -0,0 +1,49 @@ +table 50114 "Job Cancellation Control" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Job Queue Entry ID"; Guid) + { + } + field(2; "Stop Requested"; Boolean) + { + } + } + + keys + { + key(PK; "Job Queue Entry ID") + { + Clustered = true; + } + } +} + +codeunit 50114 "Job Queue On Hold Good" +{ + TableNo = "Job Queue Entry"; + + trigger OnRun() + begin + while not IsStopRequested(Rec.ID) do + if not ProcessNextBatch() then + exit; + end; + + local procedure IsStopRequested(JobQueueEntryId: Guid): Boolean + var + JobCancellationControl: Record "Job Cancellation Control"; + begin + if not JobCancellationControl.Get(JobQueueEntryId) then + exit(false); + + exit(JobCancellationControl."Stop Requested"); + end; + + local procedure ProcessNextBatch(): Boolean + begin + exit(false); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md new file mode 100644 index 0000000..6eec05c --- /dev/null +++ b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [job-queue, on-hold, cancellation, in-process, long-running, stop-request] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Putting a job queue entry on hold does not stop its current run + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The On Hold status prevents a job queue entry from starting again, but it does not cancel a run that is already in process. A long-running handler continues until it completes, fails, reaches a cancellation point implemented by the application, or its session is stopped externally. + +## Best Practice + +Use On Hold to pause future scheduling. When a long-running operation must support graceful cancellation, store a separate application-owned stop request and check it before every bounded unit of work, including the first. Exit only at a point where completed work and the checkpoint are consistent. The code that resumes scheduling must clear the stop request before restarting the job. Use administrative session termination only when graceful cancellation is impossible. + +See sample: `job-queue-on-hold-does-not-stop-running-work.good.al`. + +## Anti Pattern + +Polling the job queue entry's Status field from inside its handler and expecting a change to On Hold to cancel the active run. The status controls scheduling, not cooperative cancellation, so the handler can continue processing despite the operator's action. + +See sample: `job-queue-on-hold-does-not-stop-running-work.bad.al`. \ No newline at end of file diff --git a/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md index e8da1c5..95ac3d7 100644 --- a/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md +++ b/microsoft/knowledge/performance/oncompanyopen-subscribers-must-not-do-io.md @@ -17,7 +17,7 @@ application-area: [all] ## Best Practice -Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work. +Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work. When the subscriber can run repeatedly, use `store-scheduled-task-id-to-avoid-duplicate-tasks.md` to avoid creating the same logical task more than once. See sample: [`oncompanyopen-subscribers-must-not-do-io.good.al`](oncompanyopen-subscribers-must-not-do-io.good.al). diff --git a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al new file mode 100644 index 0000000..d692317 --- /dev/null +++ b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al @@ -0,0 +1,15 @@ +codeunit 50115 "Scheduled Task Duplicate Bad" +{ + procedure EnsureCleanupTask() + begin + // Every call creates another task for the same cleanup work. + TaskScheduler.CreateTask(Codeunit::"Scheduled Cleanup Work Bad", 0, true, CompanyName()); + end; +} + +codeunit 50116 "Scheduled Cleanup Work Bad" +{ + trigger OnRun() + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.good.al b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.good.al new file mode 100644 index 0000000..df52c62 --- /dev/null +++ b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.good.al @@ -0,0 +1,23 @@ +codeunit 50115 "Scheduled Task Duplicate Good" +{ + internal procedure EnsureCleanupTask() + var + TaskId: Guid; + StoredTaskId: Text; + begin + if IsolatedStorage.Get('CleanupTaskId', DataScope::Company, StoredTaskId) then + if Evaluate(TaskId, StoredTaskId) then + if TaskScheduler.TaskExists(TaskId) then + exit; + + TaskId := TaskScheduler.CreateTask(Codeunit::"Scheduled Cleanup Work Good", 0, true, CompanyName()); + IsolatedStorage.Set('CleanupTaskId', Format(TaskId), DataScope::Company); + end; +} + +codeunit 50116 "Scheduled Cleanup Work Good" +{ + trigger OnRun() + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md new file mode 100644 index 0000000..600c671 --- /dev/null +++ b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [task-scheduler, scheduled-task, taskexists, duplicate-task, createtask, guid] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Store the scheduled task ID to avoid duplicate tasks + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Every call to `TaskScheduler.CreateTask` creates a new scheduled task and returns its unique GUID. Repeating setup or lifecycle code without retaining that GUID can create multiple tasks for the same logical work, consuming scheduler capacity and running the work more than once. + +## Best Practice + +Persist the GUID returned by `CreateTask` at the same scope as the logical task. Before creating a replacement, parse the stored GUID and call `TaskScheduler.TaskExists`; create and store a new task only when the previous task no longer exists. `TaskExists` checks one GUID, not whether an equivalent codeunit is already scheduled, so callers that can schedule concurrently still need serialization around this check-and-create sequence. + +See sample: `store-scheduled-task-id-to-avoid-duplicate-tasks.good.al`. + +## Anti Pattern + +Calling `TaskScheduler.CreateTask` every time initialization, login, setup, or another repeatable path runs while ignoring its return value. Each invocation creates another independent task even when an equivalent task is already pending. + +See sample: `store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al`. \ No newline at end of file diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 664f20d..7829d70 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-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 tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration. - The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation. -- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`). +- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`). 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. @@ -52,6 +52,12 @@ Apply these targeted cues even when simple token overlap would rank the article - Worklist `avoid-cloning-records-before-modify-delete-in-loops.md` when an iteration calls `Copy` or `RecordRef.GetTable` before `Modify`/`Delete`, or passes the iterated record without `var` to a helper that writes that record. Do not worklist it from `Modify`, `Delete`, or `RecordRef` alone; exclude a direct write on the iterator, a read-only copy, a temporary record, a different target table, and a `RecordRef` opened and iterated directly. - Worklist `use-tryfunction-for-error-catching-not-rollback.md` only when writes occur inside a try method and the code or surrounding flow expects an error to roll them back. A bare try-method call whose Boolean result is ignored belongs exclusively to `error-handling/ignored-tryfunction-return-disables-try-semantics.md`; do not worklist the performance article from that call shape alone. - For `LockTable` in a pure read helper, select exactly one owner. Use `do-not-locktable-in-read-only-procedure.md` when the helper needs no stronger isolation and should remove the lock. Use `prefer-readisolation-over-locktable-for-reads.md` instead when the code explicitly requires committed-read semantics and `ReadIsolation` is the replacement. Never emit both findings for the same call. +- Worklist `job-queue-handlers-must-not-require-ui.md` when a codeunit run by the job queue calls `Confirm`, `Page.Run`, `Page.RunModal`, `Report.Run`, `Report.RunModal`, `Hyperlink`, `File.Upload`, or `File.Download`, or uses `Message` as its only success or failure notification. Exclude optional UI-only behavior guarded by `GuiAllowed`; do not exclude a guard that silently skips a decision required by the operation. +- Worklist `job-queue-handlers-must-propagate-failures.md` when a codeunit run by the job queue handles a failed `TryFunction`, `Codeunit.Run`, or another Boolean-returning operation with `exit` or normal fall-through, causing the dispatcher to observe success. Exclude intentional partial-success handling that persists or emits an observable aggregate outcome. A bare try-method call whose Boolean result is ignored remains owned exclusively by `error-handling/ignored-tryfunction-return-disables-try-semantics.md`. +- Worklist `job-queue-external-effects-must-be-idempotent.md` when rerunnable job queue work reads an outbox row, performs a state-changing external request, then updates or deletes local data without sending a stable request ID understood by the external system. Exclude naturally idempotent operations and requests whose body, URI, headers, or business key lets the external service return the existing result instead of repeating the side effect. +- Worklist `job-queue-on-hold-does-not-stop-running-work.md` when a running job queue handler polls the entry's `Status` or `On Hold` value as a cancellation signal. Exclude application-owned stop requests that are checked before every bounded unit of work, including the first, when completed work and its checkpoint remain consistent and resume logic clears the request. +- Worklist `job-queue-category-code-serializes-conflicting-jobs.md` when two or more job queue entries in the same company are shown by the changed context to require mutual exclusion but have empty or different Job Queue Category Codes. Do not infer a conflict merely because jobs touch the same tables, and do not recommend a category to coordinate across companies, environments, or workers outside the job queue dispatcher. +- Worklist `store-scheduled-task-id-to-avoid-duplicate-tasks.md` when `TaskScheduler.CreateTask` runs from initialization, login, setup, or another repeatable path without persisting its returned GUID and checking it with `TaskScheduler.TaskExists` before creating a replacement. Exclude one-shot creation and correctly persisted check-before-create flows; concurrent callers still require serialization around that sequence. These targeted inclusions and exclusions override generic token overlap. Do not retain an excluded article solely because the diff contains one of its keywords. diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index c4b0bf1..8cf019f 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -195,12 +195,42 @@ foreach ($domain in $leafDomains) { } $override = if ($overrides.ContainsKey($domain)) { $overrides[$domain] } else { $null } - $selectedArticle = $null - if ($override -and ($override.PSObject.Properties.Name -contains 'article')) { - $articleName = [string]$override.article + $hasArticleOverride = $override -and ($override.PSObject.Properties.Name -contains 'article') + $hasArticlesOverride = $override -and ($override.PSObject.Properties.Name -contains 'articles') + if ($hasArticleOverride -and $hasArticlesOverride) { + $problems.Add("${domain}: override must specify either 'article' or 'articles', not both.") | Out-Null + continue + } + + $articleNames = @() + if ($hasArticlesOverride) { + $articleNames = @($override.articles) + if (-not $articleNames.Count) { + $problems.Add("${domain}: override 'articles' must contain at least one article.") | Out-Null + continue + } + } elseif ($hasArticleOverride) { + $articleNames = @($override.article) + } else { + $articleNames = @($articles | Select-Object -First 1 | ForEach-Object BaseName) + } + + $selectedArticles = [System.Collections.Generic.List[object]]::new() + $seenArticleNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($articleNameValue in $articleNames) { + if ($articleNameValue -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$articleNameValue)) { + $problems.Add("${domain}: override article names must be non-empty strings.") | Out-Null + continue + } + $articleName = [string]$articleNameValue if ($articleName.EndsWith('.md')) { $articleName = [System.IO.Path]::GetFileNameWithoutExtension($articleName) } + if (-not $seenArticleNames.Add($articleName)) { + $problems.Add("${domain}: override contains duplicate article: $articleName.md") | Out-Null + continue + } + $selectedArticle = $articles | Where-Object BaseName -eq $articleName | Select-Object -First 1 if (-not $selectedArticle) { $articleExists = @( @@ -218,32 +248,41 @@ foreach ($domain in $leafDomains) { } continue } - } else { - $selectedArticle = $articles | Select-Object -First 1 + $selectedArticles.Add($selectedArticle) | Out-Null } - if (-not $selectedArticle) { - $problems.Add("${domain}: no article has both .good.al and .bad.al companion samples.") | Out-Null + if (-not $selectedArticles.Count) { + if (-not $articleNames.Count) { + $problems.Add("${domain}: no article has both .good.al and .bad.al companion samples.") | Out-Null + } continue } - $articlePath = [string]$selectedArticle.ArticlePath - $sampleDirectory = (Split-Path -Parent $articlePath).Replace('\', '/') $context = if ($override -and ($override.PSObject.Properties.Name -contains 'context')) { [string]$override.context } else { $null } - foreach ($kind in 'bad', 'good') { - $case = [pscustomobject]@{ - id = "$domain-$kind" - domain = $domain - input = "$sampleDirectory/$($selectedArticle.BaseName).$kind.al" - expected = if ($kind -eq 'bad') { @($articlePath) } else { @() } + for ($articleIndex = 0; $articleIndex -lt $selectedArticles.Count; $articleIndex++) { + $selectedArticle = $selectedArticles[$articleIndex] + $articlePath = [string]$selectedArticle.ArticlePath + $sampleDirectory = (Split-Path -Parent $articlePath).Replace('\', '/') + foreach ($kind in 'bad', 'good') { + $caseId = if ($articleIndex -eq 0) { + "$domain-$kind" + } else { + "$domain-$($selectedArticle.BaseName)-$kind" + } + $case = [pscustomobject]@{ + id = $caseId + domain = $domain + input = "$sampleDirectory/$($selectedArticle.BaseName).$kind.al" + expected = if ($kind -eq 'bad') { @($articlePath) } else { @() } + } + if ($context) { + $case | Add-Member -NotePropertyName context -NotePropertyValue $context + } + $caseList.Add($case) | Out-Null } - if ($context) { - $case | Add-Member -NotePropertyName context -NotePropertyValue $context - } - $caseList.Add($case) | Out-Null } } $cases = @($caseList) From 852a6762857cdd2a13d675ea0245e24471914e95 Mon Sep 17 00:00:00 2001 From: dayland <48474707+dayland@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:32:40 +0200 Subject: [PATCH 11/51] Fix Job Queue sample links (#184) Use the required READ-convention Markdown links so knowledge retrieval can associate all new samples with their articles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: dayland Copilot-Session: 76eb42c4-2acd-4f9c-a898-f4a44f9d46f7 --- .../job-queue-category-code-serializes-conflicting-jobs.md | 4 ++-- .../job-queue-external-effects-must-be-idempotent.md | 4 ++-- .../performance/job-queue-handlers-must-not-require-ui.md | 4 ++-- .../performance/job-queue-handlers-must-propagate-failures.md | 4 ++-- .../job-queue-on-hold-does-not-stop-running-work.md | 4 ++-- .../store-scheduled-task-id-to-avoid-duplicate-tasks.md | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md index d92647e..190fb5e 100644 --- a/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md +++ b/microsoft/knowledge/performance/job-queue-category-code-serializes-conflicting-jobs.md @@ -19,10 +19,10 @@ Different job queue entries can run at the same time. When two jobs update the s Assign the same non-empty Job Queue Category Code to job queue entries in the same company that must not overlap, regardless of which codeunit they run. Define categories around the shared resource or exclusivity requirement, not merely around object names. Leave independent jobs in different categories so they can still run concurrently. A category does not serialize work across companies or environments, or coordinate workers outside the job queue dispatcher. Protect shared external or cross-company resources with a separate application-level locking mechanism. -See sample: `job-queue-category-code-serializes-conflicting-jobs.good.al`. +See sample: [`job-queue-category-code-serializes-conflicting-jobs.good.al`](job-queue-category-code-serializes-conflicting-jobs.good.al). ## Anti Pattern Creating or configuring multiple job queue entries that update the same exclusive resource while leaving their Job Queue Category Code empty or different. Do not flag jobs merely because they touch the same tables; the rule applies when their operation requires mutual exclusion. -See sample: `job-queue-category-code-serializes-conflicting-jobs.bad.al`. \ No newline at end of file +See sample: [`job-queue-category-code-serializes-conflicting-jobs.bad.al`](job-queue-category-code-serializes-conflicting-jobs.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md index a5932df..66bb11b 100644 --- a/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md +++ b/microsoft/knowledge/performance/job-queue-external-effects-must-be-idempotent.md @@ -21,10 +21,10 @@ Use a stable request ID that exists before the job queue processes the outbox ro A `Processed` flag set after the external call does not solve this failure window. If a later AL error rolls back that flag, the outbox row again looks unprocessed even though the external operation already happened. -See sample: `job-queue-external-effects-must-be-idempotent.good.al`. +See sample: [`job-queue-external-effects-must-be-idempotent.good.al`](job-queue-external-effects-must-be-idempotent.good.al). ## Anti Pattern Sending a state-changing request from a job queue handler with no stable request ID understood by the external API. Specifically, look for this sequence: read an outbox row, call `HttpClient.Post` or another side-effecting API, update or delete local data, and propagate an error after which the same outbox row can be processed again. The key may be part of the request body, URI, headers, or an existing business key; a naturally idempotent remote operation is already safe and should not be flagged. -See sample: `job-queue-external-effects-must-be-idempotent.bad.al`. \ No newline at end of file +See sample: [`job-queue-external-effects-must-be-idempotent.bad.al`](job-queue-external-effects-must-be-idempotent.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md index f780ab5..5987270 100644 --- a/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md +++ b/microsoft/knowledge/performance/job-queue-handlers-must-not-require-ui.md @@ -19,10 +19,10 @@ A job queue handler runs in a background session with no client UI. Calls that r Make a dedicated job queue entry point non-interactive. Validate parameters and data in AL, persist business-visible status when needed, and let failures propagate to the job queue log. If one procedure genuinely serves both foreground and background callers, isolate optional UI-only behavior behind `GuiAllowed`; do not use the guard to silently skip a decision that the operation requires. -See sample: `job-queue-handlers-must-not-require-ui.good.al`. +See sample: [`job-queue-handlers-must-not-require-ui.good.al`](job-queue-handlers-must-not-require-ui.good.al). ## Anti Pattern Calling `Confirm`, `Page.Run`, `Page.RunModal`, `Report.Run`, `Report.RunModal`, `Hyperlink`, `File.Upload`, or `File.Download` from a codeunit run by the job queue. Another signal is using `Message` as the only success or failure notification: no user is attached to receive it. -See sample: `job-queue-handlers-must-not-require-ui.bad.al`. \ No newline at end of file +See sample: [`job-queue-handlers-must-not-require-ui.bad.al`](job-queue-handlers-must-not-require-ui.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md index 1c7b83f..361fc7d 100644 --- a/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md +++ b/microsoft/knowledge/performance/job-queue-handlers-must-propagate-failures.md @@ -19,10 +19,10 @@ The job queue dispatcher can mark an entry as failed, record the error, and appl Let an error that invalidates the whole run propagate out of the job queue entry point. Add context only when it helps an operator diagnose the failure and does not expose sensitive data. Per-item failures may be collected deliberately, but the batch must persist or emit an observable aggregate outcome instead of silently treating incomplete work as success. -See sample: `job-queue-handlers-must-propagate-failures.good.al`. +See sample: [`job-queue-handlers-must-propagate-failures.good.al`](job-queue-handlers-must-propagate-failures.good.al). ## Anti Pattern Calling a `TryFunction`, `Codeunit.Run`, or another Boolean-returning operation from a job queue handler and using `exit` or normal fall-through on failure without recording an intentional partial-success outcome. The dispatcher sees a successful return, so the entry's status and log do not represent the failed work and configured retries are not applied. -See sample: `job-queue-handlers-must-propagate-failures.bad.al`. \ No newline at end of file +See sample: [`job-queue-handlers-must-propagate-failures.bad.al`](job-queue-handlers-must-propagate-failures.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md index 6eec05c..b0411a8 100644 --- a/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md +++ b/microsoft/knowledge/performance/job-queue-on-hold-does-not-stop-running-work.md @@ -19,10 +19,10 @@ The On Hold status prevents a job queue entry from starting again, but it does n Use On Hold to pause future scheduling. When a long-running operation must support graceful cancellation, store a separate application-owned stop request and check it before every bounded unit of work, including the first. Exit only at a point where completed work and the checkpoint are consistent. The code that resumes scheduling must clear the stop request before restarting the job. Use administrative session termination only when graceful cancellation is impossible. -See sample: `job-queue-on-hold-does-not-stop-running-work.good.al`. +See sample: [`job-queue-on-hold-does-not-stop-running-work.good.al`](job-queue-on-hold-does-not-stop-running-work.good.al). ## Anti Pattern Polling the job queue entry's Status field from inside its handler and expecting a change to On Hold to cancel the active run. The status controls scheduling, not cooperative cancellation, so the handler can continue processing despite the operator's action. -See sample: `job-queue-on-hold-does-not-stop-running-work.bad.al`. \ No newline at end of file +See sample: [`job-queue-on-hold-does-not-stop-running-work.bad.al`](job-queue-on-hold-does-not-stop-running-work.bad.al). \ No newline at end of file diff --git a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md index 600c671..1e64c32 100644 --- a/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md +++ b/microsoft/knowledge/performance/store-scheduled-task-id-to-avoid-duplicate-tasks.md @@ -19,10 +19,10 @@ Every call to `TaskScheduler.CreateTask` creates a new scheduled task and return Persist the GUID returned by `CreateTask` at the same scope as the logical task. Before creating a replacement, parse the stored GUID and call `TaskScheduler.TaskExists`; create and store a new task only when the previous task no longer exists. `TaskExists` checks one GUID, not whether an equivalent codeunit is already scheduled, so callers that can schedule concurrently still need serialization around this check-and-create sequence. -See sample: `store-scheduled-task-id-to-avoid-duplicate-tasks.good.al`. +See sample: [`store-scheduled-task-id-to-avoid-duplicate-tasks.good.al`](store-scheduled-task-id-to-avoid-duplicate-tasks.good.al). ## Anti Pattern Calling `TaskScheduler.CreateTask` every time initialization, login, setup, or another repeatable path runs while ignoring its return value. Each invocation creates another independent task even when an equivalent task is already pending. -See sample: `store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al`. \ No newline at end of file +See sample: [`store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al`](store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al). \ No newline at end of file From b74967bc5b7a454eae19d6a1250199afd869f064 Mon Sep 17 00:00:00 2001 From: dayland <48474707+dayland@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:27:40 +0200 Subject: [PATCH 12/51] Add machine-readable review contracts (#182) Generate a deterministic action-skill index from frontmatter, publish structural schemas for orchestration and findings, and validate flat review composition in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: dayland Copilot-Session: 76eb42c4-2acd-4f9c-a898-f4a44f9d46f7 --- .github/scripts/Test-SkillIndex.ps1 | 240 +++++++++++++++++++++ .github/scripts/validate_frontmatter.py | 41 +++- .github/workflows/skill-index.yml | 18 ++ microsoft/skills/review/al-code-review.md | 5 + schemas/findings-report.schema.json | 159 ++++++++++++++ schemas/skill-index.schema.json | 84 ++++++++ skills/do.md | 12 ++ tools/Build-SkillIndex.ps1 | 247 ++++++++++++++++++++++ 8 files changed, 803 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/Test-SkillIndex.ps1 create mode 100644 .github/workflows/skill-index.yml create mode 100644 schemas/findings-report.schema.json create mode 100644 schemas/skill-index.schema.json create mode 100644 tools/Build-SkillIndex.ps1 diff --git a/.github/scripts/Test-SkillIndex.ps1 b/.github/scripts/Test-SkillIndex.ps1 new file mode 100644 index 0000000..839042f --- /dev/null +++ b/.github/scripts/Test-SkillIndex.ps1 @@ -0,0 +1,240 @@ +<# +.SYNOPSIS + Validates the BCQuality action-skill index generator and shared schemas. +#> +[CmdletBinding()] +param( + [string] $Root = (Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath '..' '..')) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$Root = (Resolve-Path -LiteralPath $Root).Path + +function Assert-ThrowsLike { + param( + [scriptblock] $Action, + [string] $Pattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -like $Pattern) { + return + } + throw "Expected error like '$Pattern', received: $($_.Exception.Message)" + } + throw "Expected error like '$Pattern', but no error was thrown." +} + +$generator = Join-Path $Root 'tools/Build-SkillIndex.ps1' +$indexSchema = Join-Path $Root 'schemas/skill-index.schema.json' +$reportSchema = Join-Path $Root 'schemas/findings-report.schema.json' +foreach ($path in $generator, $indexSchema, $reportSchema) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Required contract file not found: $path" + } +} + +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("skillindex_" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + $first = Join-Path $tmp 'first.json' + $second = Join-Path $tmp 'second.json' + & $generator -BCQualityRoot $Root -IndexPath $first | Out-Null + & $generator -BCQualityRoot $Root -IndexPath $second | Out-Null + + $normalize = { + param([string] $Path) + return ((Get-Content -LiteralPath $Path -Raw) -replace '"generatedAt":"[^"]*"', '"generatedAt":""') + } + if ((& $normalize $first) -ne (& $normalize $second)) { + throw 'Skill index is not deterministic beyond generatedAt.' + } + + $raw = Get-Content -LiteralPath $first -Raw + if (-not ($raw | Test-Json -SchemaFile $indexSchema -ErrorAction Stop)) { + throw 'Generated skill index does not satisfy schemas/skill-index.schema.json.' + } + + $index = $raw | ConvertFrom-Json + $skills = @($index.skills) + if ($index.skillCount -ne $skills.Count) { + throw "skillCount is $($index.skillCount), but the index contains $($skills.Count) records." + } + + $paths = @($skills.path) + $duplicates = @($paths | Group-Object | Where-Object Count -gt 1) + if ($duplicates.Count) { + throw "Duplicate skill paths: $($duplicates.Name -join ', ')" + } + foreach ($path in $paths) { + if (-not (Test-Path -LiteralPath (Join-Path $Root $path) -PathType Leaf)) { + throw "Indexed skill does not exist: $path" + } + } + + $expectedLeaves = @( + 'microsoft/skills/review/al-performance-review.md', + 'microsoft/skills/review/al-security-review.md', + 'microsoft/skills/review/al-privacy-review.md', + 'microsoft/skills/review/al-upgrade-review.md', + 'microsoft/skills/review/al-style-review.md', + 'microsoft/skills/review/al-ui-review.md', + 'microsoft/skills/review/al-error-handling-review.md', + 'microsoft/skills/review/al-events-review.md', + 'microsoft/skills/review/al-interfaces-review.md', + 'microsoft/skills/review/al-breaking-changes-review.md', + 'microsoft/skills/review/al-web-services-review.md', + 'microsoft/skills/review/al-testing-review.md', + 'microsoft/skills/review/al-data-modeling-review.md', + 'microsoft/skills/review/al-query-review.md', + 'microsoft/skills/review/al-appsource-review.md', + 'microsoft/skills/review/al-telemetry-review.md' + ) + $review = @($skills | Where-Object id -eq 'al-code-review') + if ($review.Count -ne 1) { + throw "Expected exactly one al-code-review record, found $($review.Count)." + } + if ((@($review[0].subSkills) -join "`n") -cne ($expectedLeaves -join "`n")) { + throw 'al-code-review subSkills did not preserve the declared 16-leaf order.' + } + foreach ($leafPath in $expectedLeaves) { + $leaf = @($skills | Where-Object path -ceq $leafPath) + if ($leaf.Count -ne 1 -or @($leaf[0].subSkills).Count -ne 0) { + throw "Expected '$leafPath' to resolve to exactly one leaf action skill." + } + } + + $minimalReport = @{ + skill = @{ id = 'al-style-review'; version = 1 } + outcome = 'completed' + summary = @{ + counts = @{ blocker = 0; major = 0; minor = 0; info = 0 } + coverage = @{ 'worklist-size' = 0; 'items-evaluated' = 0 } + } + findings = @() + suppressed = @() + } | ConvertTo-Json -Depth 8 + if (-not ($minimalReport | Test-Json -SchemaFile $reportSchema -ErrorAction Stop)) { + throw 'Minimal findings report does not satisfy schemas/findings-report.schema.json.' + } + + $reviewSkillText = Get-Content -LiteralPath ( + Join-Path -Path $Root -ChildPath 'microsoft/skills/review/al-code-review.md' + ) -Raw + $reportExamples = [regex]::Matches($reviewSkillText, '(?s)```json\s*(\{.*?\})\s*```') + if ($reportExamples.Count -ne 2) { + throw "Expected two al-code-review JSON examples, found $($reportExamples.Count)." + } + foreach ($example in $reportExamples) { + if (-not ($example.Groups[1].Value | Test-Json -SchemaFile $reportSchema -ErrorAction Stop)) { + throw 'An al-code-review output example does not satisfy schemas/findings-report.schema.json.' + } + } + + $fixtureRoot = Join-Path -Path $tmp -ChildPath 'fixture' + $fixtureSkills = Join-Path -Path $fixtureRoot -ChildPath 'microsoft/skills/review' + New-Item -ItemType Directory -Path $fixtureSkills -Force | Out-Null + $leaf = @' +--- +kind: action-skill +id: al-leaf-review +version: 1 +title: Leaf +description: Test leaf. +inputs: [file-path] +outputs: [findings-report] +--- + +# Leaf + +## Source +Source. +## Relevance +Relevance. +## Worklist +Worklist. +## Action +Action. +## Output +Output. +'@ + Set-Content -LiteralPath (Join-Path $fixtureSkills 'al-leaf-review.md') -Value $leaf -Encoding utf8NoBOM + + $duplicateSuper = @' +--- +kind: action-skill +id: al-code-review +version: 1 +title: Review +description: Test super-skill. +inputs: [file-path] +outputs: [findings-report] +sub-skills: + - microsoft/skills/review/al-leaf-review.md + - microsoft/skills/review/al-leaf-review.md +--- + +# Review + +## Source +Source. +## Relevance +Relevance. +## Worklist +Worklist. +## Action +Action. +## Output +Output. +'@ + $superPath = Join-Path $fixtureSkills 'al-code-review.md' + Set-Content -LiteralPath $superPath -Value $duplicateSuper -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*duplicate sub-skill*' -Action { + & $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'invalid.json') + } + + $nestedLeaf = $leaf.Replace('id: al-leaf-review', 'id: al-nested-review').Replace( + 'outputs: [findings-report]', + "outputs: [findings-report]`nsub-skills:`n - microsoft/skills/review/al-leaf-review.md" + ) + Set-Content -LiteralPath (Join-Path $fixtureSkills 'al-nested-review.md') -Value $nestedLeaf -Encoding utf8NoBOM + $nestedSuper = @' +--- +kind: action-skill +id: al-code-review +version: 1 +title: Review +description: Test super-skill. +inputs: [file-path] +outputs: [findings-report] +sub-skills: + - microsoft/skills/review/al-nested-review.md +--- + +# Review + +## Source +Source. +## Relevance +Relevance. +## Worklist +Worklist. +## Action +Action. +## Output +Output. +'@ + Set-Content -LiteralPath $superPath -Value $nestedSuper -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*Nested super-skills are not supported*' -Action { + & $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'nested.json') + } +} +finally { + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Output 'Skill-index check PASSED: deterministic, schema-valid, and all 16 review leaves preserved in order.' diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index f422d53..20f33d6 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -381,6 +381,20 @@ def validate_action_skill(path: Path, parsed: Parsed, report: Report) -> None: bad = [x for x in ss if not x.endswith(".md")] if bad: report.error(path, "R20", f"sub-skills entries must end in '.md': {bad}", 1) + non_canonical = [ + x for x in ss + if "\\" in x or x.startswith("/") or ".." in Path(x).parts or x.startswith("./") + ] + if non_canonical: + report.error( + path, + "R20", + f"sub-skills entries must be canonical repo-relative paths: {non_canonical}", + 1, + ) + duplicates = sorted({x for x in ss if ss.count(x) > 1}) + if duplicates: + report.error(path, "R20", f"sub-skills contains duplicate paths: {duplicates}", 1) # R21 five required sections, in order, each exactly once heads = [h for h, _ in headings_in_order(parsed.body)] @@ -565,7 +579,13 @@ class SkillRecord: skill_id: str | None -def validate_sub_skills_registry(path: Path, fm: dict[str, Any], root: Path, report: Report) -> None: +def validate_sub_skills_registry( + path: Path, + fm: dict[str, Any], + root: Path, + action_skills_by_path: dict[str, dict[str, Any]], + report: Report, +) -> None: """R26: a super-skill's declared `sub-skills` must exactly match the `al-*-review.md` leaf files present in the same directory (set equality, ordering-agnostic). This keeps the registered leaf list the single source @@ -598,6 +618,17 @@ def validate_sub_skills_registry(path: Path, fm: dict[str, Any], root: Path, rep f"sub-skills entry is not a sibling 'al-*-review.md' leaf: {entry}", 1, ) + for entry in ss: + leaf = action_skills_by_path.get(entry) + if leaf is None: + if (root / entry).exists(): + report.error(path, "R26", f"sub-skills entry is not an action skill: {entry}", 1) + continue + if is_non_empty_list_of_str(leaf.get("sub-skills")): + report.error(path, "R26", f"nested super-skill is not permitted in v1 composition: {entry}", 1) + if leaf.get("outputs") != ["findings-report"]: + report.error(path, "R26", f"sub-skill must produce findings-report: {entry}", 1) + # Sibling leaves on disk that were never registered ('forgot to wire it up'). for leaf in sorted(leaves - declared): report.error(path, "R26", f"leaf not registered in sub-skills: {leaf}", 1) @@ -670,9 +701,13 @@ def run(root: Path) -> Report: others = [q.relative_to(root).as_posix() for q in paths if q != p] report.error(p, "R24", f"skill id '{sid}' ({kind}) is not unique; also defined in: {others}") - # Fourth pass: R26 sub-skills registry matches leaf files on disk + # Fourth pass: R26 sub-skills registry matches compatible leaf files on disk + action_skills_by_path = { + path.relative_to(root).as_posix(): fm + for path, fm in action_skill_fms + } for path, fm in action_skill_fms: - validate_sub_skills_registry(path, fm, root, report) + validate_sub_skills_registry(path, fm, root, action_skills_by_path, report) return report diff --git a/.github/workflows/skill-index.yml b/.github/workflows/skill-index.yml new file mode 100644 index 0000000..b1b8e3a --- /dev/null +++ b/.github/workflows/skill-index.yml @@ -0,0 +1,18 @@ +name: Validate skill index and report schemas + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + validate-contract: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Validate skill-index generator and schemas + shell: pwsh + run: ./.github/scripts/Test-SkillIndex.ps1 -Root . diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 9239220..9b5f739 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -41,6 +41,11 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`. Additional leaf skills are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly. +Hosts that orchestrate leaves mechanically SHOULD run +`tools/Build-SkillIndex.ps1` and resolve this skill by `id: al-code-review`. +The generated `subSkills` array preserves the frontmatter order and avoids +host-specific Markdown parsing. + ## Relevance A sub-skill is relevant when both of the following hold: diff --git a/schemas/findings-report.schema.json b/schemas/findings-report.schema.json new file mode 100644 index 0000000..76712f4 --- /dev/null +++ b/schemas/findings-report.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/microsoft/BCQuality/schemas/findings-report.schema.json", + "title": "BCQuality findings report", + "type": "object", + "additionalProperties": false, + "required": ["skill", "outcome", "summary", "findings", "suppressed"], + "properties": { + "skill": { "$ref": "#/definitions/skillReference" }, + "outcome": { + "enum": ["completed", "not-applicable", "no-knowledge", "partial", "failed"] + }, + "outcome-reason": { "type": "string", "minLength": 1 }, + "summary": { "$ref": "#/definitions/summary" }, + "findings": { + "type": "array", + "items": { "$ref": "#/definitions/finding" } + }, + "suppressed": { + "type": "array", + "items": { "$ref": "#/definitions/suppressed" } + }, + "sub-results": { + "type": "array", + "items": { "$ref": "#" } + }, + "skipped-sub-skills": { + "type": "array", + "items": { "$ref": "#/definitions/skippedSubSkill" } + } + }, + "allOf": [ + { + "if": { + "properties": { + "outcome": { "enum": ["partial", "failed"] } + } + }, + "then": { "required": ["outcome-reason"] } + }, + { + "if": { + "properties": { + "outcome": { "enum": ["not-applicable", "no-knowledge", "failed"] } + } + }, + "then": { + "properties": { + "findings": { "maxItems": 0 } + } + } + } + ], + "definitions": { + "skillReference": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "version": { "type": "integer", "minimum": 1 } + } + }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": ["blocker", "major", "minor", "info"], + "properties": { + "blocker": { "type": "integer", "minimum": 0 }, + "major": { "type": "integer", "minimum": 0 }, + "minor": { "type": "integer", "minimum": 0 }, + "info": { "type": "integer", "minimum": 0 } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": ["worklist-size", "items-evaluated"], + "properties": { + "worklist-size": { "type": "integer", "minimum": 0 }, + "items-evaluated": { "type": "integer", "minimum": 0 } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["counts", "coverage"], + "properties": { + "counts": { "$ref": "#/definitions/counts" }, + "coverage": { "$ref": "#/definitions/coverage" } + } + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { "type": "string", "minLength": 1, "pattern": "^[^\\\\]+$" }, + "sha": { "type": "string", "pattern": "^[a-fA-F0-9]{40}$" } + } + }, + "location": { + "type": "object", + "additionalProperties": false, + "required": ["file", "line"], + "properties": { + "file": { "type": "string", "minLength": 1, "pattern": "^[^\\\\]+$" }, + "line": { "type": "integer", "minimum": 1 }, + "range": { + "type": "object", + "additionalProperties": false, + "required": ["start-line", "end-line"], + "properties": { + "start-line": { "type": "integer", "minimum": 1 }, + "end-line": { "type": "integer", "minimum": 1 } + } + } + } + }, + "finding": { + "type": "object", + "additionalProperties": false, + "required": ["id", "severity", "message", "references", "confidence"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "severity": { "enum": ["blocker", "major", "minor", "info"] }, + "message": { "type": "string", "minLength": 1 }, + "location": { "$ref": "#/definitions/location" }, + "references": { + "type": "array", + "items": { "$ref": "#/definitions/reference" } + }, + "confidence": { "enum": ["high", "medium", "low"] }, + "from-sub-skill": { "type": "string", "minLength": 1 }, + "domain": { "type": "string", "minLength": 1, "pattern": "^[^\\r\\n]+$" }, + "suggested-code": { "type": "string", "minLength": 1 }, + "suggested-code-omission-reason": { "type": "string", "minLength": 1 } + } + }, + "suppressed": { + "type": "object", + "additionalProperties": false, + "required": ["reference", "reason"], + "properties": { + "reference": { "$ref": "#/definitions/reference" }, + "reason": { "enum": ["layer-precedence", "configuration"] } + } + }, + "skippedSubSkill": { + "type": "object", + "additionalProperties": false, + "required": ["skill", "reason"], + "properties": { + "skill": { "$ref": "#/definitions/skillReference" }, + "reason": { "enum": ["configuration", "not-applicable"] } + } + } + } +} diff --git a/schemas/skill-index.schema.json b/schemas/skill-index.schema.json new file mode 100644 index 0000000..01944bb --- /dev/null +++ b/schemas/skill-index.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/microsoft/BCQuality/schemas/skill-index.schema.json", + "title": "BCQuality action-skill index", + "type": "object", + "additionalProperties": false, + "required": ["version", "generatedAt", "skillCount", "sourceSnapshot", "skills"], + "properties": { + "version": { "const": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "skillCount": { "type": "integer", "minimum": 0 }, + "sourceSnapshot": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "skills": { + "type": "array", + "items": { "$ref": "#/definitions/skill" } + } + }, + "definitions": { + "stringArray": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "skill": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "layer", + "id", + "version", + "title", + "description", + "inputs", + "outputs", + "filters", + "subSkills", + "sourceSha256" + ], + "properties": { + "path": { "type": "string", "pattern": "^(microsoft|community|custom)/skills/.+\\.md$" }, + "layer": { "enum": ["microsoft", "community", "custom"] }, + "id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "version": { "type": "integer", "minimum": 1 }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "inputs": { "$ref": "#/definitions/stringArray" }, + "outputs": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { "const": "findings-report" } + }, + "filters": { + "type": "object", + "additionalProperties": false, + "required": ["bc-version", "technologies", "countries", "application-area"], + "properties": { + "bc-version": { + "type": "array", + "items": { + "oneOf": [ + { "type": "integer", "minimum": 1 }, + { "type": "string", "pattern": "^(all|[1-9][0-9]*\\.\\.[1-9][0-9]*|[1-9][0-9]*\\.\\.)$" } + ] + } + }, + "technologies": { "$ref": "#/definitions/stringArray" }, + "countries": { "$ref": "#/definitions/stringArray" }, + "application-area": { "$ref": "#/definitions/stringArray" } + } + }, + "subSkills": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(microsoft|community|custom)/skills/.+\\.md$" + } + }, + "sourceSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + } + } +} diff --git a/skills/do.md b/skills/do.md index 9abdf58..e67faf6 100644 --- a/skills/do.md +++ b/skills/do.md @@ -106,6 +106,12 @@ Every action skill MUST contain these five sections, in order: Every action skill emits a single JSON document that conforms to this schema: +The machine-readable structural schema is +[`schemas/findings-report.schema.json`](../schemas/findings-report.schema.json). +The rules below remain authoritative for semantic checks that JSON Schema +cannot perform by itself, including summary arithmetic, reference existence, +source-scope locations, and article-body retrieval. + ```json { "skill": { "id": "string", "version": 1 }, @@ -349,6 +355,12 @@ the declared worklist order, and wait for every invocation to finish before performing any super-skill self-review or final rollup. Scheduling MUST NOT change relevance, coverage, failure, reference-integrity, or output semantics. +Orchestrators SHOULD generate `skill-index.json` with +`tools/Build-SkillIndex.ps1` and consume the super-skill's ordered `subSkills` +from that index instead of parsing Markdown. Action-skill frontmatter remains +the source of truth; the generated index conforms to +`schemas/skill-index.schema.json`. + ### Section interpretation for super-skills The five required sections still apply. Their meaning shifts from knowledge files to sub-skills: diff --git a/tools/Build-SkillIndex.ps1 b/tools/Build-SkillIndex.ps1 new file mode 100644 index 0000000..022898e --- /dev/null +++ b/tools/Build-SkillIndex.ps1 @@ -0,0 +1,247 @@ +<# +.SYNOPSIS + Builds the machine-readable BCQuality action-skill index. + +.DESCRIPTION + Action-skill frontmatter remains the source of truth. This script emits the + versioned JSON contract orchestrators consume so they do not need to parse + Markdown or duplicate composition rules. + +.PARAMETER BCQualityRoot + BCQuality repository or filtered content root. + +.PARAMETER IndexPath + Output path. Defaults to /skill-index.json. + +.OUTPUTS + Returns the number of indexed action skills. +#> +[CmdletBinding()] +param( + [string] $BCQualityRoot, + [string] $IndexPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $BCQualityRoot) { + $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +} +if (-not (Test-Path -LiteralPath $BCQualityRoot -PathType Container)) { + throw "BCQuality root not found: $BCQualityRoot" +} +$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path +if (-not $IndexPath) { + $IndexPath = Join-Path $BCQualityRoot 'skill-index.json' +} + +function Get-RelativePath { + param([string] $Root, [string] $Full) + + return ($Full.Substring($Root.Length).TrimStart([char]'/', [char]'\') -replace '\\', '/') +} + +function Get-Sha256 { + param([byte[]] $Bytes) + + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } +} + +function Get-ValueSha256 { + param([Parameter(Mandatory)] $Value) + + return Get-Sha256 -Bytes ([Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject $Value -Depth 12 -Compress) + )) +} + +function ConvertFrom-SkillFrontmatter { + param( + [string] $Path, + [string] $Text + ) + + $lines = [regex]::Split($Text.TrimStart([char]0xfeff), '\r\n|\n|\r') + if ($lines.Count -lt 3 -or $lines[0].Trim() -ne '---') { + throw [IO.InvalidDataException]::new("Missing frontmatter in '$Path'.") + } + + $end = -1 + for ($i = 1; $i -lt $lines.Count; $i++) { + if ($lines[$i].Trim() -eq '---') { + $end = $i + break + } + } + if ($end -lt 0) { + throw [IO.InvalidDataException]::new("Unterminated frontmatter in '$Path'.") + } + + $frontmatter = [ordered]@{} + for ($i = 1; $i -lt $end; $i++) { + $line = $lines[$i] + if ($line -notmatch '^([a-zA-Z][\w-]*)\s*:\s*(.*)$') { + continue + } + + $key = $Matches[1] + $value = $Matches[2].Trim() + if ($value -eq '') { + $items = [System.Collections.Generic.List[string]]::new() + while ($i + 1 -lt $end -and $lines[$i + 1] -match '^\s+-\s+(.+?)\s*$') { + $i++ + $items.Add($Matches[1].Trim().Trim('"', "'")) | Out-Null + } + $frontmatter[$key] = @($items) + continue + } + + if ($value -match '^\[(.*)\]$') { + $inner = $Matches[1].Trim() + $values = [System.Collections.Generic.List[object]]::new() + if ($inner) { + foreach ($item in $inner -split '\s*,\s*') { + $normalized = $item.Trim().Trim('"', "'") + $number = 0 + if ($key -eq 'bc-version' -and [int]::TryParse($normalized, [ref]$number)) { + $values.Add($number) | Out-Null + } + else { + $values.Add($normalized) | Out-Null + } + } + } + $frontmatter[$key] = [object[]]@($values) + continue + } + + $frontmatter[$key] = $value.Trim('"', "'") + } + + return $frontmatter +} + +$records = [System.Collections.Generic.List[object]]::new() +$recordsByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +$sourceManifest = [System.Collections.Generic.List[object]]::new() + +foreach ($layer in 'microsoft', 'community', 'custom') { + $skillsRoot = Join-Path $BCQualityRoot (Join-Path $layer 'skills') + if (-not (Test-Path -LiteralPath $skillsRoot -PathType Container)) { + continue + } + + foreach ($file in Get-ChildItem -LiteralPath $skillsRoot -Recurse -File -Filter '*.md' | Sort-Object FullName) { + $bytes = [IO.File]::ReadAllBytes($file.FullName) + try { + $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + } + catch [Text.DecoderFallbackException] { + throw [IO.InvalidDataException]::new("Invalid UTF-8 in '$($file.FullName)'.", $_.Exception) + } + + $frontmatter = ConvertFrom-SkillFrontmatter -Path $file.FullName -Text $text + if ($frontmatter['kind'] -ne 'action-skill') { + continue + } + + foreach ($required in 'id', 'version', 'title', 'description', 'inputs', 'outputs') { + if (-not $frontmatter.Contains($required) -or $null -eq $frontmatter[$required] -or + ([string]$frontmatter[$required]).Trim() -eq '') { + throw [IO.InvalidDataException]::new( + "Action skill '$($file.FullName)' is missing required frontmatter '$required'." + ) + } + } + + $path = Get-RelativePath -Root $BCQualityRoot -Full $file.FullName + $sourceSha256 = Get-Sha256 -Bytes $bytes + $version = 0 + if (-not [int]::TryParse([string]$frontmatter['version'], [ref]$version) -or $version -le 0) { + throw [IO.InvalidDataException]::new("Action skill '$path' has an invalid version.") + } + + $subSkills = @() + if ($frontmatter.Contains('sub-skills')) { + $subSkills = @($frontmatter['sub-skills']) + if (-not $subSkills.Count) { + throw [IO.InvalidDataException]::new("Super-skill '$path' has an empty sub-skills list.") + } + } + + $record = [pscustomobject][ordered]@{ + path = $path + layer = $layer + id = [string]$frontmatter['id'] + version = $version + title = [string]$frontmatter['title'] + description = [string]$frontmatter['description'] + inputs = [string[]]@($frontmatter['inputs']) + outputs = [string[]]@($frontmatter['outputs']) + filters = [ordered]@{ + 'bc-version' = [object[]]$(if ($frontmatter.Contains('bc-version')) { $frontmatter['bc-version'] }) + technologies = [string[]]$(if ($frontmatter.Contains('technologies')) { $frontmatter['technologies'] }) + countries = [string[]]$(if ($frontmatter.Contains('countries')) { $frontmatter['countries'] }) + 'application-area' = [string[]]$(if ($frontmatter.Contains('application-area')) { $frontmatter['application-area'] }) + } + subSkills = [string[]]$subSkills + sourceSha256 = $sourceSha256 + } + + if (-not $recordsByPath.TryAdd($path, $record)) { + throw "Duplicate action-skill path: $path" + } + $records.Add($record) | Out-Null + $sourceManifest.Add([ordered]@{ path = $path; sha256 = $sourceSha256 }) | Out-Null + } +} + +$ids = @($records | Group-Object id | Where-Object Count -gt 1) +if ($ids.Count) { + throw "Duplicate action-skill IDs: $($ids.Name -join ', ')" +} + +foreach ($record in $records) { + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($subSkillPath in @($record.subSkills)) { + if (-not $seen.Add($subSkillPath)) { + throw "Super-skill '$($record.path)' declares duplicate sub-skill '$subSkillPath'." + } + if (-not $recordsByPath.ContainsKey($subSkillPath)) { + throw "Super-skill '$($record.path)' references missing action skill '$subSkillPath'." + } + + $leaf = $recordsByPath[$subSkillPath] + if (@($leaf.subSkills).Count) { + throw "Nested super-skills are not supported: '$($record.path)' references '$subSkillPath'." + } + if (@($leaf.outputs).Count -ne 1 -or $leaf.outputs[0] -ne 'findings-report') { + throw "Sub-skill '$subSkillPath' must produce findings-report." + } + } +} + +$index = [ordered]@{ + version = 1 + generatedAt = (Get-Date).ToUniversalTime().ToString('o') + skillCount = $records.Count + sourceSnapshot = Get-ValueSha256 -Value @($sourceManifest) + skills = @($records) +} + +$parent = Split-Path -Parent $IndexPath +if ($parent -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null +} +Set-Content -LiteralPath $IndexPath -Value ( + ConvertTo-Json -InputObject $index -Depth 12 -Compress +) -Encoding utf8NoBOM + +return $records.Count From b545b22fb9173e0a1f17c3b2e02dc77ea6c92dab Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:38:32 +0200 Subject: [PATCH 13/51] Add reporting review guidance and evaluation fixtures (#183) * knowledge(performance): add job queue reliability guidance * Address Job Queue review feedback * Address Job Queue routing review feedback * Encode job queue conflict in fixtures * Add reporting review guidance and fixtures * Fix reporting article reference --- docs/using-bcquality.md | 3 +- evaluation/review-fixtures.json | 13 ++++ ...ariable-before-independent-runmodal.bad.al | 16 +++++ ...riable-before-independent-runmodal.good.al | 17 +++++ ...rt-variable-before-independent-runmodal.md | 32 ++++++++++ ...port-break-ends-the-current-trigger.bad.al | 31 +++++++++ ...ort-break-ends-the-current-trigger.good.al | 31 +++++++++ ...rrreport-break-ends-the-current-trigger.md | 30 +++++++++ ...t-rolls-back-and-skips-onpostreport.bad.al | 27 ++++++++ ...-rolls-back-and-skips-onpostreport.good.al | 28 +++++++++ ...-quit-rolls-back-and-skips-onpostreport.md | 30 +++++++++ ...ort-skip-does-not-stop-trigger-code.bad.al | 26 ++++++++ ...rt-skip-does-not-stop-trigger-code.good.al | 28 +++++++++ ...rreport-skip-does-not-stop-trigger-code.md | 30 +++++++++ ...in-a-loop-needs-one-client-download.bad.al | 14 +++++ ...n-a-loop-needs-one-client-download.good.al | 37 +++++++++++ ...put-in-a-loop-needs-one-client-download.md | 32 ++++++++++ ...-dataitem-trigger-order-is-explicit.bad.al | 30 +++++++++ ...dataitem-trigger-order-is-explicit.good.al | 30 +++++++++ ...sion-dataitem-trigger-order-is-explicit.md | 32 ++++++++++ ...rt-triggers-run-after-base-triggers.bad.al | 31 +++++++++ ...t-triggers-run-after-base-triggers.good.al | 37 +++++++++++ ...report-triggers-run-after-base-triggers.md | 30 +++++++++ ...ew-cannot-broaden-dataitemtableview.bad.al | 26 ++++++++ ...w-cannot-broaden-dataitemtableview.good.al | 26 ++++++++ ...leview-cannot-broaden-dataitemtableview.md | 30 +++++++++ ...equestpage-returns-empty-parameters.bad.al | 17 +++++ ...questpage-returns-empty-parameters.good.al | 20 ++++++ ...runrequestpage-returns-empty-parameters.md | 30 +++++++++ microsoft/skills/review/al-code-review.md | 1 + .../skills/review/al-reporting-review.md | 63 +++++++++++++++++++ 31 files changed, 827 insertions(+), 1 deletion(-) create mode 100644 microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al create mode 100644 microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al create mode 100644 microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md create mode 100644 microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al create mode 100644 microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al create mode 100644 microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md create mode 100644 microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al create mode 100644 microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al create mode 100644 microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md create mode 100644 microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al create mode 100644 microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al create mode 100644 microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md create mode 100644 microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al create mode 100644 microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al create mode 100644 microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md create mode 100644 microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al create mode 100644 microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al create mode 100644 microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md create mode 100644 microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al create mode 100644 microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al create mode 100644 microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md create mode 100644 microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al create mode 100644 microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al create mode 100644 microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md create mode 100644 microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al create mode 100644 microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al create mode 100644 microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md create mode 100644 microsoft/skills/review/al-reporting-review.md diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md index dcaddde..e55751f 100644 --- a/docs/using-bcquality.md +++ b/docs/using-bcquality.md @@ -183,7 +183,7 @@ using your normal compilation, analyzer, test, and human-review workflow. ## Coverage and limits -The Microsoft broad review composes the 16 Microsoft domains listed below. +The Microsoft broad review composes the 17 Microsoft domains listed below. The Community Agents review is a separate skill selected by the request, not a nested part of that coordinator. All current review leaves accept app folders, files, and diffs; request an Agent SDK review explicitly when that @@ -219,6 +219,7 @@ Each article describes one concern. Where samples exist, use its linked | Performance | [Performance](../microsoft/knowledge/performance/) | | Privacy | [Privacy](../microsoft/knowledge/privacy/) | | Query objects | [Query](../microsoft/knowledge/query/) | +| Reporting | [Reporting](../microsoft/knowledge/reporting/) | | Security | [Security](../microsoft/knowledge/security/) | | Style | [Style](../microsoft/knowledge/style/) | | Telemetry | [Telemetry](../microsoft/knowledge/telemetry/) | diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 352fccf..3eeed68 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -30,6 +30,19 @@ "privacy": { "article": "no-pii-in-telemetry-message-string" }, + "reporting": { + "articles": [ + "clear-report-variable-before-independent-runmodal", + "currreport-break-ends-the-current-trigger", + "currreport-quit-rolls-back-and-skips-onpostreport", + "currreport-skip-does-not-stop-trigger-code", + "report-output-in-a-loop-needs-one-client-download", + "reportextension-dataitem-trigger-order-is-explicit", + "reportextension-report-triggers-run-after-base-triggers", + "settableview-cannot-broaden-dataitemtableview", + "stop-when-runrequestpage-returns-empty-parameters" + ] + }, "style": { "article": "label-comment-explains-placeholders" }, diff --git a/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al new file mode 100644 index 0000000..736ddbf --- /dev/null +++ b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.bad.al @@ -0,0 +1,16 @@ +codeunit 50102 "Run Customer Reports" +{ + procedure RunBlockedAndUnblockedCustomers() + var + Customer: Record Customer; + CustomerList: Report "Customer - List"; + begin + Customer.SetRange(Blocked, Customer.Blocked::All); + CustomerList.SetTableView(Customer); + CustomerList.RunModal(); + + Customer.SetRange(Blocked, Customer.Blocked::" "); + CustomerList.SetTableView(Customer); + CustomerList.RunModal(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al new file mode 100644 index 0000000..51e5a0e --- /dev/null +++ b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.good.al @@ -0,0 +1,17 @@ +codeunit 50102 "Run Customer Reports" +{ + procedure RunBlockedAndUnblockedCustomers() + var + Customer: Record Customer; + CustomerList: Report "Customer - List"; + begin + Customer.SetRange(Blocked, Customer.Blocked::All); + CustomerList.SetTableView(Customer); + CustomerList.RunModal(); + + Clear(CustomerList); + Customer.SetRange(Blocked, Customer.Blocked::" "); + CustomerList.SetTableView(Customer); + CustomerList.RunModal(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md new file mode 100644 index 0000000..b82c1fb --- /dev/null +++ b/microsoft/knowledge/reporting/clear-report-variable-before-independent-runmodal.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, runmodal, clear, settableview, instance, state, filters] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Clear a Report variable before an independent RunModal execution + +## Description + +`Report.Run()` automatically clears the report variable after execution, but `Report.RunModal()` does not. Reconfiguring and running the same variable for an independent operation can therefore retain filters and other instance state from the previous run. + +## Best Practice + +Call `Clear(ReportVariable)` before configuring a new, logically independent `RunModal()` execution on a reused report variable. No clear is required after a single execution, and retaining state is valid when the subsequent run intentionally continues with the same configuration. + +See sample: [`clear-report-variable-before-independent-runmodal.good.al`](clear-report-variable-before-independent-runmodal.good.al). + +## Anti Pattern + +Run the same report variable modally for two independent views without clearing it between runs. The second `SetTableView` can only narrow the existing report view, so filters retained by the instance can make the second result incomplete or empty. + +See sample: [`clear-report-variable-before-independent-runmodal.bad.al`](clear-report-variable-before-independent-runmodal.bad.al). + +## References + +`Report.RunModal()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-runmodal-method + +`Report.Run()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-run-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al new file mode 100644 index 0000000..dd88abc --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.bad.al @@ -0,0 +1,31 @@ +report 50105 "Customer Entry Review" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + var + EntryNo: Integer; + begin + repeat + EntryNo += 1; + if EntryNo = 5 then + CurrReport.Break(); + until EntryNo = 10; + + MarkCustomerReviewed(); + end; + } + } + + local procedure MarkCustomerReviewed() + begin + ReviewedCustomerCount += 1; + end; + + var + ReviewedCustomerCount: Integer; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al new file mode 100644 index 0000000..c220732 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.good.al @@ -0,0 +1,31 @@ +report 50105 "Customer Entry Review" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + var + EntryNo: Integer; + StopReview: Boolean; + begin + repeat + EntryNo += 1; + StopReview := EntryNo = 5; + until StopReview or (EntryNo = 10); + + MarkCustomerReviewed(); + end; + } + } + + local procedure MarkCustomerReviewed() + begin + ReviewedCustomerCount += 1; + end; + + var + ReviewedCustomerCount: Integer; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md new file mode 100644 index 0000000..ec22ea4 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-break-ends-the-current-trigger.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, currreport, break, loop, trigger, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# CurrReport.Break ends the current trigger + +## Description + +`CurrReport.Break()` inside a report dataitem trigger does more than leave an AL loop. It terminates the current trigger and omits the current record from the dataset. The report runtime still invokes the remaining triggers for that record. Consequently, statements after the loop in the current trigger do not run, while later report triggers can still produce side effects. + +## Best Practice + +Use an explicit loop condition or the AL `break` statement when only the loop must end and the current trigger must continue. Use `CurrReport.Break()` only when ending the trigger and omitting the current record are both intended, and keep subsequent report triggers safe for that omitted record. + +See sample: [`currreport-break-ends-the-current-trigger.good.al`](currreport-break-ends-the-current-trigger.good.al). + +## Anti Pattern + +Call `CurrReport.Break()` inside a loop and rely on statements after the loop to finish processing the current record. Those statements are unreachable when the call executes, the record is omitted, and remaining report triggers still run. + +See sample: [`currreport-break-ends-the-current-trigger.bad.al`](currreport-break-ends-the-current-trigger.bad.al). + +## References + +`Report.Break()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-break-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al new file mode 100644 index 0000000..262ca78 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.bad.al @@ -0,0 +1,27 @@ +report 50101 "Update Customer Review" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + begin + "Last Date Modified" := Today(); + Modify(); + + if Blocked <> Blocked::" " then + CurrReport.Quit(); + end; + } + } + + trigger OnPostReport() + begin + Message(CompletedMsg); + end; + + var + CompletedMsg: Label 'Customer review completed.'; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al new file mode 100644 index 0000000..f7a50c5 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.good.al @@ -0,0 +1,28 @@ +report 50101 "Update Customer Review" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + begin + if Blocked <> Blocked::" " then + Error(BlockedCustomerErr, "No."); + + "Last Date Modified" := Today(); + Modify(); + end; + } + } + + trigger OnPostReport() + begin + Message(CompletedMsg); + end; + + var + BlockedCustomerErr: Label 'Customer %1 is blocked.', Comment = '%1 = customer number'; + CompletedMsg: Label 'Customer review completed.'; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md new file mode 100644 index 0000000..287f625 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-quit-rolls-back-and-skips-onpostreport.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, currreport, quit, rollback, onpostreport, transaction, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# CurrReport.Quit rolls back report changes and skips OnPostReport + +## Description + +`CurrReport.Quit()` aborts the report without committing database changes made during its execution. It also prevents `OnPostReport` from running. It is therefore not a normal early-return mechanism for a processing report that expects earlier writes or finalization in `OnPostReport` to survive. + +## Best Practice + +Use `CurrReport.Quit()` only when silently aborting the report, rolling back its database changes, and skipping `OnPostReport` are all intentional. When processing must stop with a failure, raise an error. When completed work and `OnPostReport` must be preserved, structure the dataitem control flow without `Quit()`. + +See sample: [`currreport-quit-rolls-back-and-skips-onpostreport.good.al`](currreport-quit-rolls-back-and-skips-onpostreport.good.al). + +## Anti Pattern + +Modify data and then call `CurrReport.Quit()` while relying on those writes or on `OnPostReport` finalization. The report exits without committing its changes and never invokes `OnPostReport`. + +See sample: [`currreport-quit-rolls-back-and-skips-onpostreport.bad.al`](currreport-quit-rolls-back-and-skips-onpostreport.bad.al). + +## References + +`Report.Quit()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-quit-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al new file mode 100644 index 0000000..1debd99 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.bad.al @@ -0,0 +1,26 @@ +report 50100 "Released Customer List" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + begin + if Blocked <> Blocked::" " then + CurrReport.Skip(); + + CountIncludedCustomer(); + end; + } + } + + local procedure CountIncludedCustomer() + begin + IncludedCustomerCount += 1; + end; + + var + IncludedCustomerCount: Integer; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al new file mode 100644 index 0000000..f239a2b --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.good.al @@ -0,0 +1,28 @@ +report 50100 "Released Customer List" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnAfterGetRecord() + begin + if Blocked <> Blocked::" " then begin + CurrReport.Skip(); + exit; + end; + + CountIncludedCustomer(); + end; + } + } + + local procedure CountIncludedCustomer() + begin + IncludedCustomerCount += 1; + end; + + var + IncludedCustomerCount: Integer; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md new file mode 100644 index 0000000..d2b4e34 --- /dev/null +++ b/microsoft/knowledge/reporting/currreport-skip-does-not-stop-trigger-code.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, currreport, skip, trigger, onaftergetrecord, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# CurrReport.Skip omits the record but does not stop trigger code + +## Description + +`CurrReport.Skip()` omits the current record from the report dataset and continues processing with the next record. It does not terminate the current trigger, and the remaining triggers for the current record still run. Code placed after `Skip()` can therefore produce side effects for a record that never appears in the output. + +## Best Practice + +When no further code in the current trigger should run for a skipped record, call `CurrReport.Skip()` and then exit the trigger explicitly. Keep later record triggers safe for skipped records because the report runtime still invokes them. + +See sample: [`currreport-skip-does-not-stop-trigger-code.good.al`](currreport-skip-does-not-stop-trigger-code.good.al). + +## Anti Pattern + +Call `CurrReport.Skip()` and rely on it to bypass subsequent statements or later record triggers. The record is removed from the dataset, but those statements and triggers can still update state, write data, or perform expensive work. + +See sample: [`currreport-skip-does-not-stop-trigger-code.bad.al`](currreport-skip-does-not-stop-trigger-code.bad.al). + +## References + +`Report.Skip()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-skip-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al new file mode 100644 index 0000000..945c0ae --- /dev/null +++ b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.bad.al @@ -0,0 +1,14 @@ +codeunit 50106 "Download Customer Reports" +{ + procedure DownloadReports(var Customer: Record Customer) + var + CustomerView: Record Customer; + begin + if Customer.FindSet() then + repeat + CustomerView := Customer; + CustomerView.SetRecFilter(); + Report.Run(Report::"Customer - List", false, false, CustomerView); + until Customer.Next() = 0; + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al new file mode 100644 index 0000000..dea2e7f --- /dev/null +++ b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.good.al @@ -0,0 +1,37 @@ +codeunit 50106 "Download Customer Reports" +{ + procedure DownloadReports(var Customer: Record Customer) + var + CustomerView: Record Customer; + CustomerList: Report "Customer - List"; + DataCompression: Codeunit "Data Compression"; + ReportTempBlob: Codeunit "Temp Blob"; + ZipTempBlob: Codeunit "Temp Blob"; + ReportInStream: InStream; + ZipInStream: InStream; + ReportOutStream: OutStream; + ZipOutStream: OutStream; + ZipFileName: Text; + begin + DataCompression.CreateZipArchive(); + if Customer.FindSet() then + repeat + Clear(CustomerList); + Clear(ReportTempBlob); + CustomerView := Customer; + CustomerView.SetRecFilter(); + CustomerList.SetTableView(CustomerView); + ReportTempBlob.CreateOutStream(ReportOutStream); + CustomerList.SaveAs('', ReportFormat::Pdf, ReportOutStream); + ReportTempBlob.CreateInStream(ReportInStream); + DataCompression.AddEntry(ReportInStream, Customer."No." + '.pdf'); + until Customer.Next() = 0; + + ZipTempBlob.CreateOutStream(ZipOutStream); + DataCompression.SaveZipArchive(ZipOutStream); + DataCompression.CloseZipArchive(); + ZipTempBlob.CreateInStream(ZipInStream); + ZipFileName := 'CustomerReports.zip'; + DownloadFromStream(ZipInStream, '', '', '*.zip', ZipFileName); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md new file mode 100644 index 0000000..8aede8b --- /dev/null +++ b/microsoft/knowledge/reporting/report-output-in-a-loop-needs-one-client-download.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, run, saveas, downloadfromstream, web-client, loop, zip, data-compression] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Report output in a loop needs one client download + +## Description + +The Business Central Web client can deliver only one file per request. When AL generates or downloads a report file repeatedly in the same request, only the last file is delivered to the browser. Earlier report output is silently unavailable to the user even though every iteration ran. + +## Best Practice + +Generate each report into a stream, add the streams to one archive, and call `DownloadFromStream` once after the loop. A direct report run or download inside a loop is valid only when the execution context does not use the Web client or the loop is guaranteed to execute at most once. + +See sample: [`report-output-in-a-loop-needs-one-client-download.good.al`](report-output-in-a-loop-needs-one-client-download.good.al). + +## Anti Pattern + +Call `Report.Run`, `Report.RunModal`, or `DownloadFromStream` repeatedly in a loop initiated by one Web client action and expect every generated file to reach the browser. The client receives only the last download. + +See sample: [`report-output-in-a-loop-needs-one-client-download.bad.al`](report-output-in-a-loop-needs-one-client-download.bad.al). + +## References + +`File.DownloadFromStream` method — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/file/file-downloadfromstream-method + +`Data Compression` codeunit — https://learn.microsoft.com/dynamics365/business-central/application/system-application/codeunit/system.io.data-compression \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al new file mode 100644 index 0000000..7a25a12 --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.bad.al @@ -0,0 +1,30 @@ +report 50103 "Base Customer Export" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnPreDataItem() + begin + SetRange(Blocked, Blocked::" "); + SetRange("Country/Region Code"); + end; + } + } +} + +reportextension 50104 "Local Customer Export" extends "Base Customer Export" +{ + dataset + { + modify(Customer) + { + trigger OnBeforePreDataItem() + begin + SetFilter("Country/Region Code", '<>%1', ''); + end; + } + } +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al new file mode 100644 index 0000000..52c3ebe --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.good.al @@ -0,0 +1,30 @@ +report 50103 "Base Customer Export" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + trigger OnPreDataItem() + begin + SetRange(Blocked, Blocked::" "); + SetRange("Country/Region Code"); + end; + } + } +} + +reportextension 50104 "Local Customer Export" extends "Base Customer Export" +{ + dataset + { + modify(Customer) + { + trigger OnAfterPreDataItem() + begin + SetFilter("Country/Region Code", '<>%1', ''); + end; + } + } +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md new file mode 100644 index 0000000..c149748 --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-dataitem-trigger-order-is-explicit.md @@ -0,0 +1,32 @@ +--- +bc-version: [19..] +domain: reporting +keywords: [reportextension, report, dataitem, trigger-order, onbeforepredataitem, onafterpredataitem, onbeforeaftergetrecord, onafteraftergetrecord] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Choose ReportExtension dataitem triggers by their order around the base trigger + +## Description + +ReportExtension dataitem triggers run at defined points around the corresponding base-report trigger. `OnBeforePreDataItem` and `OnBeforeAfterGetRecord` run before the base trigger; `OnAfterPreDataItem` and `OnAfterAfterGetRecord` run after it. A filter or calculated value can be overwritten when an extension uses a before-trigger even though its result must be final after base processing. + +## Best Practice + +Choose the before or after trigger from the required ordering relative to base behavior. Use an after-trigger when the extension must observe or refine the final view or value produced by the base trigger. A before-trigger is valid when the base report must consume the extension's state. + +See sample: [`reportextension-dataitem-trigger-order-is-explicit.good.al`](reportextension-dataitem-trigger-order-is-explicit.good.al). + +## Anti Pattern + +Place extension logic in a before-trigger while relying on its filter or value to survive a base trigger that can replace it. Do not report a before-trigger merely because an after-trigger exists; the defect requires visible base behavior or another reliable source showing that ordering changes the result. + +See sample: [`reportextension-dataitem-trigger-order-is-explicit.bad.al`](reportextension-dataitem-trigger-order-is-explicit.bad.al). + +## References + +`OnBeforePreDataItem` report-extension trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/reportextensiondatasetmodify/devenv-onbeforepredataitem-reportextensiondatasetmodify-trigger + +`OnAfterPreDataItem` report-extension trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/reportextensiondatasetmodify/devenv-onafterpredataitem-reportextensiondatasetmodify-trigger \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al new file mode 100644 index 0000000..d932f91 --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.bad.al @@ -0,0 +1,31 @@ +report 50110 "Customer Export" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + } + } + + trigger OnPreReport() + var + ExportSetup: Record "Customer Export Setup"; + begin + ExportSetup.Get(); + ExportSetup.TestField("Export Date"); + end; +} + +reportextension 50111 "Customer Export Extension" extends "Customer Export" +{ + trigger OnPreReport() + var + ExportSetup: Record "Customer Export Setup"; + begin + ExportSetup.Get(); + ExportSetup.Validate("Export Date", Today()); + ExportSetup.Modify(true); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al new file mode 100644 index 0000000..293784b --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.good.al @@ -0,0 +1,37 @@ +report 50110 "Customer Export" +{ + ProcessingOnly = true; + + dataset + { + dataitem(Customer; Customer) + { + } + } + + trigger OnPreReport() + var + ExportDate: Date; + begin + OnBeforeResolveExportDate(ExportDate); + if ExportDate = 0D then + Error(ExportDateRequiredErr); + end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeResolveExportDate(var ExportDate: Date) + begin + end; + + var + ExportDateRequiredErr: Label 'An export date is required.'; +} + +codeunit 50111 "Customer Export Extension" +{ + [EventSubscriber(ObjectType::Report, Report::"Customer Export", 'OnBeforeResolveExportDate', '', false, false)] + local procedure SetExportDate(var ExportDate: Date) + begin + ExportDate := Today(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md new file mode 100644 index 0000000..414752a --- /dev/null +++ b/microsoft/knowledge/reporting/reportextension-report-triggers-run-after-base-triggers.md @@ -0,0 +1,30 @@ +--- +bc-version: [18..] +domain: reporting +keywords: [reportextension, report, trigger-order, onprereport, onpostreport, base-report, integration-event] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ReportExtension report triggers run after base report triggers + +## Description + +`OnPreReport` and `OnPostReport` on a ReportExtension run after the corresponding triggers on the base report. An extension `OnPreReport` cannot prepare state that the base `OnPreReport` must consume, and an extension `OnPostReport` cannot affect finalization that the base `OnPostReport` has already completed. + +## Best Practice + +Use a base-report event at the required execution point when extension logic must run before or within a base trigger. Use ReportExtension `OnPreReport` and `OnPostReport` only for work that is correct after the corresponding base trigger. Report a violation only when the base trigger and extension dependency are both visible or otherwise established. + +See sample: [`reportextension-report-triggers-run-after-base-triggers.good.al`](reportextension-report-triggers-run-after-base-triggers.good.al). + +## Anti Pattern + +Initialize data in a ReportExtension `OnPreReport` and rely on the base report's `OnPreReport` to consume it, or perform extension `OnPostReport` work that the base `OnPostReport` needed beforehand. The base trigger has already run. + +See sample: [`reportextension-report-triggers-run-after-base-triggers.bad.al`](reportextension-report-triggers-run-after-base-triggers.bad.al). + +## References + +Report extension object — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-ext-object \ No newline at end of file diff --git a/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al new file mode 100644 index 0000000..844c542 --- /dev/null +++ b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.bad.al @@ -0,0 +1,26 @@ +report 50107 "Selected Sales Orders" +{ + ProcessingOnly = true; + + dataset + { + dataitem(SalesHeader; "Sales Header") + { + DataItemTableView = where("Document Type" = const(Order), Status = const(Open)); + } + } +} + +codeunit 50108 "Run Selected Sales Orders" +{ + procedure RunReleasedOrders() + var + SalesHeader: Record "Sales Header"; + SelectedSalesOrders: Report "Selected Sales Orders"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange(Status, SalesHeader.Status::Released); + SelectedSalesOrders.SetTableView(SalesHeader); + SelectedSalesOrders.RunModal(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al new file mode 100644 index 0000000..5dc4f2e --- /dev/null +++ b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.good.al @@ -0,0 +1,26 @@ +report 50107 "Selected Sales Orders" +{ + ProcessingOnly = true; + + dataset + { + dataitem(SalesHeader; "Sales Header") + { + DataItemTableView = where("Document Type" = const(Order)); + } + } +} + +codeunit 50108 "Run Selected Sales Orders" +{ + procedure RunReleasedOrders() + var + SalesHeader: Record "Sales Header"; + SelectedSalesOrders: Report "Selected Sales Orders"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange(Status, SalesHeader.Status::Released); + SelectedSalesOrders.SetTableView(SalesHeader); + SelectedSalesOrders.RunModal(); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md new file mode 100644 index 0000000..2dfc691 --- /dev/null +++ b/microsoft/knowledge/reporting/settableview-cannot-broaden-dataitemtableview.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, settableview, dataitemtableview, filter, view, narrowing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# SetTableView cannot broaden DataItemTableView + +## Description + +`Report.SetTableView()` applies the supplied record view by narrowing the view already defined by the report dataitem's `DataItemTableView`. It cannot remove or broaden a static dataitem filter. A caller that requests records excluded by `DataItemTableView` therefore produces an empty dataset rather than overriding the report filter. + +## Best Practice + +Keep only invariant restrictions in `DataItemTableView`. When callers must select among values, leave that dimension open in the static view and pass the required filter through `SetTableView`. Review this as a defect only when the report definition and caller together show a contradictory filter. + +See sample: [`settableview-cannot-broaden-dataitemtableview.good.al`](settableview-cannot-broaden-dataitemtableview.good.al). + +## Anti Pattern + +Define a static filter in `DataItemTableView` and call `SetTableView` with a mutually exclusive filter while expecting the runtime view to replace the static one. The filters are intersected and no records are selected. + +See sample: [`settableview-cannot-broaden-dataitemtableview.bad.al`](settableview-cannot-broaden-dataitemtableview.bad.al). + +## References + +`Report.SetTableView()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-settableview-method \ No newline at end of file diff --git a/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al new file mode 100644 index 0000000..feccfb6 --- /dev/null +++ b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.bad.al @@ -0,0 +1,17 @@ +codeunit 50109 "Export Customer Report" +{ + procedure ExportReport() + var + TempBlob: Codeunit "Temp Blob"; + ReportOutStream: OutStream; + RequestPageParameters: Text; + begin + RequestPageParameters := Report.RunRequestPage(Report::"Customer - List"); + TempBlob.CreateOutStream(ReportOutStream); + Report.SaveAs( + Report::"Customer - List", + RequestPageParameters, + ReportFormat::Pdf, + ReportOutStream); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al new file mode 100644 index 0000000..d706a2f --- /dev/null +++ b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.good.al @@ -0,0 +1,20 @@ +codeunit 50109 "Export Customer Report" +{ + procedure ExportReport() + var + TempBlob: Codeunit "Temp Blob"; + ReportOutStream: OutStream; + RequestPageParameters: Text; + begin + RequestPageParameters := Report.RunRequestPage(Report::"Customer - List"); + if RequestPageParameters = '' then + exit; + + TempBlob.CreateOutStream(ReportOutStream); + Report.SaveAs( + Report::"Customer - List", + RequestPageParameters, + ReportFormat::Pdf, + ReportOutStream); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md new file mode 100644 index 0000000..0cc0e71 --- /dev/null +++ b/microsoft/knowledge/reporting/stop-when-runrequestpage-returns-empty-parameters.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: reporting +keywords: [report, runrequestpage, cancel, parameters, saveas, execute, print] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Stop when RunRequestPage returns empty parameters + +## Description + +`Report.RunRequestPage()` returns an empty string when the user chooses **Cancel**. Passing that value to `Report.Execute`, `Report.Print`, or `Report.SaveAs` ignores the cancellation and can run the report with default parameters instead. + +## Best Practice + +Test the returned parameter string immediately after `RunRequestPage()` and exit when it is empty. Pass the value to `Execute`, `Print`, or `SaveAs` only after the user has confirmed the request page. + +See sample: [`stop-when-runrequestpage-returns-empty-parameters.good.al`](stop-when-runrequestpage-returns-empty-parameters.good.al). + +## Anti Pattern + +Call `RunRequestPage()` and unconditionally pass its return value to a report execution method. Choosing **Cancel** can still execute, print, or save the report. + +See sample: [`stop-when-runrequestpage-returns-empty-parameters.bad.al`](stop-when-runrequestpage-returns-empty-parameters.bad.al). + +## References + +`Report.RunRequestPage()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/report-runrequestpage-method \ No newline at end of file diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 9b5f739..dc1f1db 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -25,6 +25,7 @@ sub-skills: - microsoft/skills/review/al-testing-review.md - microsoft/skills/review/al-data-modeling-review.md - microsoft/skills/review/al-query-review.md + - microsoft/skills/review/al-reporting-review.md - microsoft/skills/review/al-appsource-review.md - microsoft/skills/review/al-telemetry-review.md --- diff --git a/microsoft/skills/review/al-reporting-review.md b/microsoft/skills/review/al-reporting-review.md new file mode 100644 index 0000000..52f8f53 --- /dev/null +++ b/microsoft/skills/review/al-reporting-review.md @@ -0,0 +1,63 @@ +--- +kind: action-skill +id: al-reporting-review +version: 1 +title: AL reporting review +description: Reviews AL Report and ReportExtension code against BCQuality reporting guidance. +inputs: [pr-diff, file-path, folder-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL reporting review + +Reviews AL source changes against the `reporting` knowledge domain in BCQuality. This is a leaf action skill composed by `al-code-review`. + +## Source + +Use READ's **Bounded retrieval for review skills** workflow with `-Domain reporting`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. + +## Relevance + +Apply READ's frontmatter matching rules against the task context. Use the target version from `app.json` when available and `[al]` for technologies. Retain conditionally applicable files only when configured; cap resulting confidence at `medium` and name every unknown dimension in the finding message. + +Return `not-applicable` when the input contains no Report or ReportExtension declaration and no Report variable or method call. + +## Worklist + +Match relevant entries against changed `report` and `reportextension` objects, variables typed as `Report`, and the tokens `CurrReport`, `Skip`, `Break`, `Quit`, `Run`, `RunModal`, `RunRequestPage`, `Execute`, `Print`, `SaveAs`, `DownloadFromStream`, `Data Compression`, `SetTableView`, `DataItemTableView`, `OnPreReport`, `OnPostReport`, `OnPreDataItem`, `OnAfterGetRecord`, and report-extension dataset triggers. + +Apply this targeted check even when token overlap would rank the article below the worklist cutoff: + +- The same Report variable has two logically independent `RunModal()` executions without `Clear` before the second configuration — `clear-report-variable-before-independent-runmodal`. +- `CurrReport.Break()` is used inside an explicit loop while reachable statements after the loop are expected to finish the current trigger — `currreport-break-ends-the-current-trigger`. +- `CurrReport.Quit()` follows database writes or the report relies on `OnPostReport` finalization — `currreport-quit-rolls-back-and-skips-onpostreport`. +- `CurrReport.Skip()` is followed by reachable code in the same trigger, or later record triggers contain work that is unsafe for skipped records — `currreport-skip-does-not-stop-trigger-code`. +- A loop reachable from one Web client action calls `Report.Run`, `Report.RunModal`, or `DownloadFromStream` more than once instead of producing one archive download — `report-output-in-a-loop-needs-one-client-download`. Do not select this article when the context is non-Web or the loop is provably single-iteration. +- A ReportExtension before-trigger establishes a filter or value that visible base-trigger code subsequently replaces — `reportextension-dataitem-trigger-order-is-explicit`. Do not select this article from a before-trigger alone. +- A ReportExtension `OnPreReport` prepares state consumed by the base `OnPreReport`, or its `OnPostReport` prepares state already consumed by the base `OnPostReport` — `reportextension-report-triggers-run-after-base-triggers`. Require visible base behavior or equivalent established evidence. +- A report's `DataItemTableView` and a caller's `SetTableView` apply mutually exclusive filters to the same field — `settableview-cannot-broaden-dataitemtableview`. Require both views or equivalent direct evidence; `SetTableView` alone is not a finding. +- The value returned by `Report.RunRequestPage()` reaches `Report.Execute`, `Report.Print`, or `Report.SaveAs` without an empty-string cancellation check — `stop-when-runrequestpage-returns-empty-parameters`. + +Resolve layer conflicts per READ. When no reporting knowledge exists, emit `no-knowledge`; when knowledge exists but no article matches the changed report code, emit `completed` with no findings. + +## Action + +Evaluate every worklist article against the diff's report control flow and surrounding triggers. + +- Emit `major` for an unambiguous Anti Pattern that causes incorrect output, persisted side effects, or lost work. +- Emit `minor` when code contradicts a Best Practice but the effect depends on unseen report or caller context. +- Do not emit applicability-only information. A reporting article produces a finding only when changed code violates its normative guidance. + +Set confidence to `high` for locally visible control flow and `medium` when base-report behavior, callers, or missing context affect the conclusion. Domain-scoped agent findings follow DO's precision bar and remain capped at `minor`/`medium`. + +Provide `suggested-code` only when the replacement is complete, local, and unambiguous. Otherwise set `suggested-code-omission-reason`. + +Outcome selection follows DO: `completed`, `no-knowledge`, `not-applicable`, `partial`, or `failed`. + +## Output + +Output conforms to the DO findings-report contract. Every finding this skill emits MUST set `findings[].domain` to `"Reporting"`. \ No newline at end of file From d24dc7b14b39624e8b2160dbca73d66140106e4c Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 15 Sep 2026 10:45:35 +0200 Subject: [PATCH 14/51] Fix reporting skill index expectation (#185) Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Test-SkillIndex.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/Test-SkillIndex.ps1 b/.github/scripts/Test-SkillIndex.ps1 index 839042f..ed07204 100644 --- a/.github/scripts/Test-SkillIndex.ps1 +++ b/.github/scripts/Test-SkillIndex.ps1 @@ -91,6 +91,7 @@ try { 'microsoft/skills/review/al-testing-review.md', 'microsoft/skills/review/al-data-modeling-review.md', 'microsoft/skills/review/al-query-review.md', + 'microsoft/skills/review/al-reporting-review.md', 'microsoft/skills/review/al-appsource-review.md', 'microsoft/skills/review/al-telemetry-review.md' ) @@ -99,7 +100,7 @@ try { throw "Expected exactly one al-code-review record, found $($review.Count)." } if ((@($review[0].subSkills) -join "`n") -cne ($expectedLeaves -join "`n")) { - throw 'al-code-review subSkills did not preserve the declared 16-leaf order.' + throw 'al-code-review subSkills did not preserve the declared 17-leaf order.' } foreach ($leafPath in $expectedLeaves) { $leaf = @($skills | Where-Object path -ceq $leafPath) @@ -237,4 +238,4 @@ finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } -Write-Output 'Skill-index check PASSED: deterministic, schema-valid, and all 16 review leaves preserved in order.' +Write-Output 'Skill-index check PASSED: deterministic, schema-valid, and all 17 review leaves preserved in order.' From b7617fb48a01c9f41116e37edaede3daed424236 Mon Sep 17 00:00:00 2001 From: waldo Date: Tue, 15 Sep 2026 12:34:28 +0200 Subject: [PATCH 15/51] knowledge(events): ChangeCompany leaves triggers and trigger-event subscribers running in the calling company (#152) * knowledge(events): ChangeCompany leaves triggers and trigger-event subscribers running in the calling company ChangeCompany redirects only the data access of a record variable; Learn states that triggers still run in the current company. The database trigger events are raised on every database operation and only pass RunTrigger to the subscriber, so Insert(false) after ChangeCompany still runs every subscriber in the calling company. Generated code either assumes the record 'becomes' a target-company record, or switches RunTrigger off and hand-copies the trigger logic, leaving the subscribers writing to the wrong company; none of the tested runs reached StartSession with the company parameter. Co-Authored-By: Claude Fable 5.1 * knowledge(events): qualify StartSession async semantics and fix concurrency-unsafe sample key Addresses PR #152 review: StartSession is a fire-and-forget background session (Ok reports only whether it started, not whether the codeunit succeeded, and errors inside it do not propagate), so the Best Practice now scopes the recommendation and calls out the durable status/error channel a synchronous-success write needs. The good sample's FindLast()+1 entry-number pattern raced under concurrent background sessions; switched to AutoIncrement, which the platform guarantees is unique across concurrent transactions. Co-Authored-By: Claude Sonnet 5 * knowledge(events): serialize the setup-counter increment; promote article and wire the review skill PR #152 round 3 (JesperSchulz): - The good sample's OnAfterInsertEvent subscriber still raced on the shared "Transfer Setup Good" singleton (Get/increment/Modify); AutoIncrement only protected the request key. Added TransferSetup.LockTable() before Get() to serialize concurrent background sessions. - Promoted changecompany-runs-triggers-in-the-calling-company from community/knowledge/events/ to microsoft/knowledge/events/, and wired ChangeCompany/StartSession/RunTrigger tokens plus a targeted detection cue into microsoft/skills/review/al-events-review.md so a diff containing the anti-pattern reliably worklists this article, preserving the documented RunTrigger=false hand-off exception. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: waldo1001 <12088142+waldo1001@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- ...uns-triggers-in-the-calling-company.bad.al | 91 +++++++++++++++++++ ...ns-triggers-in-the-calling-company.good.al | 84 +++++++++++++++++ ...ny-runs-triggers-in-the-calling-company.md | 42 +++++++++ microsoft/skills/review/al-events-review.md | 3 +- 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.bad.al create mode 100644 microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.good.al create mode 100644 microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md diff --git a/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.bad.al b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.bad.al new file mode 100644 index 0000000..7b8e7d5 --- /dev/null +++ b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.bad.al @@ -0,0 +1,91 @@ +codeunit 50100 "Transfer Request Bad" +{ + // Self-contained demonstration of the anti pattern. Not derived from base-app source. + procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal) + var + TransferRequest: Record "Transfer Request Bad"; + TransferSetup: Record "Transfer Setup Bad"; + begin + TransferRequest.ChangeCompany(TargetCompany); + TransferSetup.ChangeCompany(TargetCompany); + TransferSetup.Get(); + + TransferRequest.Init(); + TransferRequest."Entry No." := NextEntryNo(TargetCompany); + TransferRequest."Item No." := ItemNo; + TransferRequest.Quantity := Quantity; + // OnInsert is skipped below, so the default is copied by hand from the target company's setup. + TransferRequest."Location Code" := TransferSetup."Default Location Code"; + // The OnAfterInsertEvent subscriber still fires, in the calling company, and grows the caller's counter. + TransferRequest.Insert(false); + + TransferSetup."Open Requests" += 1; + TransferSetup.Modify(); + end; + + local procedure NextEntryNo(TargetCompany: Text[30]): Integer + var + LastRequest: Record "Transfer Request Bad"; + begin + LastRequest.ChangeCompany(TargetCompany); + if LastRequest.FindLast() then + exit(LastRequest."Entry No." + 1); + exit(1); + end; +} + +table 50100 "Transfer Request Bad" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Item No."; Code[20]) { } + field(3; Quantity; Decimal) { } + field(4; "Location Code"; Code[10]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } + + trigger OnInsert() + var + TransferSetup: Record "Transfer Setup Bad"; + begin + TransferSetup.Get(); + "Location Code" := TransferSetup."Default Location Code"; + end; +} + +table 50101 "Transfer Setup Bad" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) { } + field(2; "Default Location Code"; Code[10]) { } + field(3; "Open Requests"; Integer) { } + } + + keys + { + key(PK; "Primary Key") { Clustered = true; } + } +} + +codeunit 50101 "Transfer Request Count Bad" +{ + [EventSubscriber(ObjectType::Table, Database::"Transfer Request Bad", OnAfterInsertEvent, '', false, false)] + local procedure CountOpenRequest(var Rec: Record "Transfer Request Bad"; RunTrigger: Boolean) + var + TransferSetup: Record "Transfer Setup Bad"; + begin + TransferSetup.Get(); + TransferSetup."Open Requests" += 1; + TransferSetup.Modify(); + end; +} diff --git a/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.good.al b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.good.al new file mode 100644 index 0000000..dd92d9d --- /dev/null +++ b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.good.al @@ -0,0 +1,84 @@ +codeunit 50100 "Transfer Request Good" +{ + // Self-contained demonstration of the best practice. Not derived from base-app source. + procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal) + var + TransferRequest: Record "Transfer Request Good"; + SessionId: Integer; + begin + TransferRequest.Init(); + TransferRequest."Item No." := ItemNo; + TransferRequest.Quantity := Quantity; + // The insert runs inside TargetCompany, so OnInsert and the subscriber read that company's setup. + StartSession(SessionId, Codeunit::"Transfer Request Create Good", TargetCompany, TransferRequest); + end; +} + +codeunit 50102 "Transfer Request Create Good" +{ + TableNo = "Transfer Request Good"; + + trigger OnRun() + begin + // "Entry No." is AutoIncrement, so concurrent background sessions in TargetCompany never race on the same value. + Rec.Insert(true); + end; +} + +table 50100 "Transfer Request Good" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { AutoIncrement = true; } + field(2; "Item No."; Code[20]) { } + field(3; Quantity; Decimal) { } + field(4; "Location Code"; Code[10]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } + + trigger OnInsert() + var + TransferSetup: Record "Transfer Setup Good"; + begin + TransferSetup.Get(); + "Location Code" := TransferSetup."Default Location Code"; + end; +} + +table 50101 "Transfer Setup Good" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Primary Key"; Code[10]) { } + field(2; "Default Location Code"; Code[10]) { } + field(3; "Open Requests"; Integer) { } + } + + keys + { + key(PK; "Primary Key") { Clustered = true; } + } +} + +codeunit 50101 "Transfer Request Count Good" +{ + [EventSubscriber(ObjectType::Table, Database::"Transfer Request Good", OnAfterInsertEvent, '', false, false)] + local procedure CountOpenRequest(var Rec: Record "Transfer Request Good"; RunTrigger: Boolean) + var + TransferSetup: Record "Transfer Setup Good"; + begin + // Serializes the read-modify-write so concurrent background sessions don't lose an increment. + TransferSetup.LockTable(); + TransferSetup.Get(); + TransferSetup."Open Requests" += 1; + TransferSetup.Modify(); + end; +} diff --git a/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md new file mode 100644 index 0000000..4a524f4 --- /dev/null +++ b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md @@ -0,0 +1,42 @@ +--- +bc-version: [all] +domain: events +keywords: [changecompany, cross-company, runtrigger, trigger-event, subscriber, onafterinsertevent, insert, startsession, multi-company] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ChangeCompany leaves triggers and trigger-event subscribers running in the calling company + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`. + +## Best Practice + +Use `ChangeCompany` to read. Access rights in the target company are still enforced, so reads are safe. When the goal is business data in another company, run the code in that company: `StartSession` takes a company name and runs a codeunit there, so triggers, validation, and subscribers all execute with the target company as their context. `StartSession` is a background session, not a synchronous call: the `Ok` return value reports only whether the session started, not whether the codeunit's work inside it succeeded, the caller's transaction does not extend into it, and an error raised there does not come back to the caller — it has to be logged or telemetered from inside that session. Reach for `StartSession` only for work the caller does not need to confirm before it continues; a write whose success the caller must know synchronously needs a durable status or error channel (a field the caller polls, a job queue with retry) rather than a bare `StartSession` call. Learn notes that a background session costs as much as a user session to start, so batch the work rather than starting one session per row, or let the target company process a hand-off row on its own schedule. A direct cross-company write is acceptable only as such a hand-off into a table the writing extension owns, whose triggers do not read company data and whose trigger-event subscribers exit when `RunTrigger` is false, using `Insert(false)`, `Modify(false)`, or `Delete(false)`, and never `Validate`. + +See sample: [`changecompany-runs-triggers-in-the-calling-company.good.al`](changecompany-runs-triggers-in-the-calling-company.good.al). + +## Anti Pattern + +An `Insert`, `Modify`, `Delete`, or `Validate` on a record variable after `ChangeCompany()`, on a table whose triggers or trigger-event subscribers read setup, consume a number series, or write companion rows. With `RunTrigger = true` the trigger code fills the row from the caller's setup. With `RunTrigger = false` the trigger code is skipped, but the subscribers still fire in the caller, so a counter, log, or companion row maintained by a subscriber is written in the wrong company, and a caller that also updates the target by hand counts twice. + +Detection signal: a record variable that has had `ChangeCompany` called on it with a company name and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, or has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false`. Do not flag reads after `ChangeCompany`; writes with `RunTrigger = false` into an owned table whose triggers do not read company data and whose subscribers exit on `RunTrigger = false`; or `ChangeCompany()` without an argument, which points the variable back at the current company. + +See sample: [`changecompany-runs-triggers-in-the-calling-company.bad.al`](changecompany-runs-triggers-in-the-calling-company.bad.al). + +## See also + +- Record.ChangeCompany method, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-changecompany-method +- Record.Insert(Boolean) method, RunTrigger — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-insert-boolean-method +- Record.Delete method, RunTrigger defaults to false — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-delete-method +- OnInsert (Table) trigger, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/table/devenv-oninsert-table-trigger +- Event types, Database trigger events and order of event execution — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-event-types +- OnAfterInsertEvent trigger event, RunTrigger parameter — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/events/table/devenv-onafterinsertevent-table-trigger +- Session.StartSession method, Company parameter, Remarks (background session, no UI), and Return Value (`Ok` reports whether the session started, not whether the codeunit's work succeeded) — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/session/session-startsession-integer-integer-string-table-method +- AL error handling, error handling strategies: an error inside a rolled-back transaction is logged from a background session or telemetry, it does not return to the caller — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-al-error-handling +- AutoIncrement property, Remarks: "if several transactions are performed at the same time, they will each be assigned a different number" — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-autoincrement-property diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index 68bcdb0..b257d3d 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-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 that publish events or host event subscribers, posting/release/validation routines that should expose extension points, and test codeunits that bind subscribers. - The changed procedures and triggers, weighted toward event publisher methods, methods carrying the `[EventSubscriber(...)]` attribute, routines that raise `OnBefore`/`OnAfter` events, and any procedure that calls `BindSubscription`/`UnbindSubscription`. -- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`). +- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`, `ChangeCompany`, `StartSession`, `RunTrigger`). 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. @@ -65,6 +65,7 @@ The following targeted checks map diff signals to specific `events` articles. Tr - A `RecordRef` event parameter, or a passed-through `xRec`, where a concrete typed record fits — `avoid-loosely-typed-event-parameters`. - A `var IsHandled` added to a pre-existing event rather than introduced through a new `OnBefore` publisher — `do-not-add-ishandled-to-an-existing-event`. - An `if IsHandled then exit;` whose skipped body performs posting, ledger-entry creation, number-series consumption, or integrity/permission validation — `do-not-bypass-critical-operations-with-ishandled`. +- A record variable that had `ChangeCompany()` called on it and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false` — `changecompany-runs-triggers-in-the-calling-company`. Do not match a read-only use after `ChangeCompany`, a write with `RunTrigger = false` into an extension-owned table whose triggers do not read company data and whose trigger-event subscribers exit on `RunTrigger = false`, or the parameterless `ChangeCompany()` reset. ## Action From 861f53dd97fcc25cc797e79902884c7bbb612178 Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:51:15 +0200 Subject: [PATCH 16/51] Add query filter semantics guidance (#186) --- evaluation/review-fixtures.json | 8 ++++ ...er-cannot-be-overwritten-at-runtime.bad.al | 39 +++++++++++++++++++ ...r-cannot-be-overwritten-at-runtime.good.al | 38 ++++++++++++++++++ ...filter-cannot-be-overwritten-at-runtime.md | 30 ++++++++++++++ ...ilter-overwrites-query-columnfilter.bad.al | 37 ++++++++++++++++++ ...lter-overwrites-query-columnfilter.good.al | 38 ++++++++++++++++++ ...setfilter-overwrites-query-columnfilter.md | 30 ++++++++++++++ microsoft/skills/review/al-query-review.md | 8 ++-- 8 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al create mode 100644 microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al create mode 100644 microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md create mode 100644 microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al create mode 100644 microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al create mode 100644 microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 3eeed68..90c42c6 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -30,6 +30,14 @@ "privacy": { "article": "no-pii-in-telemetry-message-string" }, + "query": { + "articles": [ + "dataitemtablefilter-cannot-be-overwritten-at-runtime", + "reopening-query-resets-cursor-but-keeps-filters", + "set-query-filters-before-open", + "setfilter-overwrites-query-columnfilter" + ] + }, "reporting": { "articles": [ "clear-report-variable-before-independent-runmodal", diff --git a/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al new file mode 100644 index 0000000..67012eb --- /dev/null +++ b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al @@ -0,0 +1,39 @@ +query 50428 "Static Query Filter Bad" +{ + QueryType = Normal; + + elements + { + dataitem(SalesHeader; "Sales Header") + { + DataItemTableFilter = Status = const(Open); + + column(DocumentNo; "No.") + { + } + filter(StatusFilter; Status) + { + } + } + } +} + +codeunit 50429 "Static Query Filter Bad" +{ + procedure ReadReleasedOrders() + var + SalesHeader: Record "Sales Header"; + SalesHeaderQuery: Query "Static Query Filter Bad"; + begin + // This is combined with Status = Open and returns no rows. + SalesHeaderQuery.SetRange(StatusFilter, SalesHeader.Status::Released); + SalesHeaderQuery.Open(); + while SalesHeaderQuery.Read() do + ProcessOrder(SalesHeaderQuery.DocumentNo); + SalesHeaderQuery.Close(); + end; + + local procedure ProcessOrder(DocumentNo: Code[20]) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al new file mode 100644 index 0000000..d095330 --- /dev/null +++ b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al @@ -0,0 +1,38 @@ +query 50430 "Static Query Filter Good" +{ + QueryType = Normal; + + elements + { + dataitem(SalesHeader; "Sales Header") + { + DataItemTableFilter = "Document Type" = const(Order); + + column(DocumentNo; "No.") + { + } + filter(StatusFilter; Status) + { + } + } + } +} + +codeunit 50431 "Static Query Filter Good" +{ + procedure ReadReleasedOrders() + var + SalesHeader: Record "Sales Header"; + SalesHeaderQuery: Query "Static Query Filter Good"; + begin + SalesHeaderQuery.SetRange(StatusFilter, SalesHeader.Status::Released); + SalesHeaderQuery.Open(); + while SalesHeaderQuery.Read() do + ProcessOrder(SalesHeaderQuery.DocumentNo); + SalesHeaderQuery.Close(); + end; + + local procedure ProcessOrder(DocumentNo: Code[20]) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md new file mode 100644 index 0000000..6a96db1 --- /dev/null +++ b/microsoft/knowledge/query/dataitemtablefilter-cannot-be-overwritten-at-runtime.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: query +keywords: [query, dataitemtablefilter, setfilter, setrange, static-filter, filter-precedence] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataItemTableFilter cannot be overwritten at runtime + +## Description + +`DataItemTableFilter` defines a static filter on a Query dataitem. A runtime `SetFilter` or `SetRange` on the same source field does not replace that filter. The static and runtime filters are combined with AND, so contradictory values produce an empty dataset instead of broadening or replacing the query definition. + +## Best Practice + +Keep only invariant restrictions in `DataItemTableFilter`. Expose caller-selectable fields through a column or filter row and apply their values with `SetFilter` or `SetRange` before `Open()`. When both filter types intentionally target the same field, ensure their intersection represents the required dataset. + +See sample: [`dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al`](dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al). + +## Anti Pattern + +Define a static filter in `DataItemTableFilter`, then apply a contradictory runtime filter to the same source field while expecting the runtime filter to replace the static one. Both filters remain effective and the query returns no rows. + +See sample: [`dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al`](dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al). + +## References + +Filtering in Query objects — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-filters \ No newline at end of file diff --git a/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al new file mode 100644 index 0000000..f286334 --- /dev/null +++ b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.bad.al @@ -0,0 +1,37 @@ +query 50432 "Column Query Filter Bad" +{ + QueryType = Normal; + + elements + { + dataitem(SalesLine; "Sales Line") + { + column(DocumentNo; "Document No.") + { + } + column(LineQuantity; Quantity) + { + ColumnFilter = LineQuantity = filter(> 0); + } + } + } +} + +codeunit 50433 "Column Query Filter Bad" +{ + procedure ReadSmallPositiveLines() + var + SalesLineQuery: Query "Column Query Filter Bad"; + begin + // This replaces > 0, so negative quantities are also returned. + SalesLineQuery.SetFilter(LineQuantity, '<100'); + SalesLineQuery.Open(); + while SalesLineQuery.Read() do + ProcessLine(SalesLineQuery.DocumentNo, SalesLineQuery.LineQuantity); + SalesLineQuery.Close(); + end; + + local procedure ProcessLine(DocumentNo: Code[20]; Quantity: Decimal) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al new file mode 100644 index 0000000..49353e6 --- /dev/null +++ b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.good.al @@ -0,0 +1,38 @@ +query 50434 "Column Query Filter Good" +{ + QueryType = Normal; + + elements + { + dataitem(SalesLine; "Sales Line") + { + DataItemTableFilter = Quantity = filter(> 0); + + column(DocumentNo; "Document No.") + { + } + column(LineQuantity; Quantity) + { + } + } + } +} + +codeunit 50435 "Column Query Filter Good" +{ + procedure ReadSmallPositiveLines() + var + SalesLineQuery: Query "Column Query Filter Good"; + begin + // This combines with the invariant Quantity > 0 dataitem filter. + SalesLineQuery.SetFilter(LineQuantity, '<100'); + SalesLineQuery.Open(); + while SalesLineQuery.Read() do + ProcessLine(SalesLineQuery.DocumentNo, SalesLineQuery.LineQuantity); + SalesLineQuery.Close(); + end; + + local procedure ProcessLine(DocumentNo: Code[20]; Quantity: Decimal) + begin + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md new file mode 100644 index 0000000..9f9dd7a --- /dev/null +++ b/microsoft/knowledge/query/setfilter-overwrites-query-columnfilter.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: query +keywords: [query, columnfilter, setfilter, setrange, filter-precedence, runtime-filter] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# SetFilter and SetRange overwrite Query ColumnFilter + +## Description + +`ColumnFilter` on a Query column or filter row defines a dynamic filter. A runtime `SetFilter` or `SetRange` on that same column or filter row replaces the `ColumnFilter`; it does not combine the two conditions. Rows excluded by the declarative filter can therefore reappear when the runtime filter omits that restriction. + +## Best Practice + +Place invariant restrictions in `DataItemTableFilter`, which runtime filters cannot overwrite. When a `ColumnFilter` is intentionally replaceable, make each runtime `SetFilter` or `SetRange` express the complete required condition before `Open()`. + +See sample: [`setfilter-overwrites-query-columnfilter.good.al`](setfilter-overwrites-query-columnfilter.good.al). + +## Anti Pattern + +Apply `SetFilter` or `SetRange` to a column or filter row and rely on its existing `ColumnFilter` to remain effective. The runtime call replaces that filter and can admit rows that the query definition appeared to exclude. + +See sample: [`setfilter-overwrites-query-columnfilter.bad.al`](setfilter-overwrites-query-columnfilter.bad.al). + +## References + +Filtering in Query objects — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-filters \ No newline at end of file diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md index c52ddd8..e97a20a 100644 --- a/microsoft/skills/review/al-query-review.md +++ b/microsoft/skills/review/al-query-review.md @@ -32,22 +32,24 @@ Match relevant entries against changed `query` objects, variables typed as `Quer The following targeted checks cover every current `query` article: +- A `DataItemTableFilter` and a runtime `SetFilter` or `SetRange` constrain the same source field incompatibly, while the runtime call is intended to replace or broaden the static filter — `dataitemtablefilter-cannot-be-overwritten-at-runtime`. - `SetFilter` or `SetRange` occurs after `Open()` without a new `Open()` before the next `Read()` — `set-query-filters-before-open`. +- A runtime `SetFilter` or `SetRange` replaces a `ColumnFilter` on the same column or filter row, while later code relies on the declarative restriction remaining effective — `setfilter-overwrites-query-columnfilter`. - An already-open query is opened again as if that advanced the cursor, or a query variable is reused for an independent operation without `Clear` even though old filters must not carry over — `reopening-query-resets-cursor-but-keeps-filters`. Resolve layer conflicts per READ. When no query knowledge exists, emit `no-knowledge`; when knowledge exists but no article matches the changed Query usage, emit `completed` with no findings. ## Action -Evaluate every worklist article against the diff's Query call order and surrounding control flow. +Evaluate every worklist article against the Query definition, the diff's call order, and surrounding control flow. For filter-precedence findings, require both the declarative filter and the runtime call to be visible, and require local evidence that replacement, broadening, or retention of the original filter is intended. -- Emit `major` for an unambiguous Anti Pattern that can close the dataset, restart processing, or retain an unintended filter. +- Emit `major` for an unambiguous Anti Pattern that can close the dataset, restart processing, retain an unintended filter, produce an empty intersection, or admit rows excluded by an overwritten filter. - Emit `minor` when code contradicts a Best Practice but the resulting behavior depends on unseen control flow. - Do not emit applicability-only information. A Query article produces a finding only when the changed code violates its normative guidance. Set confidence to `high` for a locally visible call sequence and `medium` when aliases, helper calls, or missing context obscure the sequence. Domain-scoped agent findings follow DO's precision bar and remain capped at `minor`/`medium`. -Provide `suggested-code` only when moving a filter before `Open()` or adding `Clear` is a complete, local, unambiguous replacement. Otherwise set `suggested-code-omission-reason`. +Provide `suggested-code` only when moving a filter before `Open()`, adding `Clear`, moving an invariant restriction to `DataItemTableFilter`, or composing the complete runtime filter is a complete, local, unambiguous replacement. Otherwise set `suggested-code-omission-reason`. Outcome selection follows DO: `completed`, `no-knowledge`, `not-applicable`, `partial`, or `failed`. From 2c45021cb37d6b60f33ebb945fa88e4ac71462b2 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 15 Sep 2026 13:16:53 +0200 Subject: [PATCH 17/51] Add security knowledge: validate unauthenticated endpoint responses New remedial article for spotting when AL calls an endpoint that does not authenticate itself to the client (bare HttpClient.Get, blank SOAP SecretText, post-DisableHttpsCheck HTTP) and requires the response to be size-, schema-, and request/response-integrity-validated before it is trusted. Includes the BC-specific false-positive clarifications (platform buffers the full body, so an in-AL size check after buffering is correct; no DNS-rebinding/bounded-read demand; HTTPS not always enforceable) plus good/bad AL samples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...unauthenticated-response-before-use.bad.al | 23 ++++++++++ ...nauthenticated-response-before-use.good.al | 46 +++++++++++++++++++ ...ate-unauthenticated-response-before-use.md | 22 +++++++++ 3 files changed, 91 insertions(+) create mode 100644 microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al create mode 100644 microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al create mode 100644 microsoft/knowledge/security/validate-unauthenticated-response-before-use.md diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al new file mode 100644 index 0000000..ee0d25e --- /dev/null +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.bad.al @@ -0,0 +1,23 @@ +codeunit 50541 "Sec Sample UnauthResp Bad" +{ + procedure IsVatNumberValid(RequestedCountryCode: Text; RequestedVatNumber: Text): Boolean + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + JsonResponse: JsonObject; + JsonToken: JsonToken; + Content: Text; + begin + // Anti-pattern: the endpoint is unauthenticated, yet the response is trusted with no + // size cap, no schema check, and no request-to-response integrity check. + HttpClient.Get('http://vat-service.example/check?cc=' + RequestedCountryCode + '&vat=' + RequestedVatNumber, Response); + Response.Content().ReadAs(Content); + JsonResponse.ReadFrom(Content); + + // Trusts valid=true for ANY input: a spoofed or MITM response that omits the echoed + // countryCode/vatNumber is accepted as valid for whatever number was requested. + if JsonResponse.Get('valid', JsonToken) then + exit(JsonToken.AsValue().AsBoolean()); + exit(false); + end; +} diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al new file mode 100644 index 0000000..2c3e437 --- /dev/null +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.good.al @@ -0,0 +1,46 @@ +codeunit 50540 "Sec Sample UnauthResp Good" +{ + // The public VAT validation service does not authenticate itself to us (no OAuth, no + // certificate, plain HTTP), so its response must be validated before it is trusted. + procedure IsVatNumberValid(RequestedCountryCode: Text; RequestedVatNumber: Text): Boolean + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + JsonResponse: JsonObject; + JsonToken: JsonToken; + Content: Text; + ResponseCountryCode: Text; + ResponseVatNumber: Text; + begin + HttpClient.Get('http://vat-service.example/check?cc=' + RequestedCountryCode + '&vat=' + RequestedVatNumber, Response); + if not Response.IsSuccessStatusCode() then + exit(false); + + Response.Content().ReadAs(Content); + + // 1) Size cap - the platform already buffered the whole body; reject abnormally large payloads. + if StrLen(Content) > 4096 then + Error('The VAT validation response exceeded the maximum allowed size and was rejected.'); + + // 2) Schema - require the expected scalar fields, not just a truthy flag. + if not JsonResponse.ReadFrom(Content) then + Error('The VAT validation response was not in the expected format and was rejected.'); + if not JsonResponse.Get('countryCode', JsonToken) then + Error('The VAT validation response did not include the requested identifiers and was rejected.'); + ResponseCountryCode := JsonToken.AsValue().AsText(); + if not JsonResponse.Get('vatNumber', JsonToken) then + Error('The VAT validation response did not include the requested identifiers and was rejected.'); + ResponseVatNumber := JsonToken.AsValue().AsText(); + + // 3) Integrity - the echoed identifiers must match the request, so a valid=true payload + // with the identifiers stripped cannot be accepted for an arbitrary VAT number. + if (UpperCase(ResponseCountryCode) <> UpperCase(RequestedCountryCode)) or + (UpperCase(ResponseVatNumber) <> UpperCase(RequestedVatNumber)) + then + Error('The VAT validation response did not match the requested identifiers and was rejected.'); + + if not JsonResponse.Get('valid', JsonToken) then + exit(false); + exit(JsonToken.AsValue().AsBoolean()); + end; +} diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md new file mode 100644 index 0000000..2e99e2c --- /dev/null +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [unauthenticated, ssrf, httpclient, soap, response-validation, integrity, size-limit, disablehttpscheck, temp-blob, vies] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate responses from unauthenticated endpoints before trusting them + +## Description + +When AL calls an external endpoint that does not authenticate *itself* to the client, the response is fully attacker-influenceable — cleartext MITM, a spoofed or compromised host, DNS/redirect games, or simply a misbehaving public service. Recognizing that a call is unauthenticated is the first review step, and the signals are BC-specific: a bare `HttpClient.Get`/`Post` with no `Authorization` header, no acquired OAuth token, and no client certificate; a SOAP request whose credentials are blank, such as `SOAP Web Service Request Mgt.SetGlobals(..., '', BlankSecretText)`; or any request issued after `DisableHttpsCheck()` over plain HTTP (for example the EU VIES VAT service, whose default endpoint is `http://`). Because the BC platform HTTP stack buffers the entire response body before AL is handed the stream or `Temp Blob`, the whole payload is already in memory by the time AL parses it — so the response must be range- and shape-checked in AL *before* any of it is written to tax, VAT, customer, or vendor tables. + +## Best Practice + +Before parsing or trusting a response from an unauthenticated endpoint: (1) enforce a maximum size — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) validate the schema/shape — require the specific scalar nodes you expect, not merely "the body contains a truthy flag"; (3) enforce request-to-response integrity — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input; (4) on rejection raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: `validate-unauthenticated-response-before-use.good.al`. For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. + +## Anti Pattern + +Feeding the parsed response straight into business logic — load the XML/JSON, read a `valid` flag or an IP-shaped substring, then `Customer.Modify()` — trusting it purely because the HTTP call returned 2xx, with no size, shape, or echoed-identifier check. Reviewers should flag an unauthenticated outbound call (no `Authorization`/OAuth/cert, blank SOAP `SecretText`, or a request after `DisableHttpsCheck`) whose response is parsed and persisted without a preceding size cap, schema check, and request-to-response integrity check. Do NOT, however, demand a streaming or bounded read that aborts the transfer mid-download, nor a resolved-IP/DNS-rebinding check: the platform buffers the full body before AL sees it and AL has no connection-time or DNS hook, so an in-AL size check necessarily runs after buffering and host-rebinding defense belongs to the platform egress layer — raising those is a false positive. HTTPS is likewise not always enforceable (VIES is HTTP by design); the mitigation there is response validation, not scheme enforcement. See sample: `validate-unauthenticated-response-before-use.bad.al`. From 58b3be23abee90269adbe434d8ae395ca8e78a30 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 15 Sep 2026 13:19:37 +0200 Subject: [PATCH 18/51] Name the three required checks explicitly: response size, schema compliance, content integrity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../security/validate-unauthenticated-response-before-use.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md index 2e99e2c..27db51f 100644 --- a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md @@ -11,11 +11,11 @@ application-area: [all] ## Description -When AL calls an external endpoint that does not authenticate *itself* to the client, the response is fully attacker-influenceable — cleartext MITM, a spoofed or compromised host, DNS/redirect games, or simply a misbehaving public service. Recognizing that a call is unauthenticated is the first review step, and the signals are BC-specific: a bare `HttpClient.Get`/`Post` with no `Authorization` header, no acquired OAuth token, and no client certificate; a SOAP request whose credentials are blank, such as `SOAP Web Service Request Mgt.SetGlobals(..., '', BlankSecretText)`; or any request issued after `DisableHttpsCheck()` over plain HTTP (for example the EU VIES VAT service, whose default endpoint is `http://`). Because the BC platform HTTP stack buffers the entire response body before AL is handed the stream or `Temp Blob`, the whole payload is already in memory by the time AL parses it — so the response must be range- and shape-checked in AL *before* any of it is written to tax, VAT, customer, or vendor tables. +When AL calls an external endpoint that does not authenticate *itself* to the client, the response is fully attacker-influenceable — cleartext MITM, a spoofed or compromised host, DNS/redirect games, or simply a misbehaving public service. Recognizing that a call is unauthenticated is the first review step, and the signals are BC-specific: a bare `HttpClient.Get`/`Post` with no `Authorization` header, no acquired OAuth token, and no client certificate; a SOAP request whose credentials are blank, such as `SOAP Web Service Request Mgt.SetGlobals(..., '', BlankSecretText)`; or any request issued after `DisableHttpsCheck()` over plain HTTP (for example the EU VIES VAT service, whose default endpoint is `http://`). Because the BC platform HTTP stack buffers the entire response body before AL is handed the stream or `Temp Blob`, the whole payload is already in memory by the time AL parses it — so the response must pass three checks in AL — **response size**, **schema compliance**, and **content integrity** — *before* any of it is written to tax, VAT, customer, or vendor tables. ## Best Practice -Before parsing or trusting a response from an unauthenticated endpoint: (1) enforce a maximum size — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) validate the schema/shape — require the specific scalar nodes you expect, not merely "the body contains a truthy flag"; (3) enforce request-to-response integrity — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input; (4) on rejection raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: `validate-unauthenticated-response-before-use.good.al`. For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. +Before parsing or trusting a response from an unauthenticated endpoint, apply all three of these checks before the payload reaches business logic: (1) **Response size** — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) **Schema compliance** — require the specific scalar nodes/fields you expect in the expected shape, not merely "the body contains a truthy flag"; (3) **Content integrity** — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input. On any failing check, raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: `validate-unauthenticated-response-before-use.good.al`. For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. ## Anti Pattern From 5bda05492752a5954b282dbce5fef8782b8ab474 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 15 Sep 2026 13:48:59 +0200 Subject: [PATCH 19/51] Link samples using the READ markdown-link convention Use [\slug.good.al\](slug.good.al) form so tools/Knowledge-Retrieval.ps1 Assert-SampleLink validation passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../security/validate-unauthenticated-response-before-use.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md index 27db51f..ca09d56 100644 --- a/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md +++ b/microsoft/knowledge/security/validate-unauthenticated-response-before-use.md @@ -15,8 +15,8 @@ When AL calls an external endpoint that does not authenticate *itself* to the cl ## Best Practice -Before parsing or trusting a response from an unauthenticated endpoint, apply all three of these checks before the payload reaches business logic: (1) **Response size** — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) **Schema compliance** — require the specific scalar nodes/fields you expect in the expected shape, not merely "the body contains a truthy flag"; (3) **Content integrity** — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input. On any failing check, raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: `validate-unauthenticated-response-before-use.good.al`. For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. +Before parsing or trusting a response from an unauthenticated endpoint, apply all three of these checks before the payload reaches business logic: (1) **Response size** — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) **Schema compliance** — require the specific scalar nodes/fields you expect in the expected shape, not merely "the body contains a truthy flag"; (3) **Content integrity** — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input. On any failing check, raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: [`validate-unauthenticated-response-before-use.good.al`](validate-unauthenticated-response-before-use.good.al). For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`. ## Anti Pattern -Feeding the parsed response straight into business logic — load the XML/JSON, read a `valid` flag or an IP-shaped substring, then `Customer.Modify()` — trusting it purely because the HTTP call returned 2xx, with no size, shape, or echoed-identifier check. Reviewers should flag an unauthenticated outbound call (no `Authorization`/OAuth/cert, blank SOAP `SecretText`, or a request after `DisableHttpsCheck`) whose response is parsed and persisted without a preceding size cap, schema check, and request-to-response integrity check. Do NOT, however, demand a streaming or bounded read that aborts the transfer mid-download, nor a resolved-IP/DNS-rebinding check: the platform buffers the full body before AL sees it and AL has no connection-time or DNS hook, so an in-AL size check necessarily runs after buffering and host-rebinding defense belongs to the platform egress layer — raising those is a false positive. HTTPS is likewise not always enforceable (VIES is HTTP by design); the mitigation there is response validation, not scheme enforcement. See sample: `validate-unauthenticated-response-before-use.bad.al`. +Feeding the parsed response straight into business logic — load the XML/JSON, read a `valid` flag or an IP-shaped substring, then `Customer.Modify()` — trusting it purely because the HTTP call returned 2xx, with no size, shape, or echoed-identifier check. Reviewers should flag an unauthenticated outbound call (no `Authorization`/OAuth/cert, blank SOAP `SecretText`, or a request after `DisableHttpsCheck`) whose response is parsed and persisted without a preceding size cap, schema check, and request-to-response integrity check. Do NOT, however, demand a streaming or bounded read that aborts the transfer mid-download, nor a resolved-IP/DNS-rebinding check: the platform buffers the full body before AL sees it and AL has no connection-time or DNS hook, so an in-AL size check necessarily runs after buffering and host-rebinding defense belongs to the platform egress layer — raising those is a false positive. HTTPS is likewise not always enforceable (VIES is HTTP by design); the mitigation there is response validation, not scheme enforcement. See sample: [`validate-unauthenticated-response-before-use.bad.al`](validate-unauthenticated-response-before-use.bad.al). From 450e5965e180cb5c0fa9c69e4375b081ff43de01 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 17 Sep 2026 13:34:22 +0200 Subject: [PATCH 20/51] Add BCQuality logo and brand assets (#190) * Add BCQuality logo and brand assets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove brand assets link from README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Use horizontal logo banner in README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Enlarge banner wordmark Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 ++++ docs/README.md | 5 +++++ docs/assets/bcq-logo.svg | 26 ++++++++++++++++++++++++ docs/assets/bcq-mark.svg | 22 ++++++++++++++++++++ docs/brand-assets.md | 43 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+) create mode 100644 docs/assets/bcq-logo.svg create mode 100644 docs/assets/bcq-mark.svg create mode 100644 docs/brand-assets.md diff --git a/README.md b/README.md index e9e18d5..147a84c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ BCQuality logo +

+ # BCQuality Quality skills and knowledge that help AI tools make better Business Central diff --git a/docs/README.md b/docs/README.md index d9ca27b..7210cb7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,7 @@ +

+ BCQuality mark +

+ # BCQuality documentation **New to BCQuality? Start with the [quick start](../README.md#quick-start).** @@ -32,3 +36,4 @@ prerequisites for using the plugin. | [DO](../skills/do.md) | Action-skill format and structured output contract. | | [WRITE](../skills/write.md) | Knowledge-authoring rules. | | [Review evaluation](../evaluation/README.md) | Sample conventions, fixture preparation, and scoring. | +| [Brand assets](brand-assets.md) | BCQ logo files and usage guidance. | diff --git a/docs/assets/bcq-logo.svg b/docs/assets/bcq-logo.svg new file mode 100644 index 0000000..0c7aae5 --- /dev/null +++ b/docs/assets/bcq-logo.svg @@ -0,0 +1,26 @@ + + BCQuality banner logo + A blue letter Q crossed by a teal quality check beside the BCQuality wordmark. + + + + + + + + + + + + + + + + + + + + + + BCQuality + diff --git a/docs/assets/bcq-mark.svg b/docs/assets/bcq-mark.svg new file mode 100644 index 0000000..0b6c00e --- /dev/null +++ b/docs/assets/bcq-mark.svg @@ -0,0 +1,22 @@ + + BCQuality mark + A blue letter Q crossed by a teal quality check. + + + + + + + + + + + + + + + + + + + diff --git a/docs/brand-assets.md b/docs/brand-assets.md new file mode 100644 index 0000000..e578fcd --- /dev/null +++ b/docs/brand-assets.md @@ -0,0 +1,43 @@ +# BCQ brand assets + +

+ BCQuality logo +

+ +The BCQuality mark combines a **Q** with a check to represent review, +confidence, and quality. The vector artwork follows the supplied blue-to-teal +concept and connects the mark to the split-color BCQuality wordmark. A true +circular ring and square-ended 45-degree bars keep the check and Q tail aligned +at exact right angles; the check's vertical cut and the tail's horizontal cut +match the source concept. + +## Available artwork + +| Asset | Best use | +| --- | --- | +| [`bcq-logo.svg`](assets/bcq-logo.svg) | Horizontal banner with the mark and wordmark. | +| [`bcq-mark.svg`](assets/bcq-mark.svg) | Symbol without the wordmark. | + +The SVG files can be scaled without losing quality. + +## Color palette + +The artwork uses the dominant colors sampled from the supplied Business Central +logo reference. + +| Color | Hex | +| --- | --- | +| Deep blue | `#086194` | +| Blue | `#138EB7` | +| Cyan | `#17ABCB` | +| Aqua | `#2CD2CD` | +| Mint | `#57E6CB` | +| Light mint | `#9BF2C8` | + +## Usage + +- Prefer the full logo when at least 320 pixels of horizontal space is + available; use the mark at smaller sizes. +- Preserve the artwork's proportions, colors, and orientation. +- Use `BCQuality logo` as alternative text unless nearby text already names the + project, in which case the image can be decorative. From 2b96f5226d469542646d73e010c4099da46995a5 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 17 Sep 2026 13:35:46 +0200 Subject: [PATCH 21/51] Update logo width and remove project title Reduced logo width in README and removed title. --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 147a84c..bdf10f3 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@

- BCQuality logo + BCQuality logo

-# BCQuality - Quality skills and knowledge that help AI tools make better Business Central development decisions: catch BC-specific defects, avoid misleading advice, and explain findings with references you can read. From d209cd0f73daadb1ca3b7fc9e476e6b1453c29a9 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 17 Sep 2026 13:38:04 +0200 Subject: [PATCH 22/51] Left-align README logo (#191) * Add BCQuality logo and brand assets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove brand assets link from README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Use horizontal logo banner in README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Enlarge banner wordmark Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bdf10f3..3f66a4e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

+

BCQuality logo

From b91443beec785606e08274dea3f402a99aaec7bd Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 17 Sep 2026 13:38:34 +0200 Subject: [PATCH 23/51] Update logo width in README Reduced logo width in README from 500 to 300 pixels. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f66a4e..daea405 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- BCQuality logo + BCQuality logo

Quality skills and knowledge that help AI tools make better Business Central From 523fc88f877a5151d6a3a15ef52106fa8d886f90 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 18 Sep 2026 09:05:29 +0200 Subject: [PATCH 24/51] Remove logo branding (#194) Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 ---- docs/README.md | 5 ----- docs/assets/bcq-logo.svg | 26 ------------------------ docs/assets/bcq-mark.svg | 22 -------------------- docs/brand-assets.md | 43 ---------------------------------------- 5 files changed, 100 deletions(-) delete mode 100644 docs/assets/bcq-logo.svg delete mode 100644 docs/assets/bcq-mark.svg delete mode 100644 docs/brand-assets.md diff --git a/README.md b/README.md index daea405..d45a5d1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -

- BCQuality logo -

- Quality skills and knowledge that help AI tools make better Business Central development decisions: catch BC-specific defects, avoid misleading advice, and explain findings with references you can read. diff --git a/docs/README.md b/docs/README.md index 7210cb7..d9ca27b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,3 @@ -

- BCQuality mark -

- # BCQuality documentation **New to BCQuality? Start with the [quick start](../README.md#quick-start).** @@ -36,4 +32,3 @@ prerequisites for using the plugin. | [DO](../skills/do.md) | Action-skill format and structured output contract. | | [WRITE](../skills/write.md) | Knowledge-authoring rules. | | [Review evaluation](../evaluation/README.md) | Sample conventions, fixture preparation, and scoring. | -| [Brand assets](brand-assets.md) | BCQ logo files and usage guidance. | diff --git a/docs/assets/bcq-logo.svg b/docs/assets/bcq-logo.svg deleted file mode 100644 index 0c7aae5..0000000 --- a/docs/assets/bcq-logo.svg +++ /dev/null @@ -1,26 +0,0 @@ - - BCQuality banner logo - A blue letter Q crossed by a teal quality check beside the BCQuality wordmark. - - - - - - - - - - - - - - - - - - - - - - BCQuality - diff --git a/docs/assets/bcq-mark.svg b/docs/assets/bcq-mark.svg deleted file mode 100644 index 0b6c00e..0000000 --- a/docs/assets/bcq-mark.svg +++ /dev/null @@ -1,22 +0,0 @@ - - BCQuality mark - A blue letter Q crossed by a teal quality check. - - - - - - - - - - - - - - - - - - - diff --git a/docs/brand-assets.md b/docs/brand-assets.md deleted file mode 100644 index e578fcd..0000000 --- a/docs/brand-assets.md +++ /dev/null @@ -1,43 +0,0 @@ -# BCQ brand assets - -

- BCQuality logo -

- -The BCQuality mark combines a **Q** with a check to represent review, -confidence, and quality. The vector artwork follows the supplied blue-to-teal -concept and connects the mark to the split-color BCQuality wordmark. A true -circular ring and square-ended 45-degree bars keep the check and Q tail aligned -at exact right angles; the check's vertical cut and the tail's horizontal cut -match the source concept. - -## Available artwork - -| Asset | Best use | -| --- | --- | -| [`bcq-logo.svg`](assets/bcq-logo.svg) | Horizontal banner with the mark and wordmark. | -| [`bcq-mark.svg`](assets/bcq-mark.svg) | Symbol without the wordmark. | - -The SVG files can be scaled without losing quality. - -## Color palette - -The artwork uses the dominant colors sampled from the supplied Business Central -logo reference. - -| Color | Hex | -| --- | --- | -| Deep blue | `#086194` | -| Blue | `#138EB7` | -| Cyan | `#17ABCB` | -| Aqua | `#2CD2CD` | -| Mint | `#57E6CB` | -| Light mint | `#9BF2C8` | - -## Usage - -- Prefer the full logo when at least 320 pixels of horizontal space is - available; use the mark at smaller sizes. -- Preserve the artwork's proportions, colors, and orientation. -- Use `BCQuality logo` as alternative text unless nearby text already names the - project, in which case the image can be decorative. From 38ad6b8810440e9e78733e4c75b06fb8614db729 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Fri, 18 Sep 2026 09:07:57 +0200 Subject: [PATCH 25/51] Add title and description to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d45a5d1..8bf1cc7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# BC Quality - Don’t teach one agent. Teach the ecosystem. 🤝 + Quality skills and knowledge that help AI tools make better Business Central development decisions: catch BC-specific defects, avoid misleading advice, and explain findings with references you can read. From dd833133e05f1467e0e01c312f50508c5bd547d5 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Mon, 21 Sep 2026 10:15:39 +0200 Subject: [PATCH 26/51] Add foundational AL developer knowledge (#195) Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ize-document-defaults-in-initrecord.bad.al | 28 ++++++++++++ ...ze-document-defaults-in-initrecord.good.al | 45 +++++++++++++++++++ ...tialize-document-defaults-in-initrecord.md | 30 +++++++++++++ ...und-direction-symbols-use-magnitude.bad.al | 8 ++++ ...nd-direction-symbols-use-magnitude.good.al | 10 +++++ .../round-direction-symbols-use-magnitude.md | 30 +++++++++++++ .../guard-interface-casts-with-is.bad.al | 20 +++++++++ .../guard-interface-casts-with-is.good.al | 24 ++++++++++ .../guard-interface-casts-with-is.md | 30 +++++++++++++ ...oup-respects-lookup-page-visibility.bad.al | 9 ++++ ...up-respects-lookup-page-visibility.good.al | 20 +++++++++ ...ldgroup-respects-lookup-page-visibility.md | 30 +++++++++++++ ...ropagation-both-refreshes-main-page.bad.al | 26 +++++++++++ ...opagation-both-refreshes-main-page.good.al | 27 +++++++++++ ...atepropagation-both-refreshes-main-page.md | 30 +++++++++++++ ...on-meaning-depends-on-execution-context.md | 26 +++++++++++ ...and-upgrade-codeunits-have-no-order.bad.al | 28 ++++++++++++ ...nd-upgrade-codeunits-have-no-order.good.al | 18 ++++++++ ...all-and-upgrade-codeunits-have-no-order.md | 30 +++++++++++++ .../skills/review/al-data-modeling-review.md | 4 +- .../skills/review/al-interfaces-review.md | 3 +- microsoft/skills/review/al-ui-review.md | 7 ++- microsoft/skills/review/al-upgrade-review.md | 4 +- 23 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.bad.al create mode 100644 microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.good.al create mode 100644 microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.md create mode 100644 microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.bad.al create mode 100644 microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.good.al create mode 100644 microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.md create mode 100644 microsoft/knowledge/interfaces/guard-interface-casts-with-is.bad.al create mode 100644 microsoft/knowledge/interfaces/guard-interface-casts-with-is.good.al create mode 100644 microsoft/knowledge/interfaces/guard-interface-casts-with-is.md create mode 100644 microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.bad.al create mode 100644 microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.good.al create mode 100644 microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.md create mode 100644 microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.bad.al create mode 100644 microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.good.al create mode 100644 microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.md create mode 100644 microsoft/knowledge/upgrade/appversion-meaning-depends-on-execution-context.md create mode 100644 microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.bad.al create mode 100644 microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.good.al create mode 100644 microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.md diff --git a/microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.bad.al b/microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.bad.al new file mode 100644 index 0000000..0c87bb2 --- /dev/null +++ b/microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.bad.al @@ -0,0 +1,28 @@ +table 50603 "Sample Order Header Bad" +{ + fields + { + field(1; "No."; Code[20]) + { + DataClassification = CustomerContent; + } + field(2; "Document Date"; Date) + { + DataClassification = CustomerContent; + } + } + + trigger OnInsert() + var + SalesSetup: Record "Sales & Receivables Setup"; + NoSeries: Codeunit "No. Series"; + begin + "Document Date" := WorkDate(); + + if "No." = '' then begin + SalesSetup.Get(); + SalesSetup.TestField("Order Nos."); + "No." := NoSeries.GetNextNo(SalesSetup."Order Nos."); + end; + end; +} diff --git a/microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.good.al b/microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.good.al new file mode 100644 index 0000000..355d3d5 --- /dev/null +++ b/microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.good.al @@ -0,0 +1,45 @@ +table 50602 "Sample Order Header Good" +{ + fields + { + field(1; "No."; Code[20]) + { + DataClassification = CustomerContent; + } + field(2; "Document Date"; Date) + { + DataClassification = CustomerContent; + } + } + + trigger OnInsert() + var + SalesSetup: Record "Sales & Receivables Setup"; + NoSeries: Codeunit "No. Series"; + begin + if "No." = '' then begin + SalesSetup.Get(); + SalesSetup.TestField("Order Nos."); + "No." := NoSeries.GetNextNo(SalesSetup."Order Nos."); + end; + + InitRecord(); + end; + + procedure InitRecord() + begin + OnBeforeInitRecord(Rec); + "Document Date" := WorkDate(); + OnAfterInitRecord(Rec); + end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeInitRecord(var SampleOrderHeader: Record "Sample Order Header Good") + begin + end; + + [IntegrationEvent(false, false)] + local procedure OnAfterInitRecord(var SampleOrderHeader: Record "Sample Order Header Good") + begin + end; +} diff --git a/microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.md b/microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.md new file mode 100644 index 0000000..e83a6d3 --- /dev/null +++ b/microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [document-header, initrecord, number-series, default-values, oninsert, initialization] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Initialize document defaults in `InitRecord` after assigning the number + +## Description + +Business Central document headers assign their number series first and then call an `InitRecord` procedure that owns the remaining business defaults, such as posting and document dates. Keeping that sequence and extensibility point makes initialization consistent for every creation path and lets extensions subscribe around one documented operation. Defaults scattered across page triggers or unrelated helpers can differ between UI, API, test, and background creation. + +## Best Practice + +In the document table's insert path, assign the document number and then call `InitRecord`. Keep the default assignments in that procedure and expose narrow before/after events when other extensions must participate. + +See sample: [`initialize-document-defaults-in-initrecord.good.al`](initialize-document-defaults-in-initrecord.good.al). + +## Anti Pattern + +Assigning document defaults in a page trigger, or scattering them directly through `OnInsert` with no `InitRecord` boundary. Non-page creation paths can then miss the defaults, and extensions have no stable initialization hook. + +See sample: [`initialize-document-defaults-in-initrecord.bad.al`](initialize-document-defaults-in-initrecord.bad.al). + +## Reference + +[Use the InitRecord function](https://learn.microsoft.com/en-us/training/modules/use-document-standards-business-central/3-use-initrecord-function) diff --git a/microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.bad.al b/microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.bad.al new file mode 100644 index 0000000..f8e11cc --- /dev/null +++ b/microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.bad.al @@ -0,0 +1,8 @@ +codeunit 50601 "Directed Rounding Bad" +{ + procedure FloorAmount(Value: Decimal; Precision: Decimal): Decimal + begin + // For negative values, '<' rounds toward zero rather than toward negative infinity. + exit(Round(Value, Precision, '<')); + end; +} diff --git a/microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.good.al b/microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.good.al new file mode 100644 index 0000000..cd8a80f --- /dev/null +++ b/microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.good.al @@ -0,0 +1,10 @@ +codeunit 50600 "Directed Rounding Good" +{ + procedure RoundAmount(Value: Decimal; Precision: Decimal; IncreaseMagnitude: Boolean): Decimal + begin + if IncreaseMagnitude then + exit(Round(Value, Precision, '>')); + + exit(Round(Value, Precision, '<')); + end; +} diff --git a/microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.md b/microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.md new file mode 100644 index 0000000..260905d --- /dev/null +++ b/microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [round, rounding, direction, precision, negative-decimal, amount] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `Round` direction symbols follow magnitude, not mathematical ordering + +## Description + +AL's `Round(Number, Precision, Direction)` uses `'>'` to round away from zero and `'<'` to round toward zero. For a negative value this reverses mathematical ordering: `Round(-1234.56789, 0.001, '<')` returns `-1234.567`, while direction `'>'` returns `-1234.568`. Code that treats the symbols as mathematical ceiling and floor produces sign-dependent amount errors, commonly on credit documents and negative adjustments. + +## Best Practice + +Choose the direction from the business meaning: `'>'` increases absolute magnitude and `'<'` decreases absolute magnitude for both positive and negative values. Include positive and negative cases whenever a directed rounding rule is tested. + +See sample: [`round-direction-symbols-use-magnitude.good.al`](round-direction-symbols-use-magnitude.good.al). + +## Anti Pattern + +Using `'<'` as a mathematical floor or `'>'` as a mathematical ceiling. The result looks correct for positive amounts but moves in the opposite mathematical direction for negative amounts. + +See sample: [`round-direction-symbols-use-magnitude.bad.al`](round-direction-symbols-use-magnitude.bad.al). + +## Reference + +[Use the Round function](https://learn.microsoft.com/en-us/training/modules/use-document-standards-business-central/4a-use-round-function) diff --git a/microsoft/knowledge/interfaces/guard-interface-casts-with-is.bad.al b/microsoft/knowledge/interfaces/guard-interface-casts-with-is.bad.al new file mode 100644 index 0000000..4fd98be --- /dev/null +++ b/microsoft/knowledge/interfaces/guard-interface-casts-with-is.bad.al @@ -0,0 +1,20 @@ +interface "I Quote Amount Bad" +{ + procedure GetAmount(): Decimal; +} + +interface "I Quote Date Bad" +{ + procedure GetDate(): Date; +} + +codeunit 50611 "Quote Reader Bad" +{ + procedure GetDate(Quote: Interface "I Quote Amount Bad"): Date + var + DatedQuote: Interface "I Quote Date Bad"; + begin + DatedQuote := Quote as "I Quote Date Bad"; + exit(DatedQuote.GetDate()); + end; +} diff --git a/microsoft/knowledge/interfaces/guard-interface-casts-with-is.good.al b/microsoft/knowledge/interfaces/guard-interface-casts-with-is.good.al new file mode 100644 index 0000000..f58a19d --- /dev/null +++ b/microsoft/knowledge/interfaces/guard-interface-casts-with-is.good.al @@ -0,0 +1,24 @@ +interface "I Quote Amount Good" +{ + procedure GetAmount(): Decimal; +} + +interface "I Quote Date Good" +{ + procedure GetDate(): Date; +} + +codeunit 50610 "Quote Reader Good" +{ + procedure TryGetDate(Quote: Interface "I Quote Amount Good"; var QuoteDate: Date): Boolean + var + DatedQuote: Interface "I Quote Date Good"; + begin + if not (Quote is "I Quote Date Good") then + exit(false); + + DatedQuote := Quote as "I Quote Date Good"; + QuoteDate := DatedQuote.GetDate(); + exit(true); + end; +} diff --git a/microsoft/knowledge/interfaces/guard-interface-casts-with-is.md b/microsoft/knowledge/interfaces/guard-interface-casts-with-is.md new file mode 100644 index 0000000..ad9c38e --- /dev/null +++ b/microsoft/knowledge/interfaces/guard-interface-casts-with-is.md @@ -0,0 +1,30 @@ +--- +bc-version: [25..] +domain: interfaces +keywords: [interface, is-operator, as-operator, type-test, cast, variant, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard optional interface casts with `is` + +## Description + +From runtime 15.0, AL can type-test an interface or `Variant` with `is` and cast it to another interface with `as`. The test is non-throwing, but `as` raises a runtime error when the underlying codeunit does not implement the target interface. This matters when an extended capability is optional or implementations can come from other extensions. + +## Best Practice + +Use `is` to establish that the value supports the target interface before using `as`. Cast directly only where the target implementation is an invariant guaranteed by the surrounding contract. + +See sample: [`guard-interface-casts-with-is.good.al`](guard-interface-casts-with-is.good.al). + +## Anti Pattern + +Using `as` unconditionally for an optional extended interface. An otherwise valid implementation of the base interface then fails at runtime merely because it does not implement the additional contract. + +See sample: [`guard-interface-casts-with-is.bad.al`](guard-interface-casts-with-is.bad.al). + +## Reference + +[Understand type testing and casting operators for interfaces](https://learn.microsoft.com/en-us/training/modules/business-central-interfaces/type-testing) diff --git a/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.bad.al b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.bad.al new file mode 100644 index 0000000..78602b4 --- /dev/null +++ b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.bad.al @@ -0,0 +1,9 @@ +tableextension 50622 "Ship-to Dropdown Bad" extends "Ship-to Address" +{ + fieldgroups + { + addlast(DropDown; "Address 2") + { + } + } +} diff --git a/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.good.al b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.good.al new file mode 100644 index 0000000..c8a8c28 --- /dev/null +++ b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.good.al @@ -0,0 +1,20 @@ +tableextension 50620 "Ship-to Dropdown Good" extends "Ship-to Address" +{ + fieldgroups + { + addlast(DropDown; "Address 2") + { + } + } +} + +pageextension 50621 "Ship-to Lookup Good" extends "Ship-to Address List" +{ + layout + { + modify("Address 2") + { + Visible = true; + } + } +} diff --git a/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.md b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.md new file mode 100644 index 0000000..afa2c34 --- /dev/null +++ b/microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: ui +keywords: [fieldgroup, dropdown, addlast, lookup-page, visible, tableextension, pageextension] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A `DropDown` field remains hidden when its lookup-page control is hidden + +## Description + +A tableextension can append a field to the `DropDown` field group with `addlast`, but the client still omits that field when its control on the underlying lookup page has `Visible = false`. Changing only the table field group therefore compiles while producing no visible UI change. The field-group name is case-sensitive and must be written as `DropDown`. + +## Best Practice + +When adding a hidden field to a `DropDown` field group, also extend the page used for the lookup and make that field control visible. Verify the actual lookup page rather than assuming the table definition alone controls the drop-down. + +See sample: [`dropdown-fieldgroup-respects-lookup-page-visibility.good.al`](dropdown-fieldgroup-respects-lookup-page-visibility.good.al). + +## Anti Pattern + +Adding the field with `addlast(DropDown; ...)` while leaving its lookup-page control hidden, then expecting the field to appear in the drop-down. + +See sample: [`dropdown-fieldgroup-respects-lookup-page-visibility.bad.al`](dropdown-fieldgroup-respects-lookup-page-visibility.bad.al). + +## Reference + +[Add a new FieldGroup to an existing table](https://learn.microsoft.com/en-us/training/modules/extend-modify-existing-table/add-field-group) diff --git a/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.bad.al b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.bad.al new file mode 100644 index 0000000..f5d2b2b --- /dev/null +++ b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.bad.al @@ -0,0 +1,26 @@ +page 50631 "Sample Order Bad" +{ + PageType = Document; + SourceTable = "Sales Header"; + + layout + { + area(Content) + { + group(General) + { + field(Amount; Rec.Amount) + { + ApplicationArea = All; + ToolTip = 'Specifies the total amount of the order.'; + } + } + part(Lines; "Sales Order Subform") + { + ApplicationArea = All; + SubPageLink = "Document Type" = field("Document Type"), + "Document No." = field("No."); + } + } + } +} diff --git a/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.good.al b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.good.al new file mode 100644 index 0000000..3462098 --- /dev/null +++ b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.good.al @@ -0,0 +1,27 @@ +page 50630 "Sample Order Good" +{ + PageType = Document; + SourceTable = "Sales Header"; + + layout + { + area(Content) + { + group(General) + { + field(Amount; Rec.Amount) + { + ApplicationArea = All; + ToolTip = 'Specifies the total amount of the order.'; + } + } + part(Lines; "Sales Order Subform") + { + ApplicationArea = All; + SubPageLink = "Document Type" = field("Document Type"), + "Document No." = field("No."); + UpdatePropagation = Both; + } + } + } +} diff --git a/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.md b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.md new file mode 100644 index 0000000..3ce676b --- /dev/null +++ b/microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: ui +keywords: [updatepropagation, page-part, subpage, main-page, refresh, flowfield, document-lines] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use `UpdatePropagation = Both` when line edits must refresh the main page + +## Description + +A page part does not automatically refresh its parent page when the subpage changes. `UpdatePropagation = Subpage` updates only the part; `Both` also refreshes the main page. Without `Both`, header totals, FlowFields, and FactBoxes that depend on edited lines can remain stale until another user action refreshes the page. + +## Best Practice + +Set `UpdatePropagation = Both` on a part when edits in that subpage must immediately update values rendered by the main page. Leave propagation at `Subpage` when the parent has no dependent presentation to avoid unnecessary refreshes. + +See sample: [`updatepropagation-both-refreshes-main-page.good.al`](updatepropagation-both-refreshes-main-page.good.al). + +## Anti Pattern + +Displaying a line-dependent total on the main page while the editable lines part updates only itself. The persisted values can be correct while the parent page continues to show an old total. + +See sample: [`updatepropagation-both-refreshes-main-page.bad.al`](updatepropagation-both-refreshes-main-page.bad.al). + +## Reference + +[Set different control properties](https://learn.microsoft.com/en-us/training/modules/work-with-pages/8-controls) diff --git a/microsoft/knowledge/upgrade/appversion-meaning-depends-on-execution-context.md b/microsoft/knowledge/upgrade/appversion-meaning-depends-on-execution-context.md new file mode 100644 index 0000000..61e7422 --- /dev/null +++ b/microsoft/knowledge/upgrade/appversion-meaning-depends-on-execution-context.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [appversion, dataversion, moduleinfo, install-codeunit, upgrade-codeunit, version-context] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `ModuleInfo.AppVersion` changes meaning with execution context + +## Description + +`ModuleInfo.AppVersion()` is the installed version during normal operation, the version being installed inside install code, and the target version inside upgrade code. It is therefore not the source data version during an upgrade. In upgrade code, `DataVersion()` describes the version of the existing data, whether from the currently installed app or the version most recently uninstalled. + +## Best Practice + +Interpret `AppVersion()` as the code package entering the context and `DataVersion()` as the existing data state. Prefer upgrade tags for controlling individual migration steps; when version information is needed for diagnostics or preconditions, name variables so target app version and source data version cannot be confused. + +## Anti Pattern + +Reading `AppVersion()` from an upgrade codeunit and treating it as the version being upgraded from. The comparison actually observes the target package and can skip or misroute migration logic. + +## Reference + +[Create proper installation and upgrade codeunits](https://learn.microsoft.com/en-us/training/modules/easy-application-upgrade/3-installation-upgrade-codeunits) diff --git a/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.bad.al b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.bad.al new file mode 100644 index 0000000..2a92d11 --- /dev/null +++ b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.bad.al @@ -0,0 +1,28 @@ +codeunit 50641 "Sample Upgrade Part One" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + CreateUpgradeState(); + end; + + local procedure CreateUpgradeState() + begin + end; +} + +codeunit 50642 "Sample Upgrade Part Two" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + // This can run before Part One; object IDs do not sequence upgrade codeunits. + MigrateDataThatRequiresUpgradeState(); + end; + + local procedure MigrateDataThatRequiresUpgradeState() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.good.al b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.good.al new file mode 100644 index 0000000..42346a8 --- /dev/null +++ b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.good.al @@ -0,0 +1,18 @@ +codeunit 50640 "Sample Upgrade Good" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + CreateUpgradeState(); + MigrateDependentData(); + end; + + local procedure CreateUpgradeState() + begin + end; + + local procedure MigrateDependentData() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.md b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.md new file mode 100644 index 0000000..b11a842 --- /dev/null +++ b/microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [install-codeunit, upgrade-codeunit, execution-order, subtype-install, subtype-upgrade, sequencing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Separate install or upgrade codeunits have no execution order + +## Description + +An extension can contain multiple `Install` or `Upgrade` codeunits, but Business Central does not guarantee the order in which codeunits of the same subtype execute. Upgrade trigger phases are ordered globally, yet one codeunit's `OnUpgradePerCompany` must not assume another codeunit's same-phase trigger already ran. Object ID and source-file order do not provide sequencing. + +## Best Practice + +Keep separate install or upgrade codeunits independent. When two steps have a real dependency, coordinate them from one owning trigger in the required order; use upgrade tags to make each completed step idempotent. + +See sample: [`install-and-upgrade-codeunits-have-no-order.good.al`](install-and-upgrade-codeunits-have-no-order.good.al). + +## Anti Pattern + +Splitting dependent steps into separate codeunits and relying on names, object IDs, or declaration order. The dependent codeunit can run first and fail or observe partially migrated data. + +See sample: [`install-and-upgrade-codeunits-have-no-order.bad.al`](install-and-upgrade-codeunits-have-no-order.bad.al). + +## Reference + +[Create proper installation and upgrade codeunits](https://learn.microsoft.com/en-us/training/modules/easy-application-upgrade/3-installation-upgrade-codeunits) diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 05dcd0b..3fca8ec 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-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 `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, and document or journal lines that reference a master. - The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, and posting validation. -- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`). +- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`). 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 data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -52,6 +52,8 @@ The following targeted checks cover every current `data-modeling` article. Treat - A master table adds or changes `Last Date Modified`, `OnModify`, or `OnRename`, but the non-editable field is not assigned `Today()` in both triggers — `set-last-date-modified-in-onmodify-and-onrename`. - A `tableextension` appends a conditional `TableRelation` as if it overrides an earlier unconditional relation, or relation branches are otherwise designed without accounting for additive top-down evaluation — `table-relation-extensions-are-additive-and-top-down`. - A `Media` or `MediaSet` field is assigned directly between different table types or different field IDs instead of registering each shared item with `MediaSet.Insert` — `share-mediaset-items-with-insert-not-field-assignment`. +- A custom document header assigns defaults outside an `InitRecord` boundary, calls `InitRecord` before assigning its number, or places UI-independent defaults only in a page trigger — `initialize-document-defaults-in-initrecord`. +- Directed `Round` calls use `'<'` as mathematical floor or `'>'` as mathematical ceiling, especially where negative amounts are possible — `round-direction-symbols-use-magnitude`. 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`. diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md index f5d65cd..1c38be7 100644 --- a/microsoft/skills/review/al-interfaces-review.md +++ b/microsoft/skills/review/al-interfaces-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 `interface` objects, codeunits and enums declared with the `implements` keyword, and consumers that declare or assign an `Interface` variable. - The changed procedures and triggers, weighted toward factory or dispatch routines that resolve a variant to behaviour, setter-injection procedures that take an `Interface` parameter, and `case`-over-enum blocks that select between strategies. -- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `extends`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `case`, and the `case of` anti-pattern signal — a `case` over an enum value whose branches choose between variant computations). +- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `extends`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `Variant`, `is`, `as`, `case`, and the `case of` anti-pattern signal — a `case` over an enum value whose branches choose between variant computations). 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. @@ -54,6 +54,7 @@ The following targeted checks map diff signals to specific `interfaces` articles - `DefaultImplementation` used as the only fallback where a persisted ordinal may no longer match any declared enum value, or a persisted enum lacks `UnknownValueImplementation` on BC18 or later — `handle-unknown-enum-ordinals-with-unknownvalueimplementation`. - A method added directly to an interface that exists in the baseline, instead of adding a BC25+ interface that `extends` it or a versioned sibling for older targets — `extend-published-interfaces-dont-edit-them`. - A declared enum value with no `Implementation` and no enum-level `DefaultImplementation` — `set-defaultimplementation-on-enum`. +- An `Interface` or `Variant` is cast with `as` to an optional extended interface without first establishing support with `is` — `guard-interface-casts-with-is`. For `set-defaultimplementation-on-enum`, inspect the complete containing enum before emitting. An enum-level `DefaultImplementation = = ;` conclusively covers every declared value that omits its own `Implementation`; do not flag such a value and do not replace the intentional fallback with a per-value mapping. diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index 8ffd731..8c35f49 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -41,10 +41,15 @@ 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. - 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`, `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). +- 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). 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 page element. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. +Apply these high-signal mappings before fuzzy topic ranking: + +- A tableextension adds a field to `DropDown` while the corresponding lookup-page control remains `Visible = false` — `dropdown-fieldgroup-respects-lookup-page-visibility`. +- An editable page part affects a total, FlowField, or FactBox on the parent but does not set `UpdatePropagation = Both` — `updatepropagation-both-refreshes-main-page`. + Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. When the post-conflict worklist is empty because no applicable UI knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable UI knowledge matched the page changes, emit `outcome: "completed"` with an empty `findings` array. diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 94851a3..9ae61f8 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -39,10 +39,12 @@ 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 = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces. - The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, and helper procedures transitively reachable from those entry points. -- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`). +- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `ModuleInfo`, `AppVersion`, `DataVersion`, `NavApp.GetCurrentModuleInfo`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`). - For each `OnCheckPreconditions...` and `OnValidateUpgrade...` trigger, build the best available call graph from surrounding unchanged source as well as changed hunks, tracing resolved calls through reachable local or internal helpers. Worklist the check-only rule when a database write occurs either directly in the trigger or in any helper procedure reachable from it. Writes include `Insert`, `Modify`, `ModifyAll`, `Delete`, `DeleteAll`, `Rename`, and `DataTransfer`. Also perform the reverse check when a PR changes a writing helper body: worklist the rule when that helper is invoked directly or transitively by an unchanged check or validation trigger. - Treat a direct write or a fully resolved call chain as high-confidence evidence. When cross-object dispatch, unavailable declarations, or an incomplete call graph prevents proving the complete chain, cap confidence at `medium`, name the unresolved edge in the finding, and do not claim a violation without a resolved path from a check or validation trigger to a write. - Worklist the install-versus-upgrade rule when migration helpers are reachable only from an install codeunit. +- Worklist `install-and-upgrade-codeunits-have-no-order.md` when a change adds multiple install or upgrade codeunits whose same-phase triggers share state or depend on one another. +- Worklist `appversion-meaning-depends-on-execution-context.md` when install or upgrade code branches on `ModuleInfo.AppVersion()` or confuses it with `DataVersion()`. 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. From d38377b85e4ba995d822981bdd7f8d381547026a Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Mon, 21 Sep 2026 10:16:07 +0200 Subject: [PATCH 27/51] Clarify locale-safe DateFormula Evaluate inputs (#193) * Clarify locale-safe DateFormula Evaluate inputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Normalize DateFormula article sections Keep the analyzer-gap explanation in Description and its scoped probe evidence in References, without a novel Validation section. Normative guidance and fixtures are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- evaluation/review-fixtures.json | 5 ++- ...needs-language-independent-literals.bad.al | 18 +++++++++ ...eeds-language-independent-literals.good.al | 18 +++++++++ ...ate-needs-language-independent-literals.md | 38 +++++++++++++++++++ microsoft/skills/review/al-style-review.md | 10 +++-- 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.bad.al create mode 100644 microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.good.al create mode 100644 microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 90c42c6..e52a653 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -52,7 +52,10 @@ ] }, "style": { - "article": "label-comment-explains-placeholders" + "articles": [ + "label-comment-explains-placeholders", + "dateformula-evaluate-needs-language-independent-literals" + ] }, "telemetry": { "article": "telemetry-event-id-stable-unique" diff --git a/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.bad.al b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.bad.al new file mode 100644 index 0000000..c520ee0 --- /dev/null +++ b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.bad.al @@ -0,0 +1,18 @@ +codeunit 50102 "Bad Review Scheduling" +{ + procedure NextReviewDate(Interval: DateFormula; ReferenceDate: Date): Date + begin + if Format(Interval) = '' then + Evaluate(Interval, '1W'); + + exit(CalcDate(Interval, ReferenceDate)); + end; + + procedure NextReviewFromUserInput(UserFormulaText: Text; ReferenceDate: Date): Date + var + Interval: DateFormula; + begin + Evaluate(Interval, UserFormulaText); + exit(NextReviewDate(Interval, ReferenceDate)); + end; +} diff --git a/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.good.al b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.good.al new file mode 100644 index 0000000..ee07dbe --- /dev/null +++ b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.good.al @@ -0,0 +1,18 @@ +codeunit 50101 "Good Review Scheduling" +{ + procedure NextReviewDate(Interval: DateFormula; ReferenceDate: Date): Date + begin + if Format(Interval) = '' then + Evaluate(Interval, '<1W>'); + + exit(CalcDate(Interval, ReferenceDate)); + end; + + procedure NextReviewFromUserInput(UserFormulaText: Text; ReferenceDate: Date): Date + var + Interval: DateFormula; + begin + Evaluate(Interval, UserFormulaText); + exit(NextReviewDate(Interval, ReferenceDate)); + end; +} diff --git a/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.md b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.md new file mode 100644 index 0000000..91fcfe5 --- /dev/null +++ b/microsoft/knowledge/style/dateformula-evaluate-needs-language-independent-literals.md @@ -0,0 +1,38 @@ +--- +bc-version: [all] +domain: style +keywords: [dateformula, evaluate, calcdate, date-expression, language-independent, multilanguage, global-language] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Parse DateFormula constants with language-independent input + +## Description + +A `DateFormula` stores a formula in a language-independent representation, but `Evaluate` must first interpret its text input. Declaring the destination as `DateFormula` does not make an English literal such as `1W` independent of the session language: French uses `S` for weeks. Passing the resulting typed variable to `CalcDate` satisfies that call's CodeCop AA0462 argument requirement, but cannot repair a parsing failure that already happened in `Evaluate`. + +## Best Practice + +For an application-defined formula in a normal two-argument `Evaluate` call, use the generic units inside angle brackets, such as `<1W>`. Apply this at the text-to-`DateFormula` boundary, including a visible constant passed through a helper. A label's `Locked = true` prevents translation of its text; it does not make unbracketed English units language independent. + +Preserve genuinely localized input: text entered by the user, or already formatted for the same session language, should be parsed in that language. Do not blindly wrap that text in angle brackets. Already invariant `<...>` literals, explicit import-format conversions, a typed formula passed to `CalcDate`, and `Format(Interval) = ''` checks are not findings without an unsafe constant at the parsing boundary. + +See sample: [`dateformula-evaluate-needs-language-independent-literals.good.al`](dateformula-evaluate-needs-language-independent-literals.good.al). + +## Anti Pattern + +A hard-coded, language-fixed formula such as `1W` flows into a normal two-argument `Evaluate` whose destination is known to be `DateFormula`, and the application expects that default to work across session languages. Require the destination type and constant provenance; an arbitrary `Evaluate` call or dynamic text parameter is not enough. The resulting code can compile and work in English while failing when the same default is first needed in another language. + +Do not report direct `CalcDate` text arguments under this article: CodeCop AA0462 already owns the requirement for a typed formula or angle-bracketed text there. Its typed-argument check does not establish that an earlier `Evaluate` parsed language-independent input. + +See sample: [`dateformula-evaluate-needs-language-independent-literals.bad.al`](dateformula-evaluate-needs-language-independent-literals.bad.al). + +## References + +[DateFormula data type](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/dateformula/dateformula-data-type) and [CalcDate language behavior](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/system/system-calcdate-dateformula-date-method). + +[CodeCop AA0462](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/codecop-aa0462) defines the separate direct-`CalcDate` check. In a CodeCop compilation probe against BC28.5 symbols, the direct text control produced AA0462; `Evaluate(Interval, '1W')` followed by typed `CalcDate` did not. + +[BaseApp retention scheduling](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyScheduler.Codeunit.al#L73-L97) initializes a typed formula with an invariant literal. diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 2703c87..5b0b6b6 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -3,7 +3,7 @@ kind: action-skill id: al-style-review version: 1 title: AL style review -description: Reviews AL source changes against naming, labelling, and code-convention guidance from BCQuality. +description: Reviews AL source changes against naming, labelling, localization, and code-convention guidance from BCQuality. inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `style` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -Style findings cover AL conventions that require contextual judgment — API page naming, temporary-variable prefixes, label semantics, named invocations, `FieldCaption`/`TableCaption` in user messages, error-parameter handling, and file naming. Mechanical compiler and analyzer rules are intentionally outside this skill; run the consuming app's configured analyzers separately. +Style findings cover AL conventions that require contextual judgment — API page naming, temporary-variable prefixes, label semantics, date-formula localization, named invocations, `FieldCaption`/`TableCaption` in user messages, error-parameter handling, and file naming. Mechanical compiler and analyzer rules are intentionally outside this skill; run the consuming app's configured analyzers separately. An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. @@ -40,8 +40,8 @@ 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. For each relevant file, compute overlap against: - Changed AL objects — especially API pages (`PageType = API`), tables and pages declaring Labels/TextConsts, codeunits issuing `Error`/`Message`/`Confirm`, and any file whose name violates the `..al` convention. -- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, error-handling call sites, and API declarations. -- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `StrSubstNo`). +- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, `DateFormula` declarations and their `Evaluate` call sites, error-handling call sites, and API declarations. +- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `DateFormula`, `Evaluate`, `CalcDate`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `StrSubstNo`). 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 or declaration. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -50,6 +50,8 @@ Do not worklist `temporary-variable-temp-prefix.md` for an event publisher param Apply these high-signal mappings before fuzzy topic ranking: - A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder — `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged. +- A normal two-argument `Evaluate` has a resolved `DateFormula` destination and a hard-coded non-angle-bracket date-formula literal, directly or through a visible constant — `dateformula-evaluate-needs-language-independent-literals.md`. Do not use this cue for dynamic/localized external input, already invariant `<...>` input, or direct `CalcDate(Text, ...)` calls. + Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. When the post-conflict worklist is empty because no applicable style knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable style knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. From bec8890b7ea8d84e6632abb3f25a22b169a034fa Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Mon, 21 Sep 2026 10:16:44 +0200 Subject: [PATCH 28/51] Add SCM functional knowledge domain (#192) * Add SCM functional knowledge domain Introduce nine source-backed rules with original AL sample pairs, bounded SCM review routing, and complete positive/clean evaluation coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Normalize SCM knowledge and review ownership Align article and AL sample conventions, keep BC facts separate from review mechanics, and clarify reciprocal Finance ownership without bespoke shared test assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Test-SkillIndex.ps1 | 7 +- README.md | 11 +- docs/using-bcquality.md | 22 +++- evaluation/README.md | 10 ++ evaluation/review-fixtures.json | 13 ++ ...ions-through-reservation-management.bad.al | 13 ++ ...ons-through-reservation-management.good.al | 14 ++ ...rvations-through-reservation-management.md | 38 ++++++ ...tions-through-the-standard-workflow.bad.al | 47 +++++++ ...ions-through-the-standard-workflow.good.al | 31 +++++ ...n-actions-through-the-standard-workflow.md | 43 +++++++ ...plications-through-posting-routines.bad.al | 33 +++++ ...lications-through-posting-routines.good.al | 40 ++++++ ...m-applications-through-posting-routines.md | 39 ++++++ ...edger-changes-through-item-journals.bad.al | 32 +++++ ...dger-changes-through-item-journals.good.al | 20 +++ ...em-ledger-changes-through-item-journals.md | 42 ++++++ ...tion-through-the-item-journal-batch.bad.al | 17 +++ ...ion-through-the-item-journal-batch.good.al | 16 +++ ...aluation-through-the-item-journal-batch.md | 38 ++++++ ...ough-shipment-and-receipt-codeunits.bad.al | 22 ++++ ...ugh-shipment-and-receipt-codeunits.good.al | 32 +++++ ...-through-shipment-and-receipt-codeunits.md | 41 ++++++ ...se-adjustments-with-the-item-ledger.bad.al | 37 ++++++ ...e-adjustments-with-the-item-ledger.good.al | 42 ++++++ ...ehouse-adjustments-with-the-item-ledger.md | 40 ++++++ ...hrough-source-reservation-codeunits.bad.al | 30 +++++ ...rough-source-reservation-codeunits.good.al | 20 +++ ...ng-through-source-reservation-codeunits.md | 41 ++++++ ...te-aware-availability-for-promising.bad.al | 21 +++ ...e-aware-availability-for-promising.good.al | 27 ++++ ...e-date-aware-availability-for-promising.md | 39 ++++++ microsoft/skills/review/al-code-review.md | 1 + microsoft/skills/review/al-scm-review.md | 121 ++++++++++++++++++ 34 files changed, 1030 insertions(+), 10 deletions(-) create mode 100644 microsoft/knowledge/scm/cancel-reservations-through-reservation-management.bad.al create mode 100644 microsoft/knowledge/scm/cancel-reservations-through-reservation-management.good.al create mode 100644 microsoft/knowledge/scm/cancel-reservations-through-reservation-management.md create mode 100644 microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.bad.al create mode 100644 microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.good.al create mode 100644 microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.md create mode 100644 microsoft/knowledge/scm/change-item-applications-through-posting-routines.bad.al create mode 100644 microsoft/knowledge/scm/change-item-applications-through-posting-routines.good.al create mode 100644 microsoft/knowledge/scm/change-item-applications-through-posting-routines.md create mode 100644 microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.bad.al create mode 100644 microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.good.al create mode 100644 microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.md create mode 100644 microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.bad.al create mode 100644 microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.good.al create mode 100644 microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.md create mode 100644 microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.bad.al create mode 100644 microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.good.al create mode 100644 microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.md create mode 100644 microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.bad.al create mode 100644 microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.good.al create mode 100644 microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.md create mode 100644 microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.bad.al create mode 100644 microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.good.al create mode 100644 microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.md create mode 100644 microsoft/knowledge/scm/use-date-aware-availability-for-promising.bad.al create mode 100644 microsoft/knowledge/scm/use-date-aware-availability-for-promising.good.al create mode 100644 microsoft/knowledge/scm/use-date-aware-availability-for-promising.md create mode 100644 microsoft/skills/review/al-scm-review.md diff --git a/.github/scripts/Test-SkillIndex.ps1 b/.github/scripts/Test-SkillIndex.ps1 index ed07204..5354370 100644 --- a/.github/scripts/Test-SkillIndex.ps1 +++ b/.github/scripts/Test-SkillIndex.ps1 @@ -93,14 +93,15 @@ try { 'microsoft/skills/review/al-query-review.md', 'microsoft/skills/review/al-reporting-review.md', 'microsoft/skills/review/al-appsource-review.md', - 'microsoft/skills/review/al-telemetry-review.md' + 'microsoft/skills/review/al-telemetry-review.md', + 'microsoft/skills/review/al-scm-review.md' ) $review = @($skills | Where-Object id -eq 'al-code-review') if ($review.Count -ne 1) { throw "Expected exactly one al-code-review record, found $($review.Count)." } if ((@($review[0].subSkills) -join "`n") -cne ($expectedLeaves -join "`n")) { - throw 'al-code-review subSkills did not preserve the declared 17-leaf order.' + throw "al-code-review subSkills did not preserve the declared $($expectedLeaves.Count)-leaf order." } foreach ($leafPath in $expectedLeaves) { $leaf = @($skills | Where-Object path -ceq $leafPath) @@ -238,4 +239,4 @@ finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } -Write-Output 'Skill-index check PASSED: deterministic, schema-valid, and all 17 review leaves preserved in order.' +Write-Output "Skill-index check PASSED: deterministic, schema-valid, and all $($expectedLeaves.Count) review leaves preserved in order." diff --git a/README.md b/README.md index 8bf1cc7..9aaea9a 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,8 @@ the review can still discover knowledge by reading the folders. ## Scope -Today's curated content focuses on **technical AL code review**. It augments +Today's curated content covers **technical AL code review** and a focused +**Supply Chain Management (SCM)** functional domain. It augments the agent's judgment; it is not an exhaustive BC manual or a substitute for compilation, analyzers, tests, or human review. See [coverage and limits](docs/using-bcquality.md#coverage-and-limits) for the @@ -82,9 +83,11 @@ available domains and the difference between a folder review and a comparison. Mechanical issues already enforced by the AL compiler or standard analyzers are intentionally left to those deterministic tools rather than duplicated here. -Functional areas such as Finance, Supply Chain Management, Manufacturing, Jobs, -Warehousing, and Service, and technologies such as PowerShell, pipelines, and -Power Platform, remain valid future scope, **not current coverage claims**. +The [SCM domain](microsoft/knowledge/scm/) covers selected inventory, costing, +reservation, tracking, and warehouse/posting workflows, not exhaustive supply +chain validation. Broader functional coverage such as Finance, Manufacturing, +Jobs, and Service, and technologies such as PowerShell, pipelines, and Power +Platform, remain valid future scope, **not current coverage claims**. ## What's in this repo diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md index e55751f..2af08be 100644 --- a/docs/using-bcquality.md +++ b/docs/using-bcquality.md @@ -85,6 +85,7 @@ branch names with ones in your project. | Uncommitted changes | Use the installed al-code-review skill to review my staged and unstaged tracked changes against HEAD, without changing files. Identify any untracked AL files not included in that diff. | | Branch changes | Use the installed al-code-review skill to review changes on this branch since its merge base with `origin/main`. Exclude uncommitted changes and do not edit files. | | Focused review | Use the installed al-code-review skill to review performance in the app in this folder, without changing files. Return the complete performance findings report. | +| Supply chain code | Use the installed al-code-review skill to review SCM posting, inventory, reservations, item tracking, and warehouse workflows in this app folder, without changing files. Return the complete Supply Chain Management findings report. | | Agent SDK code | Use the installed al-code-review skill to review Agent SDK implementation and usage in this app folder, without changing files. Return the complete Agents findings report. | For Git comparisons, the named base ref must exist locally. If it is missing, @@ -183,7 +184,7 @@ using your normal compilation, analyzer, test, and human-review workflow. ## Coverage and limits -The Microsoft broad review composes the 17 Microsoft domains listed below. +The Microsoft broad review composes the Microsoft domains listed below. The Community Agents review is a separate skill selected by the request, not a nested part of that coordinator. All current review leaves accept app folders, files, and diffs; request an Agent SDK review explicitly when that @@ -193,8 +194,22 @@ Available knowledge is **not** a promise that every rule will run. Selection depends on the task, target context, enabled layers, and source evidence. A whole-folder review is a current-state snapshot: detecting a published API removal or another comparison-only regression requires an actual baseline. -The corpus is technical AL guidance, not exhaustive functional validation or -AppSource certification. +The corpus combines technical AL guidance with targeted functional-domain +invariants, not exhaustive functional validation or AppSource certification. + +The SCM leaf owns selected inventory/value, application, reservation, tracking, +and warehouse/posting invariants. It prunes unrelated AL using the actual +tables, codeunits, fields, and operations in scope; an item caption or a broad +`ApplicationArea` alone is not an SCM review signal. Missing workflow context +must not be replaced with an assumed posting defect. Manufacturing, assembly, +planning, and other supply-chain areas are covered only where an article +explicitly names the shared interface or invariant. + +SCM owns Item/Value/Capacity/Warehouse and inventory-application posting +records. Pure G/L, customer/vendor/detailed/VAT and financial-only posting +mutations belong to Finance, even when that domain is not enabled. Equivalent +findings for one inventory-originated posting bypass have one SCM primary +owner; distinct independent financial defects remain separate. BCQuality intentionally does not duplicate mechanical diagnostics already enforced by the AL compiler or standard analyzers. Run the consuming app's @@ -222,6 +237,7 @@ Each article describes one concern. Where samples exist, use its linked | Reporting | [Reporting](../microsoft/knowledge/reporting/) | | Security | [Security](../microsoft/knowledge/security/) | | Style | [Style](../microsoft/knowledge/style/) | +| Supply Chain Management | [SCM](../microsoft/knowledge/scm/) | | Telemetry | [Telemetry](../microsoft/knowledge/telemetry/) | | Testing | [Testing](../microsoft/knowledge/testing/) | | User interface | [UI](../microsoft/knowledge/ui/) | diff --git a/evaluation/README.md b/evaluation/README.md index 2125ce3..05b5e52 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -6,6 +6,16 @@ The evaluation is convention-driven. The harness discovers every `/skills Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer. +The SCM `articles` override deliberately selects every rule in the initial +functional domain, producing nine positive cases and nine clean controls. +Business context is executable: document/status `TestField` guards, +calculated-revaluation fields, source-transfer base quantities, warehouse +reconciliation steps, and additional-demand promising parameters survive +neutralization. Do not move those preconditions into comments or generic +"posting" helper names; removing them can turn a real defect into a valid +alternative workflow. The clean pairs exercise the supported APIs selected by +the same routing cues, not merely unrelated code that contains no SCM tokens. + ## Validate the corpus ```powershell diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index e52a653..2d03940 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -51,6 +51,19 @@ "stop-when-runrequestpage-returns-empty-parameters" ] }, + "scm": { + "articles": [ + "post-item-ledger-changes-through-item-journals", + "post-revaluation-through-the-item-journal-batch", + "change-item-applications-through-posting-routines", + "cancel-reservations-through-reservation-management", + "transfer-item-tracking-through-source-reservation-codeunits", + "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" + ] + }, "style": { "articles": [ "label-comment-explains-placeholders", diff --git a/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.bad.al b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.bad.al new file mode 100644 index 0000000..248f3a4 --- /dev/null +++ b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.bad.al @@ -0,0 +1,13 @@ +codeunit 50106 "SCM Cancel Reservation Bad" +{ + procedure CancelSalesReservation(ReservationEntryNo: Integer) + var + ReservationEntry: Record "Reservation Entry"; + begin + ReservationEntry.Get(ReservationEntryNo, false); + ReservationEntry.TestField("Source Type", Database::"Sales Line"); + ReservationEntry.TestField("Reservation Status", ReservationEntry."Reservation Status"::Reservation); + + ReservationEntry.Delete(true); + end; +} diff --git a/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.good.al b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.good.al new file mode 100644 index 0000000..3d5849d --- /dev/null +++ b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.good.al @@ -0,0 +1,14 @@ +codeunit 50107 "SCM Cancel Reservation Good" +{ + procedure CancelSalesReservation(ReservationEntryNo: Integer) + var + ReservationEntry: Record "Reservation Entry"; + ReservationEngineMgt: Codeunit "Reservation Engine Mgt."; + begin + ReservationEntry.Get(ReservationEntryNo, false); + ReservationEntry.TestField("Source Type", Database::"Sales Line"); + ReservationEntry.TestField("Reservation Status", ReservationEntry."Reservation Status"::Reservation); + + ReservationEngineMgt.CancelReservation(ReservationEntry); + end; +} diff --git a/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.md b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.md new file mode 100644 index 0000000..1df3b12 --- /dev/null +++ b/microsoft/knowledge/scm/cancel-reservations-through-reservation-management.md @@ -0,0 +1,38 @@ +--- +bc-version: [all] +domain: scm +keywords: [reservation-entry, cancelreservation, reservation-engine-mgt, reservation-status, order-tracking, disallow-cancellation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Cancel reservations through reservation management + +## Description + +Persistent `"Reservation Entry"` rows are not disposable allocation markers. Reservation and Tracking links use an entry-number pair with opposite `Positive` values, while Surplus and Prospect entries can legitimately be unpaired. Cancelling a binding reservation must handle its counterpart and any remaining item tracking or order tracking, not just remove a row. + +## Best Practice + +Explicit cancellation of an existing binding reservation uses `"Reservation Engine Mgt.".CancelReservation`. It checks the reservation status and `"Disallow Cancellation"`, handles the counterpart, and preserves or retracks the remaining source quantities as appropriate. Source-line quantity changes have their own source-specific reservation management path. + +Not every Reservation Entry has a partner or identical lot/serial values on both sides: Surplus/Prospect entries and supported late-binding scenarios have different relationships. Temporary buffers, engine-owned updates, and supported publisher metadata are not independent cancellation. Cancelling a reservation is also different from intentionally removing an item-tracking assignment. + +The samples retrieve the negative side of a persistent sales-line reservation and cancel only the binding. They do not delete the sales line or remove its tracking specifications. + +See sample: [`cancel-reservations-through-reservation-management.good.al`](cancel-reservations-through-reservation-management.good.al). + +## Anti Pattern + +`Delete(true)`, `DeleteAll`, or a status/source rewrite on persistent `"Reservation Entry"` records does not perform binding-reservation cancellation. Even deleting both sides can discard tracking that should survive and omit retracking. + +Normal processing of temporary Prospect/Surplus buffers is outside that cancellation workflow, and an unpaired row is not intrinsically an orphan. + +See sample: [`cancel-reservations-through-reservation-management.bad.al`](cancel-reservations-through-reservation-management.bad.al). + +## References + +- [Reservation, order tracking, and action messaging](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-reservation-order-tracking-and-action-messaging) +- [Item tracking and reservations](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-tracking-and-reservations) +- [BaseApp reservation cancellation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Tracking/ReservationEngineMgt.Codeunit.al#L51-L90) diff --git a/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.bad.al b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.bad.al new file mode 100644 index 0000000..7bc4df8 --- /dev/null +++ b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.bad.al @@ -0,0 +1,47 @@ +codeunit 50116 "SCM Requisition Action Bad" +{ + procedure CarryOutAcceptedNewPurchase(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; OrderDate: Date; PostingDate: Date; ReceiptDate: Date; CutoffDate: Date) + var + RequisitionLine: Record "Requisition Line"; + PurchaseHeader: Record "Purchase Header"; + PurchaseLine: Record "Purchase Line"; + begin + if (OrderDate = 0D) or (PostingDate = 0D) or (ReceiptDate = 0D) or (CutoffDate = 0D) then + Error(PlanningDatesErr); + RequisitionLine.Get(TemplateName, BatchName, LineNo); + RequisitionLine.TestField(Type, RequisitionLine.Type::Item); + RequisitionLine.TestField("Replenishment System", RequisitionLine."Replenishment System"::Purchase); + RequisitionLine.TestField("Action Message", RequisitionLine."Action Message"::New); + RequisitionLine.TestField("Accept Action Message", true); + RequisitionLine.TestField("Demand Type", Database::"Sales Line"); + RequisitionLine.TestField("Demand Order No."); + RequisitionLine.TestField("Vendor No."); + RequisitionLine.SetRecFilter(); + + PurchaseHeader.Init(); + PurchaseHeader."Document Type" := PurchaseHeader."Document Type"::Order; + PurchaseHeader.Insert(true); + PurchaseHeader.Validate("Buy-from Vendor No.", RequisitionLine."Vendor No."); + PurchaseHeader.Validate("Order Date", OrderDate); + PurchaseHeader.Validate("Posting Date", PostingDate); + PurchaseHeader.Validate("Expected Receipt Date", ReceiptDate); + PurchaseHeader.Modify(true); + + PurchaseLine.Init(); + PurchaseLine."Document Type" := PurchaseHeader."Document Type"; + PurchaseLine."Document No." := PurchaseHeader."No."; + PurchaseLine."Line No." := 10000; + PurchaseLine.Validate(Type, PurchaseLine.Type::Item); + PurchaseLine.Validate("No.", RequisitionLine."No."); + PurchaseLine.Validate("Location Code", RequisitionLine."Location Code"); + PurchaseLine.Validate("Variant Code", RequisitionLine."Variant Code"); + PurchaseLine.Validate("Unit of Measure Code", RequisitionLine."Unit of Measure Code"); + PurchaseLine.Validate(Quantity, RequisitionLine.Quantity); + PurchaseLine.Insert(true); + + RequisitionLine.Delete(true); + end; + + var + PlanningDatesErr: Label 'Supply explicit order, posting, receipt, and cutoff dates.'; +} diff --git a/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.good.al b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.good.al new file mode 100644 index 0000000..c09ea65 --- /dev/null +++ b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.good.al @@ -0,0 +1,31 @@ +codeunit 50117 "SCM Requisition Action Good" +{ + procedure CarryOutAcceptedNewPurchase(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; OrderDate: Date; PostingDate: Date; ReceiptDate: Date; CutoffDate: Date) + var + RequisitionLine: Record "Requisition Line"; + PurchaseHeaderDefaults: Record "Purchase Header"; + ReqWkshMakeOrder: Codeunit "Req. Wksh.-Make Order"; + begin + if (OrderDate = 0D) or (PostingDate = 0D) or (ReceiptDate = 0D) or (CutoffDate = 0D) then + Error(PlanningDatesErr); + RequisitionLine.Get(TemplateName, BatchName, LineNo); + RequisitionLine.TestField(Type, RequisitionLine.Type::Item); + RequisitionLine.TestField("Replenishment System", RequisitionLine."Replenishment System"::Purchase); + RequisitionLine.TestField("Action Message", RequisitionLine."Action Message"::New); + RequisitionLine.TestField("Accept Action Message", true); + RequisitionLine.TestField("Demand Type", Database::"Sales Line"); + RequisitionLine.TestField("Demand Order No."); + RequisitionLine.TestField("Vendor No."); + RequisitionLine.SetRecFilter(); + + PurchaseHeaderDefaults."Order Date" := OrderDate; + PurchaseHeaderDefaults."Posting Date" := PostingDate; + PurchaseHeaderDefaults."Expected Receipt Date" := ReceiptDate; + ReqWkshMakeOrder.Set(PurchaseHeaderDefaults, CutoffDate, false); + ReqWkshMakeOrder.SetSuppressCommit(true); + ReqWkshMakeOrder.CarryOutBatchAction(RequisitionLine); + end; + + var + PlanningDatesErr: Label 'Supply explicit order, posting, receipt, and cutoff dates.'; +} diff --git a/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.md b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.md new file mode 100644 index 0000000..88f68bd --- /dev/null +++ b/microsoft/knowledge/scm/carry-out-requisition-actions-through-the-standard-workflow.md @@ -0,0 +1,43 @@ +--- +bc-version: [all] +domain: scm +keywords: [requisition-line, action-message, accept-action-message, req-wksh-make-order, carryoutbatchaction, demand-order-no, planning-flexibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Carry out requisition actions through the standard workflow + +## Description + +A requisition/planning line is a pending change to a supply/demand network, not just a template for a purchase line. Carry-out interprets New, change-quantity, reschedule, and cancel actions, preserves referenced supply and planning flexibility, and moves reservation/tracking ownership before finalizing the proposal. Creating a plausible purchase order and deleting the requisition line can leave new supply unrelated to the demand that caused it. + +## Best Practice + +Requisition batch carry-out initializes `"Req. Wksh.-Make Order"` with `Set` and invokes `CarryOutBatchAction` on the intended accepted lines. Order/posting/receipt defaults are separate from the ending-order-date cutoff, and worksheet/batch/line filters define the selection. A plain `Run` or a single order-line insertion helper is not a replacement for this batch initialization and finalization. + +The standard `"Carry Out Action"` dispatch handles broader planning output and its configured purchase, transfer, assembly, or manufacturing choices. Each action's supply change and source-specific reservation transfer precede proposal finalization; not every action creates a new purchase order. + +Ordinary manual purchase creation that does not consume planning output is outside this rule. Users may reject or delete unwanted proposals without creating supply; temporary planning simulations, pre-carry-out enrichment, and engine-owned cleanup are also legitimate. `Delete(true)` on a requisition line is not intrinsically a defect. + +The samples select an existing accepted New/Purchase item proposal with sales-demand context. Dates are explicit, the source selection remains bounded, and the clean sample leaves order creation and reservation handoff to the standard workflow; it is not a complete planning-run generator. + +See sample: [`carry-out-requisition-actions-through-the-standard-workflow.good.al`](carry-out-requisition-actions-through-the-standard-workflow.good.al). + +## Anti Pattern + +Manually creating or changing supply from a subset of an accepted persistent `"Requisition Line"`, then deleting or marking that proposal handled, skips the standard carry-out/source-reservation handoff. Purchase-field validation and the requisition delete trigger do not first move the proposal's demand links to the new purchase line. + +Deleting an unwanted suggestion or creating an ordinary purchase order without consuming planning output is a separate operation. The standard carry-out engine's own insert/delete sequence participates in the source handoff rather than replacing it. + +See sample: [`carry-out-requisition-actions-through-the-standard-workflow.bad.al`](carry-out-requisition-actions-through-the-standard-workflow.bad.al). + +## References + +- [Perform planning action messages](https://learn.microsoft.com/en-us/dynamics365/business-central/production-how-to-run-mps-and-mrp#to-perform-action-messages) +- [Planning functionality](https://learn.microsoft.com/en-us/dynamics365/business-central/production-about-planning-functionality) +- [Reservation, order tracking, and action messaging](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-reservation-order-tracking-and-action-messaging) +- [BaseApp carry-out caller and date defaults](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Requisition/CarryOutActionMsgReq.Report.al#L116-L133) +- [Batch initialization and selection](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Requisition/ReqWkshMakeOrder.Codeunit.al#L116-L215) +- [Reservation handoff before supply finalization](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Requisition/ReqWkshMakeOrder.Codeunit.al#L663-L743) diff --git a/microsoft/knowledge/scm/change-item-applications-through-posting-routines.bad.al b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.bad.al new file mode 100644 index 0000000..f0239db --- /dev/null +++ b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.bad.al @@ -0,0 +1,33 @@ +codeunit 50104 "SCM Item Application Bad" +{ + procedure ChangeSalesQuantityApplication(ApplicationEntryNo: Integer; NewInboundEntryNo: Integer) + var + ItemApplicationEntry: Record "Item Application Entry"; + OutboundItemLedgerEntry: Record "Item Ledger Entry"; + InboundItemLedgerEntry: Record "Item Ledger Entry"; + begin + ItemApplicationEntry.Get(ApplicationEntryNo); + ItemApplicationEntry.TestField(Quantity); + ItemApplicationEntry.TestField("Inbound Item Entry No."); + ItemApplicationEntry.TestField("Outbound Item Entry No."); + ItemApplicationEntry.TestField("Transferred-from Entry No.", 0); + if ItemApplicationEntry.CostApplication() then + Error(QuantityApplicationErr); + OutboundItemLedgerEntry.Get(ItemApplicationEntry."Outbound Item Entry No."); + OutboundItemLedgerEntry.TestField("Entry Type", OutboundItemLedgerEntry."Entry Type"::Sale); + OutboundItemLedgerEntry.TestField(Positive, false); + OutboundItemLedgerEntry.TestField("Drop Shipment", false); + OutboundItemLedgerEntry.TestField(Correction, false); + InboundItemLedgerEntry.Get(NewInboundEntryNo); + InboundItemLedgerEntry.TestField(Positive, true); + InboundItemLedgerEntry.TestField("Item No.", OutboundItemLedgerEntry."Item No."); + InboundItemLedgerEntry.TestField("Variant Code", OutboundItemLedgerEntry."Variant Code"); + InboundItemLedgerEntry.TestField("Location Code", OutboundItemLedgerEntry."Location Code"); + + ItemApplicationEntry."Inbound Item Entry No." := NewInboundEntryNo; + ItemApplicationEntry.Modify(true); + end; + + var + QuantityApplicationErr: Label 'Select an ordinary quantity application, not a cost application.'; +} diff --git a/microsoft/knowledge/scm/change-item-applications-through-posting-routines.good.al b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.good.al new file mode 100644 index 0000000..e176aad --- /dev/null +++ b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.good.al @@ -0,0 +1,40 @@ +codeunit 50105 "SCM Item Application Good" +{ + procedure ChangeSalesQuantityApplication(ApplicationEntryNo: Integer; NewInboundEntryNo: Integer) + var + ItemApplicationEntry: Record "Item Application Entry"; + OutboundItemLedgerEntry: Record "Item Ledger Entry"; + InboundItemLedgerEntry: Record "Item Ledger Entry"; + ItemJnlPostLine: Codeunit "Item Jnl.-Post Line"; + OutboundEntryNo: Integer; + begin + ItemApplicationEntry.Get(ApplicationEntryNo); + ItemApplicationEntry.TestField(Quantity); + ItemApplicationEntry.TestField("Inbound Item Entry No."); + ItemApplicationEntry.TestField("Outbound Item Entry No."); + ItemApplicationEntry.TestField("Transferred-from Entry No.", 0); + if ItemApplicationEntry.CostApplication() then + Error(QuantityApplicationErr); + OutboundEntryNo := ItemApplicationEntry."Outbound Item Entry No."; + OutboundItemLedgerEntry.Get(OutboundEntryNo); + OutboundItemLedgerEntry.TestField("Entry Type", OutboundItemLedgerEntry."Entry Type"::Sale); + OutboundItemLedgerEntry.TestField(Positive, false); + OutboundItemLedgerEntry.TestField("Drop Shipment", false); + OutboundItemLedgerEntry.TestField(Correction, false); + InboundItemLedgerEntry.Get(NewInboundEntryNo); + InboundItemLedgerEntry.TestField(Positive, true); + InboundItemLedgerEntry.TestField("Item No.", OutboundItemLedgerEntry."Item No."); + InboundItemLedgerEntry.TestField("Variant Code", OutboundItemLedgerEntry."Variant Code"); + InboundItemLedgerEntry.TestField("Location Code", OutboundItemLedgerEntry."Location Code"); + + ItemJnlPostLine.UnApply(ItemApplicationEntry); + OutboundItemLedgerEntry.Get(OutboundEntryNo); + ItemJnlPostLine.ReApply(OutboundItemLedgerEntry, NewInboundEntryNo); + ItemJnlPostLine.RedoApplications(); + ItemJnlPostLine.CostAdjust(); + ItemJnlPostLine.ClearApplicationLog(); + end; + + var + QuantityApplicationErr: Label 'Select an ordinary quantity application, not a cost application.'; +} diff --git a/microsoft/knowledge/scm/change-item-applications-through-posting-routines.md b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.md new file mode 100644 index 0000000..5b5443e --- /dev/null +++ b/microsoft/knowledge/scm/change-item-applications-through-posting-routines.md @@ -0,0 +1,39 @@ +--- +bc-version: [all] +domain: scm +keywords: [item-application-entry, inbound-item-entry-no, unapply, reapply, redoapplications, costadjust, application-worksheet] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Change item applications through posting routines + +## Description + +An `"Item Application Entry"` connects quantity application to cost flow; changing its inbound entry number is not merely fixing a foreign key. Unapplication/reapplication also affects ledger remaining quantities, open states, valuation, and entries needing cost adjustment. Direct edits can leave a plausible application row attached to inconsistent inventory and costs. + +## Best Practice + +The Application Worksheet provides the interactive correction workflow. A narrowly controlled programmatic correction of an ordinary quantity application uses the same `"Item Jnl.-Post Line"` instance for `UnApply`, a reload of the affected outbound item entry, and `ReApply` to the compatible inbound entry. Its finalization lifecycle includes `RedoApplications`, `CostAdjust`, and `ClearApplicationLog`. + +The posting routines enforce inventory-period, correction, transfer, and drop-shipment restrictions. Entries with `"Transferred-from Entry No."`, outbound transfers, and special application types are outside the ordinary-sales sample's scope. Application-check bypasses remove those protections, and the worksheet's multi-step recovery flags belong to its UI lifecycle rather than a standalone transaction. + +`CostAdjust` honors automatic-cost-adjustment setup; calling it does not mean all costs are settled when adjustment is disabled or deferred. Scheduled/manual adjustment remains necessary in those configurations. Temporary application projections, extension metadata, and source-document reservation/order-tracking changes are not edits to the persistent item-application graph. + +See sample: [`change-item-applications-through-posting-routines.good.al`](change-item-applications-through-posting-routines.good.al). + +## Anti Pattern + +Independent `Modify`, `Delete`, or replacement `Insert` operations on persistent `"Item Application Entry"` rows can repoint a receipt/shipment application without updating remaining quantities or cost propagation. Valid item numbers, matching quantities, and running table triggers do not complete reapplication. + +Omitting finalization or committing between unapply and reapply exposes an incomplete replacement. The standard posting/application workflow's internal table writes differ because they participate in that lifecycle. + +See sample: [`change-item-applications-through-posting-routines.bad.al`](change-item-applications-through-posting-routines.bad.al). + +## References + +- [Item application design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-application) +- [Cost adjustment design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-cost-adjustment) +- [BaseApp application finalization sequence](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Ledger/ApplicationWorksheet.Page.al#L495-L503) +- [BaseApp reapplication and cost-adjustment lifecycle](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Posting/ItemJnlPostLine.Codeunit.al#L5464-L5538) diff --git a/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.bad.al b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.bad.al new file mode 100644 index 0000000..1187f96 --- /dev/null +++ b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.bad.al @@ -0,0 +1,32 @@ +codeunit 50100 "SCM Stock Adjustment Bad" +{ + procedure PostPreparedPositiveAdjustment(ItemJournalLine: Record "Item Journal Line"; NewEntryNo: Integer) + var + ItemLedgerEntry: Record "Item Ledger Entry"; + begin + ItemJournalLine.TestField("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt."); + ItemJournalLine.TestField("Value Entry Type", ItemJournalLine."Value Entry Type"::"Direct Cost"); + ItemJournalLine.TestField("Item No."); + ItemJournalLine.TestField("Posting Date"); + ItemJournalLine.TestField("Quantity (Base)"); + if ItemJournalLine."Quantity (Base)" < 0 then + Error(PositiveQuantityErr); + + ItemLedgerEntry.Init(); + ItemLedgerEntry."Entry No." := NewEntryNo; + ItemLedgerEntry."Item No." := ItemJournalLine."Item No."; + ItemLedgerEntry."Entry Type" := ItemJournalLine."Entry Type"; + ItemLedgerEntry."Posting Date" := ItemJournalLine."Posting Date"; + ItemLedgerEntry."Document No." := ItemJournalLine."Document No."; + ItemLedgerEntry."Location Code" := ItemJournalLine."Location Code"; + ItemLedgerEntry."Variant Code" := ItemJournalLine."Variant Code"; + ItemLedgerEntry.Quantity := ItemJournalLine."Quantity (Base)"; + ItemLedgerEntry."Remaining Quantity" := ItemLedgerEntry.Quantity; + ItemLedgerEntry.Positive := true; + ItemLedgerEntry.Open := true; + ItemLedgerEntry.Insert(true); + end; + + var + PositiveQuantityErr: Label 'The prepared adjustment must increase inventory.'; +} diff --git a/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.good.al b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.good.al new file mode 100644 index 0000000..3e7866a --- /dev/null +++ b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.good.al @@ -0,0 +1,20 @@ +codeunit 50101 "SCM Stock Adjustment Good" +{ + procedure PostPreparedPositiveAdjustment(var ItemJournalLine: Record "Item Journal Line") + var + ItemJnlPostLine: Codeunit "Item Jnl.-Post Line"; + begin + ItemJournalLine.TestField("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt."); + ItemJournalLine.TestField("Value Entry Type", ItemJournalLine."Value Entry Type"::"Direct Cost"); + ItemJournalLine.TestField("Item No."); + ItemJournalLine.TestField("Posting Date"); + ItemJournalLine.TestField("Quantity (Base)"); + if ItemJournalLine."Quantity (Base)" < 0 then + Error(PositiveQuantityErr); + + ItemJnlPostLine.RunWithCheck(ItemJournalLine); + end; + + var + PositiveQuantityErr: Label 'The prepared adjustment must increase inventory.'; +} diff --git a/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.md b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.md new file mode 100644 index 0000000..0df6dac --- /dev/null +++ b/microsoft/knowledge/scm/post-item-ledger-changes-through-item-journals.md @@ -0,0 +1,42 @@ +--- +bc-version: [all] +domain: scm +keywords: [item-ledger-entry, value-entry, item-journal-line, item-jnl-post-line, runwithcheck, inventory-posting] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Post item ledger changes through item journals + +## Description + +An item ledger entry is not an independently insertable stock balance. Posting connects its quantity to item applications, reservations, tracking, and one or more value entries; expected cost, invoicing, revaluation, and later cost adjustment can produce different value entries for the same item entry. Running a table's insert trigger does not run this posting workflow or its configured inventory-to-G/L integration. + +## Best Practice + +The posting entry point for a prepared standalone item-journal movement is `"Item Jnl.-Post Line".RunWithCheck`. Persisted journal batches use `"Item Jnl.-Post Batch"`; sales, purchase, transfer, assembly, and production transactions retain their owning document/posting orchestration. Those workflows create the ledger, value, and application records together with their required checks. + +Supported posting events enrich validated journal/source data within the owning workflow. Read-only ledger queries, temporary ledger previews, extension-owned metadata fields, and publisher parameters consumed by the poster are not independent ledger posting. A temporary item-journal buffer can still produce persistent entries when passed to a posting codeunit. A publisher's `var` parameter or `IsHandled` flag alone does not supply the missing quantity/cost coordination; the normal engine's own inserts operate within that coordination. + +Not every value entry points to an item ledger entry: capacity and production WIP have their own supported posting relationships. Assembly and manufacturing posting retain order/component/routing and capacity context; one bare output/consumption call is not full order completion. + +The clean sample takes an already prepared positive-adjustment journal line. It is not a substitute for journal preparation, batch revaluation, warehouse reconciliation, or source-document posting. + +See sample: [`post-item-ledger-changes-through-item-journals.good.al`](post-item-ledger-changes-through-item-journals.good.al). + +## Anti Pattern + +Independent inserts/deletes of persistent `"Item Ledger Entry"` or `"Value Entry"` transaction rows, or overwrites of posted quantity, remaining quantity, application identity, or cost amounts, bypass the coordinated receipt, shipment, adjustment, or cost-correction workflow. `Insert(true)`, `Modify(true)`, and balanced-looking quantities do not supply that orchestration: stock can change without the corresponding application/value graph, or downstream cost flow can remain stale. + +A table declaration or custom annotation-field update does not change inventory quantities or costs and is outside this transaction-state concern. + +See sample: [`post-item-ledger-changes-through-item-journals.bad.al`](post-item-ledger-changes-through-item-journals.bad.al). + +## References + +- [Inventory posting design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-inventory-posting) +- [Item application design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-application) +- [BaseApp item-journal posting entry point](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Posting/ItemJnlPostLine.Codeunit.al#L162-L179) +- [Assembly-order posting context](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-assembly-order-posting) +- [Production-order posting context](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-production-order-posting) diff --git a/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.bad.al b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.bad.al new file mode 100644 index 0000000..df450cc --- /dev/null +++ b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.bad.al @@ -0,0 +1,17 @@ +codeunit 50102 "SCM Revaluation Batch Bad" +{ + procedure PostCalculatedRevaluationBatch(TemplateName: Code[10]; BatchName: Code[10]) + var + ItemJournalLine: Record "Item Journal Line"; + ItemJnlPostLine: Codeunit "Item Jnl.-Post Line"; + begin + ItemJournalLine.SetRange("Journal Template Name", TemplateName); + ItemJournalLine.SetRange("Journal Batch Name", BatchName); + ItemJournalLine.FindSet(); + repeat + ItemJournalLine.TestField("Value Entry Type", ItemJournalLine."Value Entry Type"::Revaluation); + ItemJournalLine.TestField("Inventory Value Per"); + ItemJnlPostLine.RunWithCheck(ItemJournalLine); + until ItemJournalLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.good.al b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.good.al new file mode 100644 index 0000000..810fad5 --- /dev/null +++ b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.good.al @@ -0,0 +1,16 @@ +codeunit 50103 "SCM Revaluation Batch Good" +{ + procedure PostCalculatedRevaluationBatch(TemplateName: Code[10]; BatchName: Code[10]) + var + ItemJournalLine: Record "Item Journal Line"; + ItemJnlPostBatch: Codeunit "Item Jnl.-Post Batch"; + begin + ItemJournalLine.SetRange("Journal Template Name", TemplateName); + ItemJournalLine.SetRange("Journal Batch Name", BatchName); + ItemJournalLine.FindFirst(); + ItemJournalLine.TestField("Value Entry Type", ItemJournalLine."Value Entry Type"::Revaluation); + ItemJournalLine.TestField("Inventory Value Per"); + + ItemJnlPostBatch.Run(ItemJournalLine); + end; +} diff --git a/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.md b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.md new file mode 100644 index 0000000..9730c2d --- /dev/null +++ b/microsoft/knowledge/scm/post-revaluation-through-the-item-journal-batch.md @@ -0,0 +1,38 @@ +--- +bc-version: [all] +domain: scm +keywords: [revaluation, inventory-value-per, partial-revaluation, item-jnl-post-batch, item-journal-line, runwithcheck, standard-cost] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Post calculated revaluation through the item journal batch + +## Description + +A calculated revaluation line with nonblank `"Inventory Value Per"` represents an aggregate, not a finalized posting against one item ledger entry. Codeunit `"Item Jnl.-Post Batch"` distributes that value over eligible entries, handles rounding, and coordinates Item/SKU standard-cost updates. Calling the line poster directly skips that batch work even though the input is a valid `"Item Journal Line"`. + +## Best Practice + +Posting a prepared revaluation batch through `"Item Jnl.-Post Batch"` preserves the calculated line's valuation date, aggregation scope, location/variant filters, and revaluation fields. The batch expands summarized values into per-entry postings and checks that the eligible inventory has not changed; for partial revaluation it also rechecks remaining quantity before posting. + +The public `"Item Jnl.-Post Line".RunWithCheck` API does not replace that orchestration. It remains legitimate for finalized individual-entry revaluation lines within a workflow that already supplies the necessary checks; the batch itself uses the line poster. Ordinary quantity journals and finalized per-entry revaluations are distinct from this summarized/partial-revaluation case. + +The samples explicitly require `"Value Entry Type" = Revaluation` and a nonblank `"Inventory Value Per"` in an existing calculated journal batch. They demonstrate posting, not how to calculate a new valuation or choose a standard cost. + +See sample: [`post-revaluation-through-the-item-journal-batch.good.al`](post-revaluation-through-the-item-journal-batch.good.al). + +## Anti Pattern + +A loop that sends calculated aggregate revaluation lines straight to `"Item Jnl.-Post Line"` skips distribution over the underlying item entries. A partial-revaluation workflow without the remaining-quantity recheck can post a valuation against inventory that no longer matches the calculation. + +Directly editing existing `"Value Entry"` cost amounts or the Item's unit cost does not repair those allocation and adjustment relationships. Their correction belongs to the revaluation/cost-adjustment workflow. + +See sample: [`post-revaluation-through-the-item-journal-batch.bad.al`](post-revaluation-through-the-item-journal-batch.bad.al). + +## References + +- [Revaluation design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-revaluation) +- [Inventory posting design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-inventory-posting) +- [BaseApp summarized revaluation and remaining-quantity checks](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Posting/ItemJnlPostBatch.Codeunit.al#L510-L714) diff --git a/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.bad.al b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.bad.al new file mode 100644 index 0000000..2e14920 --- /dev/null +++ b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.bad.al @@ -0,0 +1,22 @@ +codeunit 50112 "SCM Transfer Posting Bad" +{ + procedure ShipTransferOrder(TransferOrderNo: Code[20]; var ItemJournalLine: Record "Item Journal Line") + var + TransferHeader: Record "Transfer Header"; + Location: Record Location; + ItemJnlPostLine: Codeunit "Item Jnl.-Post Line"; + begin + TransferHeader.Get(TransferOrderNo); + TransferHeader.TestField("Direct Transfer", false); + TransferHeader.TestField("In-Transit Code"); + Location.Get(TransferHeader."Transfer-from Code"); + Location.TestField("Require Shipment", false); + ItemJournalLine.TestField("Entry Type", ItemJournalLine."Entry Type"::Transfer); + ItemJournalLine.TestField("Location Code", TransferHeader."Transfer-from Code"); + ItemJournalLine.TestField("New Location Code", TransferHeader."In-Transit Code"); + + ItemJnlPostLine.RunWithCheck(ItemJournalLine); + TransferHeader."Last Shipment No." := ItemJournalLine."Document No."; + TransferHeader.Modify(true); + end; +} diff --git a/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.good.al b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.good.al new file mode 100644 index 0000000..8507c99 --- /dev/null +++ b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.good.al @@ -0,0 +1,32 @@ +codeunit 50113 "SCM Transfer Posting Good" +{ + procedure ShipTransferOrder(TransferOrderNo: Code[20]) + var + TransferHeader: Record "Transfer Header"; + Location: Record Location; + TransferOrderPostShipment: Codeunit "TransferOrder-Post Shipment"; + begin + TransferHeader.Get(TransferOrderNo); + TransferHeader.TestField("Direct Transfer", false); + TransferHeader.TestField("In-Transit Code"); + Location.Get(TransferHeader."Transfer-from Code"); + Location.TestField("Require Shipment", false); + + TransferOrderPostShipment.Run(TransferHeader); + end; + + procedure ReceiveTransferOrder(TransferOrderNo: Code[20]) + var + TransferHeader: Record "Transfer Header"; + Location: Record Location; + TransferOrderPostReceipt: Codeunit "TransferOrder-Post Receipt"; + begin + TransferHeader.Get(TransferOrderNo); + TransferHeader.TestField("Direct Transfer", false); + TransferHeader.TestField("In-Transit Code"); + Location.Get(TransferHeader."Transfer-to Code"); + Location.TestField("Require Receive", false); + + TransferOrderPostReceipt.Run(TransferHeader); + end; +} diff --git a/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.md b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.md new file mode 100644 index 0000000..b541dba --- /dev/null +++ b/microsoft/knowledge/scm/post-transfers-through-shipment-and-receipt-codeunits.md @@ -0,0 +1,41 @@ +--- +bc-version: [all] +domain: scm +keywords: [transfer-header, transfer-line, transferorder-post-shipment, transferorder-post-receipt, in-transit-code, last-shipment-no, item-application-entry] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Post transfers through shipment and receipt codeunits + +## Description + +A two-step transfer order preserves a continuous quantity, reservation, and cost/application lineage from the source through in-transit to the destination. Its shipment and receipt workflows also own posted documents and derived lines for partial receipt. Posting an item-journal movement and manually marking a transfer header or line as shipped is not equivalent, even if the total quantities balance. + +## Best Practice + +A prepared non-direct transfer order without required warehouse documents uses `"TransferOrder-Post Shipment".Run` at shipment and `"TransferOrder-Post Receipt".Run` at receipt, with the actual `"Transfer Header"`. Validated source quantities to ship/receive prepare the operation; posted quantity counters are results of posting. + +Required warehouse shipment or receipt enters the warehouse document posting workflow, which invokes the transfer poster with its real source context. Direct transfers have their configured standard workflow; the two-step sample's in-transit guard is not a universal requirement. + +Standalone item reclassification journals and bin movements are legitimate separate operations, not completion of an existing transfer order. Tracking/application splits and average-cost handling mean transfers do not have one fixed item-entry count or a nonzero `"Transferred-from Entry No."` on every application. + +See sample: [`post-transfers-through-shipment-and-receipt-codeunits.good.al`](post-transfers-through-shipment-and-receipt-codeunits.good.al). + +## Anti Pattern + +Ad-hoc item postings, independent positive/negative adjustments, manually created posted-transfer rows, and direct shipment/receipt-counter changes cannot substitute for transfer-order posting. Updating `"Last Shipment No."` after a bare item-journal call does not create the posted shipment, source-line progress, or transfer application lineage. + +Changing an existing item ledger entry's location or inventing application links does not repair that missing workflow. Metadata enrichment within the normal shipment/receipt or direct-transfer workflow is distinct from replacing the posting operation. + +See sample: [`post-transfers-through-shipment-and-receipt-codeunits.bad.al`](post-transfers-through-shipment-and-receipt-codeunits.bad.al). + +## References + +- [Transfer inventory between locations](https://learn.microsoft.com/en-us/dynamics365/business-central/inventory-how-transfer-between-locations) +- [Item application design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-application) +- [Cost adjustment design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-cost-adjustment) +- [BaseApp shipment journal/source linkage](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Transfer/TransferOrderPostShipment.Codeunit.al#L292-L336) +- [Partial-receipt derived-line handling](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Transfer/TransferOrderPostReceipt.Codeunit.al#L457-L529) +- [Transfer application and average-cost branches](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Posting/ItemJnlPostLine.Codeunit.al#L1911-L1976) diff --git a/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.bad.al b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.bad.al new file mode 100644 index 0000000..97c7cb4 --- /dev/null +++ b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.bad.al @@ -0,0 +1,37 @@ +codeunit 50110 "SCM Warehouse Adjustment Bad" +{ + procedure ReconcileRegisteredWarehouseAdjustment(ItemNo: Code[20]; LocationCode: Code[10]; TemplateName: Code[10]; BatchName: Code[10]; PostingDate: Date; DocumentNo: Code[20]): Boolean + var + Item: Record Item; + ItemJournalBatch: Record "Item Journal Batch"; + ItemJournalLine: Record "Item Journal Line"; + Location: Record Location; + CalculateWhseAdjustment: Report "Calculate Whse. Adjustment"; + begin + Location.Get(LocationCode); + Location.TestField("Directed Put-away and Pick", true); + Location.TestField("Adjustment Bin Code"); + ItemJournalBatch.Get(TemplateName, BatchName); + ItemJournalLine.SetRange("Journal Template Name", TemplateName); + ItemJournalLine.SetRange("Journal Batch Name", BatchName); + if not ItemJournalLine.IsEmpty() then + Error(EmptyBatchErr); + + Item.Get(ItemNo); + Item.SetRecFilter(); + Item.SetRange("Location Filter", LocationCode); + ItemJournalLine."Journal Template Name" := TemplateName; + ItemJournalLine."Journal Batch Name" := BatchName; + CalculateWhseAdjustment.SetItemJnlLine(ItemJournalLine); + CalculateWhseAdjustment.SetTableView(Item); + CalculateWhseAdjustment.InitializeRequest(PostingDate, DocumentNo); + CalculateWhseAdjustment.SetHideValidationDialog(true); + CalculateWhseAdjustment.UseRequestPage(false); + CalculateWhseAdjustment.RunModal(); + + exit(true); + end; + + var + EmptyBatchErr: Label 'Use an empty, dedicated item journal batch for warehouse reconciliation.'; +} diff --git a/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.good.al b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.good.al new file mode 100644 index 0000000..469ed2d --- /dev/null +++ b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.good.al @@ -0,0 +1,42 @@ +codeunit 50111 "SCM Warehouse Adjustment Good" +{ + procedure ReconcileRegisteredWarehouseAdjustment(ItemNo: Code[20]; LocationCode: Code[10]; TemplateName: Code[10]; BatchName: Code[10]; PostingDate: Date; DocumentNo: Code[20]): Boolean + var + Item: Record Item; + ItemJournalBatch: Record "Item Journal Batch"; + ItemJournalLine: Record "Item Journal Line"; + Location: Record Location; + CalculateWhseAdjustment: Report "Calculate Whse. Adjustment"; + ItemJnlPostBatch: Codeunit "Item Jnl.-Post Batch"; + begin + Location.Get(LocationCode); + Location.TestField("Directed Put-away and Pick", true); + Location.TestField("Adjustment Bin Code"); + ItemJournalBatch.Get(TemplateName, BatchName); + ItemJournalLine.SetRange("Journal Template Name", TemplateName); + ItemJournalLine.SetRange("Journal Batch Name", BatchName); + if not ItemJournalLine.IsEmpty() then + Error(EmptyBatchErr); + + Item.Get(ItemNo); + Item.SetRecFilter(); + Item.SetRange("Location Filter", LocationCode); + ItemJournalLine."Journal Template Name" := TemplateName; + ItemJournalLine."Journal Batch Name" := BatchName; + CalculateWhseAdjustment.SetItemJnlLine(ItemJournalLine); + CalculateWhseAdjustment.SetTableView(Item); + CalculateWhseAdjustment.InitializeRequest(PostingDate, DocumentNo); + CalculateWhseAdjustment.SetHideValidationDialog(true); + CalculateWhseAdjustment.UseRequestPage(false); + CalculateWhseAdjustment.RunModal(); + + if ItemJournalLine.FindFirst() then begin + ItemJournalLine.TestField("Warehouse Adjustment", true); + ItemJnlPostBatch.Run(ItemJournalLine); + end; + exit(true); + end; + + var + EmptyBatchErr: Label 'Use an empty, dedicated item journal batch for warehouse reconciliation.'; +} diff --git a/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.md b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.md new file mode 100644 index 0000000..06230e7 --- /dev/null +++ b/microsoft/knowledge/scm/reconcile-warehouse-adjustments-with-the-item-ledger.md @@ -0,0 +1,40 @@ +--- +bc-version: [all] +domain: scm +keywords: [warehouse-adjustment, calculate-whse-adjustment, adjustment-bin-code, directed-put-away-and-pick, item-journal-line, warehouse-entry] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reconcile warehouse adjustments with the item ledger + +## Description + +At a Directed Put-away and Pick location, registering an ordinary warehouse quantity or physical-inventory adjustment and synchronizing it to inventory are distinct steps. The warehouse registration balances quantity through the adjustment bin. An additional ordinary item-journal increase/decrease is not the same as consuming that pending warehouse adjustment. + +## Best Practice + +After warehouse registration, `"Calculate Whse. Adjustment"` prepares item-journal lines for the intended item/location and batch; `"Item Jnl.-Post Batch"` posts those lines. The calculation derives the reconciliation by location, variant, units of measure, and tracking, marks the lines `"Warehouse Adjustment"`, and accounts for already prepared unposted adjustments. + +Reconciliation is separate from source-document posting: warehouse receipts/shipments use their document workflows. Intentional warehouse-only staging is valid when a separately owned reconciliation step completes the process; registration need not perform both phases in one call. + +Bin movements need not change total inventory, and warehouse tracking/expiration reclassification has a standard batch path that can also post item-journal entries. Standard reclassification and basic-location item adjustments are not this ordinary advanced-warehouse quantity-adjustment case. + +The samples start after warehouse quantity registration and report whether inventory reconciliation completed. The clean sample calculates and posts into an empty dedicated batch; it is not a complete warehouse physical-count workflow. + +See sample: [`reconcile-warehouse-adjustments-with-the-item-ledger.good.al`](reconcile-warehouse-adjustments-with-the-item-ledger.good.al). + +## Anti Pattern + +A manually mirrored ordinary item-journal line does not reconcile a registered advanced-warehouse quantity adjustment. Likewise, a reconciliation function that returns completion after only registration or adjustment calculation leaves any generated adjustment lines unposted. Calculation prepares journal lines; it does not post them. + +Invented positive/negative quantities or flipping `"Warehouse Adjustment"` on an arbitrary line does not establish the required relationship to the adjustment-bin balance and tracked quantities. That relationship comes from the calculation step. + +See sample: [`reconcile-warehouse-adjustments-with-the-item-ledger.bad.al`](reconcile-warehouse-adjustments-with-the-item-ledger.bad.al). + +## References + +- [Synchronize adjusted warehouse entries with item ledger entries](https://learn.microsoft.com/en-us/dynamics365/business-central/inventory-how-count-adjust-reclassify#to-synchronize-the-adjusted-warehouse-entries-with-the-related-item-ledger-entries) +- [BaseApp warehouse-adjustment calculation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Warehouse/Journal/CalculateWhseAdjustment.Report.al#L298-L384) +- [Warehouse reclassification exception](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Warehouse/Journal/WhseJnlRegisterBatch.Codeunit.al#L196-L210) diff --git a/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.bad.al b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.bad.al new file mode 100644 index 0000000..89368cf --- /dev/null +++ b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.bad.al @@ -0,0 +1,30 @@ +codeunit 50108 "SCM Tracking Transfer Bad" +{ + procedure TransferBlanketOrderTracking(var SourceBlanketOrderLine: Record "Sales Line"; var DestinationSalesOrderLine: Record "Sales Line"; QuantityBaseToTransfer: Decimal) + var + ReservationEntry: Record "Reservation Entry"; + begin + SourceBlanketOrderLine.TestField("Document Type", SourceBlanketOrderLine."Document Type"::"Blanket Order"); + SourceBlanketOrderLine.TestField(Type, SourceBlanketOrderLine.Type::Item); + DestinationSalesOrderLine.TestField("Document Type", DestinationSalesOrderLine."Document Type"::Order); + DestinationSalesOrderLine.TestField(Type, DestinationSalesOrderLine.Type::Item); + DestinationSalesOrderLine.TestField("No.", SourceBlanketOrderLine."No."); + if QuantityBaseToTransfer <= 0 then + Error(PositiveQuantityErr); + + ReservationEntry.SetRange("Source Type", Database::"Sales Line"); + ReservationEntry.SetRange("Source Subtype", SourceBlanketOrderLine."Document Type".AsInteger()); + ReservationEntry.SetRange("Source ID", SourceBlanketOrderLine."Document No."); + ReservationEntry.SetRange("Source Ref. No.", SourceBlanketOrderLine."Line No."); + ReservationEntry.SetRange(Positive, false); + ReservationEntry.FindFirst(); + ReservationEntry."Source Subtype" := DestinationSalesOrderLine."Document Type".AsInteger(); + ReservationEntry."Source ID" := DestinationSalesOrderLine."Document No."; + ReservationEntry."Source Ref. No." := DestinationSalesOrderLine."Line No."; + ReservationEntry.Validate("Quantity (Base)", -QuantityBaseToTransfer); + ReservationEntry.Modify(true); + end; + + var + PositiveQuantityErr: Label 'The base quantity to transfer must be positive.'; +} diff --git a/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.good.al b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.good.al new file mode 100644 index 0000000..a655b34 --- /dev/null +++ b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.good.al @@ -0,0 +1,20 @@ +codeunit 50109 "SCM Tracking Transfer Good" +{ + procedure TransferBlanketOrderTracking(var SourceBlanketOrderLine: Record "Sales Line"; var DestinationSalesOrderLine: Record "Sales Line"; QuantityBaseToTransfer: Decimal) + var + SalesLineReserve: Codeunit "Sales Line-Reserve"; + begin + SourceBlanketOrderLine.TestField("Document Type", SourceBlanketOrderLine."Document Type"::"Blanket Order"); + SourceBlanketOrderLine.TestField(Type, SourceBlanketOrderLine.Type::Item); + DestinationSalesOrderLine.TestField("Document Type", DestinationSalesOrderLine."Document Type"::Order); + DestinationSalesOrderLine.TestField(Type, DestinationSalesOrderLine.Type::Item); + DestinationSalesOrderLine.TestField("No.", SourceBlanketOrderLine."No."); + if QuantityBaseToTransfer <= 0 then + Error(PositiveQuantityErr); + + SalesLineReserve.TransferSaleLineToSalesLine(SourceBlanketOrderLine, DestinationSalesOrderLine, QuantityBaseToTransfer); + end; + + var + PositiveQuantityErr: Label 'The base quantity to transfer must be positive.'; +} diff --git a/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.md b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.md new file mode 100644 index 0000000..8bc42cc --- /dev/null +++ b/microsoft/knowledge/scm/transfer-item-tracking-through-source-reservation-codeunits.md @@ -0,0 +1,41 @@ +--- +bc-version: [all] +domain: scm +keywords: [reservation-entry, tracking-specification, sales-line-reserve, transfersalelinetosalesline, transferreserventry, copyitemtracking, quantity-base] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Transfer item tracking through source reservation codeunits + +## Description + +Moving lot/serial tracking between document lines is a source-ownership operation, not a copy of visible tracking fields. Partial movement must retain the old source's remainder, destination units of measure, quantities to handle/invoice, status, sign, and any reservation counterpart. Repointing a `"Reservation Entry"` loses this coordination; inserting a `"Tracking Specification"` row alone does not book the destination's source tracking. + +## Best Practice + +Reservation codeunits provide source-specific tracking workflows. For the tracking portion of blanket-sales-order or quote conversion to a sales order, `"Sales Line-Reserve".TransferSaleLineToSalesLine` takes the existing source line, prepared destination line, and quantity to transfer in **base units**. It delegates the source/status and quantity movement to `"Create Reserv. Entry".TransferReservEntry`. + +The caller still owns document conversion and destination-line preparation; this method does not create a sales order. The source and destination must have compatible item, variant, location, and source identity. Purchases, transfers, assembly, and production have their own source-specific wrappers rather than sharing the sales conversion contract. + +`"Item Tracking Management".CopyItemTracking` serves a different purpose: it creates Prospect copies, not a transfer of reservation ownership. That is valid for its intended copy workflow. Temporary Tracking Specification processing and persisted historical tracking specifications are also normal; the working/historic representation differs from the current source booking. + +See sample: [`transfer-item-tracking-through-source-reservation-codeunits.good.al`](transfer-item-tracking-through-source-reservation-codeunits.good.al). + +## Anti Pattern + +Direct rewrites of persistent `"Reservation Entry"` source type/subtype, ID, reference number, or quantities do not perform the source-line conversion or partial tracking-transfer workflow. Changing only `"Quantity (Base)"` and source keys can drop the remainder or leave the other tracking/reservation quantities attached to the wrong source. + +A tracking copy cannot replace movement of an existing binding reservation. Legitimate Prospect copying, temporary tracking buffers, historical tracking reads, and source-specific engine calls serve distinct purposes and are not independent reservation transfers. + +See sample: [`transfer-item-tracking-through-source-reservation-codeunits.bad.al`](transfer-item-tracking-through-source-reservation-codeunits.bad.al). + +## References + +- [Item Tracking Lines window design](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-tracking-lines-window) +- [Active versus historic item-tracking entries](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-active-versus-historic-item-tracking-entries) +- [Item tracking and reservations](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-item-tracking-and-reservations) +- [BaseApp sales tracking transfer](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Document/SalesLineReserve.Codeunit.al#L532-L578) +- [Base-unit conversion caller](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Document/BlanketSalesOrdertoOrder.Codeunit.al#L195-L198) +- [Prospect-copy API](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Tracking/ItemTrackingManagement.Codeunit.al#L575-L657) diff --git a/microsoft/knowledge/scm/use-date-aware-availability-for-promising.bad.al b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.bad.al new file mode 100644 index 0000000..b90f932 --- /dev/null +++ b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.bad.al @@ -0,0 +1,21 @@ +codeunit 50114 "SCM Additional Promise Bad" +{ + procedure CanPromiseAdditionalDemand(ItemNo: Code[20]; LocationCode: Code[10]; VariantCode: Code[10]; ShipmentDate: Date; RequestedAdditionalQuantityBase: Decimal; LookaheadDateFormula: DateFormula): Boolean + var + Item: Record Item; + begin + if (ShipmentDate = 0D) or (RequestedAdditionalQuantityBase <= 0) then + Error(DemandInputErr); + Item.Get(ItemNo); + Item.TestField(Type, Item.Type::Inventory); + Item.SetRange("Location Filter", LocationCode); + Item.SetRange("Variant Filter", VariantCode); + Item.SetRange("Date Filter", 0D, ShipmentDate); + + Item.CalcFields(Inventory); + exit(Item.Inventory >= RequestedAdditionalQuantityBase); + end; + + var + DemandInputErr: Label 'Enter a shipment date and a positive additional base quantity.'; +} diff --git a/microsoft/knowledge/scm/use-date-aware-availability-for-promising.good.al b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.good.al new file mode 100644 index 0000000..b33ee79 --- /dev/null +++ b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.good.al @@ -0,0 +1,27 @@ +codeunit 50115 "SCM Additional Promise Good" +{ + procedure CanPromiseAdditionalDemand(ItemNo: Code[20]; LocationCode: Code[10]; VariantCode: Code[10]; ShipmentDate: Date; RequestedAdditionalQuantityBase: Decimal; LookaheadDateFormula: DateFormula): Boolean + var + Item: Record Item; + AvailableToPromise: Codeunit "Available to Promise"; + GrossRequirement: Decimal; + ScheduledReceipt: Decimal; + PromisableQuantityBase: Decimal; + begin + if (ShipmentDate = 0D) or (RequestedAdditionalQuantityBase <= 0) then + Error(DemandInputErr); + Item.Get(ItemNo); + Item.TestField(Type, Item.Type::Inventory); + Item.SetRange("Location Filter", LocationCode); + Item.SetRange("Variant Filter", VariantCode); + Item.SetRange("Date Filter", 0D, ShipmentDate); + + PromisableQuantityBase := AvailableToPromise.CalcQtyAvailableToPromise( + Item, GrossRequirement, ScheduledReceipt, ShipmentDate, + Enum::"Analysis Period Type"::Day, LookaheadDateFormula); + exit(PromisableQuantityBase >= RequestedAdditionalQuantityBase); + end; + + var + DemandInputErr: Label 'Enter a shipment date and a positive additional base quantity.'; +} diff --git a/microsoft/knowledge/scm/use-date-aware-availability-for-promising.md b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.md new file mode 100644 index 0000000..a182a94 --- /dev/null +++ b/microsoft/knowledge/scm/use-date-aware-availability-for-promising.md @@ -0,0 +1,39 @@ +--- +bc-version: [all] +domain: scm +keywords: [available-to-promise, calcqtyavailabletopromise, inventory, shipment-date, gross-requirement, scheduled-receipt, location-filter, variant-filter] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use date-aware availability for promising + +## Description + +`Item.Inventory` is an on-hand quantity, not an available-to-promise answer. Promising additional demand must account for the requested date, location and variant, reservations, scheduled receipts, existing requirements, and demand within the configured lookahead. An on-hand comparison can promise inventory already committed elsewhere and miss incoming supply. + +## Best Practice + +For additional demand not already recorded on a source line, `"Available to Promise".CalcQtyAvailableToPromise` uses the Item's location/variant filters, date range ending on the shipment date, and configured period/lookahead horizon. Its result and the additional quantity are compared in base units. A fresh calculation context or the codeunit's recalculation support avoids carrying cached quantities between unrelated items or requests. + +The source-aware order-promising/availability workflow accounts for an existing sales line's own quantity or delta; applying an additional-demand calculation to that line can double-count it. Assembly and production requirements/supply likewise participate in the standard availability context, not just a sales-only stock subtraction. + +An ATP result is not a reservation or a guarantee of warehouse pickability. Lot/serial constraints, bins, warehouse activity, and later concurrent changes still need their own checks. Conversely, an on-hand display, valuation report, or deliberately immediate-stock-only check can use `Item.Inventory`: it answers a different business question from ATP. + +See sample: [`use-date-aware-availability-for-promising.good.al`](use-date-aware-availability-for-promising.good.al). + +## Anti Pattern + +`CalcFields(Inventory)` or an equivalent item-ledger quantity sum used as the complete decision for a dated additional-demand promise ignores existing demand and incoming supply, even with location/variant filters. An Inventory FlowField read for an on-hand display has no such promising contract. + +Losing location, variant, date, or source-line context changes the calculation's business meaning. Another supported workflow that preserves the same availability semantics does not have to call this exact API. + +See sample: [`use-date-aware-availability-for-promising.bad.al`](use-date-aware-availability-for-promising.bad.al). + +## References + +- [Calculate order-promising dates](https://learn.microsoft.com/en-us/dynamics365/business-central/sales-how-to-calculate-order-promising-dates) +- [Availability in the warehouse](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-availability-in-the-warehouse) +- [BaseApp ATP calculation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Availability/AvailabletoPromise.Codeunit.al#L52-L184) +- [Forward-demand lookahead](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Inventory/Availability/AvailabletoPromise.Codeunit.al#L295-L356) diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index dc1f1db..7cfe07b 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -28,6 +28,7 @@ sub-skills: - microsoft/skills/review/al-reporting-review.md - microsoft/skills/review/al-appsource-review.md - microsoft/skills/review/al-telemetry-review.md + - microsoft/skills/review/al-scm-review.md --- # AL code review diff --git a/microsoft/skills/review/al-scm-review.md b/microsoft/skills/review/al-scm-review.md new file mode 100644 index 0000000..a434529 --- /dev/null +++ b/microsoft/skills/review/al-scm-review.md @@ -0,0 +1,121 @@ +--- +kind: action-skill +id: al-scm-review +version: 1 +title: AL Supply Chain Management review +description: Reviews SCM inventory costing, item application, reservations, order tracking, item tracking, warehouse, transfer, and planning workflows in AL. +inputs: [pr-diff, file-path, folder-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL Supply Chain Management review + +Reviews AL source against the `scm` knowledge domain. This leaf invokes no +sub-skills and is composed by `al-code-review`. For a folder, inspect every +relevant AL file; a folder supplies no historical baseline. + +## Source + +Apply Relevance's source gate before retrieval. For a relevant scope, use READ's +**Bounded retrieval for review skills** with `-Domain scm` and +`-Technologies @('al')`. Consume every catalog page across enabled layers, +preserving exact paths and applicability. Select from metadata, then read only +worklisted complete articles. Entry owns index preparation; the leaf does not +rebuild. Unavailable/invalid helpers or indexes use READ's bounded native-read +fallback, never a success-shaped empty result. + +## Relevance + +Resolve changed record/codeunit types, source tables, publishers and calls. +Gate on code that mutates or posts inventory, application, reservation, +tracking, warehouse, transfer or planning state, or makes a supply/demand +availability decision. Stock displays and other read-only queries without +that decision do not pass the gate. Names, comments, captions, an `Item` +reference or a broad `ApplicationArea` alone are not signals. +If no SCM surface remains, return `not-applicable` with zero coverage +and no article-body retrieval; in mixed diffs, retain only relevant procedures +and their visible supporting context. + +SCM owns `"Item Ledger Entry"`, `"Value Entry"`, `"Capacity Ledger Entry"`, +`"Warehouse Entry"` and inventory posting/application records. Pure `"G/L Entry"`, +`"Cust. Ledger Entry"`, `"Vendor Ledger Entry"`, `"Detailed Cust. Ledg. Entry"`, +`"Detailed Vendor Ledg. Entry"`, `"VAT Entry"` and financial-only posting +mutations belong to Finance. They remain outside SCM even if Finance is absent +or disabled; do not reclaim them as SCM agent findings. Ownership is not a +claim that every owned surface already has a dedicated article. + +Apply READ's frontmatter filters using the target BC major version from +application dependency/host context (not the extension version), AL, known +localization and actual task/object application areas. Omitted context stays +unknown, not `[all]`; unknown areas alone do not exclude codeunits/subscribers. +Retain conditional articles only when configured, cap their findings at +`medium`, and name every unknown dimension. + +## Worklist + +Extract resolved object/type names, quoted fields, methods, enum members and +publishers. Normalize these and catalog keywords by lowercasing invariantly, +replacing punctuation/whitespace runs with one hyphen and trimming hyphens: +`"Item Ledger Entry"` becomes `item-ledger-entry`; `RunWithCheck` becomes +`runwithcheck`. Match whole tokens/phrases, not identifier substrings. + +Select matching keywords or catalog topics only for the same source surface +**and operation**. The following cues resolve slugs to actual enabled catalog +paths; they select articles, not findings. Facts and exceptions stay in articles. + +| Changed source surface and operation | Article slug | +| --- | --- | +| `"Item Ledger Entry"`/`"Value Entry"` transaction writes, or a standalone item-journal quantity/value posting entry point | `post-item-ledger-changes-through-item-journals` | +| Revaluation `"Item Journal Line"` with `"Inventory Value Per"` or `"Partial Revaluation"`, and its line/batch posting calls | `post-revaluation-through-the-item-journal-batch` | +| `"Item Application Entry"` relationship/quantity mutation, or `UnApply`, `ReApply`, `RedoApplications`, `CostAdjust` in an application-correction flow | `change-item-applications-through-posting-routines` | +| Binding-reservation cancellation: `"Reservation Entry"` status, delete/quantity/source edits, `CancelReservation`, or source reservation-lifecycle calls | `cancel-reservations-through-reservation-management` | +| Tracking source conversion/partial movement: `"Sales Line-Reserve"`, `TransferSaleLineToSalesLine`, `TransferReservEntry`, `CopyItemTracking`, or `"Reservation Entry"`/`"Tracking Specification"` source/quantity writes | `transfer-item-tracking-through-source-reservation-codeunits` | +| 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` | +| `"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. +Resolve actual normative conflicts per READ, preserving additive layers and +recording `layer-precedence`/`configuration` suppressions, not noncandidates. +Retrieve exact paths in ordinal chunks of at most eight, consume every +continuation, and never impose a top-eight cutoff. Samples use exact READ links. + +## Action + +Evaluate every opened article's normative facts, scope and exclusions against +visible persistence, caller contract, document state and operation. Emit only +concrete violations with business consequences and supported remediation; a +declaration, valid alternative or unseen caller is not evidence of a defect. + +- Use `major` for material SCM defects, `minor` for narrower best-practice + conflicts, and `blocker` only for an article-established platform guarantee. + Applicability alone produces no finding. High confidence requires unambiguous + evidence and known applicability; inference/conditional applicability caps it + at `medium`. +- Apply DO's single-owner deduplication. Equivalent findings for the same + inventory-originated posting bypass and correction have one SCM primary + owner, even when financial records are downstream. Prefer the most specific + SCM article and retain other applicable references as supporting evidence. + Distinct independent financial defects remain Finance; do not duplicate them. +- Agent findings stay strictly SCM-scoped under DO's precision bar, with + `references: []`, an `agent:` id and `minor`/`medium` ceilings. Generic AL and + other domains' concerns remain outside this leaf. +- Supply literal `suggested-code` only for a complete, local, unambiguous fix, + not a sample call that omits workflow setup/source identity. Explain omitted + mechanical-looking fixes with `suggested-code-omission-reason`. + +Outcome selection follows DO, including accurate coverage and reasons for +`partial`/`failed`. No surviving applicable corpus is `no-knowledge`; an existing +corpus with no matching operation is `completed` with an empty worklist. + +## Output + +Output conforms to the DO findings-report contract and shared schema. Every +finding MUST set `domain` to `"Supply Chain Management"`. Knowledge-backed ids +equal the primary opened article's exact catalog path. The coordinator, not +this leaf, sets `from-sub-skill`. From 07e324ddbc42597c479e041e06a7833740e05d0f Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Mon, 21 Sep 2026 10:20:41 +0200 Subject: [PATCH 29/51] Add source-verified Finance knowledge and review domain (#57) * Add Finance posting domain-knowledge pilot Adds three atomic domain-rule knowledge files under community/knowledge/finance/ as a pilot for Type-A (normative) Business Central domain knowledge: post through the posting engine, treat posted ledger entries as immutable, and treat the Dimension Set ID as the source of truth for dimensions. Includes a good/bad AL sample pair for the posting rule. These encode BC-specific invariants that LLMs reliably get wrong, fitting the existing remedial/atomic knowledge grain with no schema or contract changes. Passes the repo frontmatter validator and is discovered by the knowledge index. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify editable operational fields on posted ledger entries Addresses review feedback from @JeremyVyska on PR #57: the immutability rule applies to financial content, not the whole entry. Reframes the Description around financial content and gives the operational-field exception (payment/application data, on-hold, applies-to, communication fields edited via CustEntry-Edit/VendEntry-Edit and the ledger entry pages) its own paragraph in Best Practice instead of understating it as a narrow set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Expand Finance pilot into a source-verified review domain Move Finance knowledge to the Microsoft-owned layer, add nine scoped rules with eighteen AL samples, and register bounded Finance review with complete paired evaluation coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix Finance review applicability and ownership boundaries Remove application-area gating and later VAT-field dependencies, align dynamic shared conventions, separate SCM ownership, and keep journal examples focused on the intended invariant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove logo branding (#194) Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add title and description to README * Add foundational AL developer knowledge (#195) Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Clarify locale-safe DateFormula Evaluate inputs (#193) * Clarify locale-safe DateFormula Evaluate inputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Normalize DateFormula article sections Keep the analyzer-gap explanation in Description and its scoped probe evidence in References, without a novel Validation section. Normative guidance and fixtures are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add SCM functional knowledge domain (#192) * Add SCM functional knowledge domain Introduce nine source-backed rules with original AL sample pairs, bounded SCM review routing, and complete positive/clean evaluation coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Normalize SCM knowledge and review ownership Align article and AL sample conventions, keep BC facts separate from review mechanics, and clarify reciprocal Finance ownership without bespoke shared test assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Test-SkillIndex.ps1 | 3 +- docs/using-bcquality.md | 11 ++ evaluation/README.md | 12 ++ evaluation/review-fixtures.json | 14 ++ ...tries-through-application-codeunits.bad.al | 33 ++++ ...ries-through-application-codeunits.good.al | 31 ++++ ...r-entries-through-application-codeunits.md | 37 +++++ ...ledger-due-dates-through-entry-edit.bad.al | 21 +++ ...edger-due-dates-through-entry-edit.good.al | 21 +++ ...nge-ledger-due-dates-through-entry-edit.md | 35 +++++ .../do-not-edit-shared-dimension-sets.bad.al | 14 ++ .../do-not-edit-shared-dimension-sets.good.al | 18 +++ .../do-not-edit-shared-dimension-sets.md | 35 +++++ ...ify-or-delete-posted-ledger-entries.bad.al | 32 ++++ ...fy-or-delete-posted-ledger-entries.good.al | 32 ++++ ...-modify-or-delete-posted-ledger-entries.md | 37 +++++ ...mal-vat-journal-amount-includes-vat.bad.al | 54 +++++++ ...al-vat-journal-amount-includes-vat.good.al | 54 +++++++ .../normal-vat-journal-amount-includes-vat.md | 37 +++++ ...r-entries-through-posting-codeunits.bad.al | 60 +++++++ ...-entries-through-posting-codeunits.good.al | 29 ++++ ...edger-entries-through-posting-codeunits.md | 38 +++++ ...erve-journal-batch-document-balance.bad.al | 53 +++++++ ...rve-journal-batch-document-balance.good.al | 49 ++++++ ...preserve-journal-batch-document-balance.md | 35 +++++ ...-transactions-by-transaction-number.bad.al | 18 +++ ...transactions-by-transaction-number.good.al | 18 +++ ...erse-transactions-by-transaction-number.md | 34 ++++ ...dimensions-as-dimension-set-entries.bad.al | 20 +++ ...imensions-as-dimension-set-entries.good.al | 19 +++ ...ite-dimensions-as-dimension-set-entries.md | 34 ++++ microsoft/skills/review/al-code-review.md | 1 + microsoft/skills/review/al-finance-review.md | 146 ++++++++++++++++++ 33 files changed, 1084 insertions(+), 1 deletion(-) create mode 100644 microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.bad.al create mode 100644 microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.good.al create mode 100644 microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.md create mode 100644 microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.bad.al create mode 100644 microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.good.al create mode 100644 microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.md create mode 100644 microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.bad.al create mode 100644 microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.good.al create mode 100644 microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.md create mode 100644 microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.bad.al create mode 100644 microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.good.al create mode 100644 microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.md create mode 100644 microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.bad.al create mode 100644 microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.good.al create mode 100644 microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.md create mode 100644 microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.bad.al create mode 100644 microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.good.al create mode 100644 microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.md create mode 100644 microsoft/knowledge/finance/preserve-journal-batch-document-balance.bad.al create mode 100644 microsoft/knowledge/finance/preserve-journal-batch-document-balance.good.al create mode 100644 microsoft/knowledge/finance/preserve-journal-batch-document-balance.md create mode 100644 microsoft/knowledge/finance/reverse-transactions-by-transaction-number.bad.al create mode 100644 microsoft/knowledge/finance/reverse-transactions-by-transaction-number.good.al create mode 100644 microsoft/knowledge/finance/reverse-transactions-by-transaction-number.md create mode 100644 microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.bad.al create mode 100644 microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.good.al create mode 100644 microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.md create mode 100644 microsoft/skills/review/al-finance-review.md diff --git a/.github/scripts/Test-SkillIndex.ps1 b/.github/scripts/Test-SkillIndex.ps1 index 5354370..b694a5b 100644 --- a/.github/scripts/Test-SkillIndex.ps1 +++ b/.github/scripts/Test-SkillIndex.ps1 @@ -94,7 +94,8 @@ try { 'microsoft/skills/review/al-reporting-review.md', 'microsoft/skills/review/al-appsource-review.md', 'microsoft/skills/review/al-telemetry-review.md', - 'microsoft/skills/review/al-scm-review.md' + 'microsoft/skills/review/al-scm-review.md', + 'microsoft/skills/review/al-finance-review.md' ) $review = @($skills | Where-Object id -eq 'al-code-review') if ($review.Count -ne 1) { diff --git a/docs/using-bcquality.md b/docs/using-bcquality.md index 2af08be..b65fcb0 100644 --- a/docs/using-bcquality.md +++ b/docs/using-bcquality.md @@ -211,6 +211,16 @@ mutations belong to Finance, even when that domain is not enabled. Equivalent findings for one inventory-originated posting bypass have one SCM primary owner; distinct independent financial defects remain separate. +The Finance leaf reviews journal posting, financial ledger changes, +applications, and posting-linked dimension handling. It prunes unrelated code +at the leaf rather than changing broad-review orchestration. Finance articles +use `application-area: [all]` so missing application-area context does not +weaken applicable findings; resolved records and operations supply the +narrowing. Finance owns financial ledgers, not Item, Value, Capacity, Warehouse, +or inventory-application records owned by SCM. It also does not own generic +custom-table or master Default Dimension wiring. Request a focused "Finance +posting review" when only this domain is needed. + BCQuality intentionally does not duplicate mechanical diagnostics already enforced by the AL compiler or standard analyzers. Run the consuming app's normal compiler and analyzer pipeline alongside review and authoring. Knowledge @@ -230,6 +240,7 @@ Each article describes one concern. Where samples exist, use its linked | Data modeling | [Data modeling](../microsoft/knowledge/data-modeling/) | | Error handling | [Error handling](../microsoft/knowledge/error-handling/) | | Events | [Events](../microsoft/knowledge/events/) | +| Finance | [Finance](../microsoft/knowledge/finance/) | | Interfaces | [Interfaces](../microsoft/knowledge/interfaces/) | | Performance | [Performance](../microsoft/knowledge/performance/) | | Privacy | [Privacy](../microsoft/knowledge/privacy/) | diff --git a/evaluation/README.md b/evaluation/README.md index 05b5e52..bce3208 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -16,6 +16,18 @@ neutralization. Do not move those preconditions into comments or generic alternative workflow. The clean pairs exercise the supported APIs selected by the same routing cues, not merely unrelated code that contains no SCM tokens. +The Finance override deliberately covers every paired Finance article, not +only the first filename. Its shared context supplies the target version and +localization but deliberately omits application area, as production callers +often do. Finance applicability must come from its source-surface gate, not +an artificial evaluation-only area hint. Scenario prerequisites live in +executable AL: the document-balance cases check the template setting, and the +VAT cases encode the imported net/VAT/gross totals and applicable VAT mode. +Do not move these prerequisites into comments that preparation removes. +Clean samples also retain supported operational edits, temporary ledger/set +buffers, legitimate entry-number APIs, and reads of individual shortcut +dimensions so these exceptions are exercised rather than blanket-excluded. + ## Validate the corpus ```powershell diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 2d03940..3f131a5 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -13,6 +13,20 @@ "events": { "article": "reset-ishandled-only-when-the-value-can-carry-over" }, + "finance": { + "articles": [ + "apply-ledger-entries-through-application-codeunits", + "change-ledger-due-dates-through-entry-edit", + "do-not-edit-shared-dimension-sets", + "do-not-modify-or-delete-posted-ledger-entries", + "normal-vat-journal-amount-includes-vat", + "post-ledger-entries-through-posting-codeunits", + "preserve-journal-batch-document-balance", + "reverse-transactions-by-transaction-number", + "write-dimensions-as-dimension-set-entries" + ], + "context": "Target Business Central 28, worldwide standard application (w1). Review each file as an independent source input." + }, "interfaces": { "article": "set-defaultimplementation-on-enum" }, diff --git a/microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.bad.al b/microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.bad.al new file mode 100644 index 0000000..1e605d3 --- /dev/null +++ b/microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.bad.al @@ -0,0 +1,33 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50104 "Customer Settlement Actions" +{ + procedure RequestApplication(CustomerEntryNo: Integer) + var + CustomerEntry: Record "Cust. Ledger Entry"; + begin + RequireInteractiveSession(); + CustomerEntry.Get(CustomerEntryNo); + CustomerEntry.TestField(Open, true); + CustomerEntry.Open := false; + CustomerEntry.Modify(true); + end; + + procedure RequestUnapplication(CustomerEntryNo: Integer) + var + DetailedCustomerEntry: Record "Detailed Cust. Ledg. Entry"; + begin + RequireInteractiveSession(); + DetailedCustomerEntry.SetRange("Cust. Ledger Entry No.", CustomerEntryNo); + DetailedCustomerEntry.SetRange("Entry Type", DetailedCustomerEntry."Entry Type"::Application); + DetailedCustomerEntry.ModifyAll(Unapplied, true); + end; + + local procedure RequireInteractiveSession() + begin + if not GuiAllowed() then + Error(InteractiveSessionErr); + end; + + var + InteractiveSessionErr: Label 'Request settlement from an interactive session.'; +} diff --git a/microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.good.al b/microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.good.al new file mode 100644 index 0000000..b33e2d2 --- /dev/null +++ b/microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.good.al @@ -0,0 +1,31 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50104 "Customer Settlement Actions" +{ + procedure RequestApplication(CustomerEntryNo: Integer) + var + CustomerEntry: Record "Cust. Ledger Entry"; + CustomerApplication: Codeunit "CustEntry-Apply Posted Entries"; + begin + RequireInteractiveSession(); + CustomerEntry.Get(CustomerEntryNo); + CustomerEntry.TestField(Open, true); + CustomerApplication.ApplyCustEntryFormEntry(CustomerEntry); + end; + + procedure RequestUnapplication(CustomerEntryNo: Integer) + var + CustomerApplication: Codeunit "CustEntry-Apply Posted Entries"; + begin + RequireInteractiveSession(); + CustomerApplication.UnApplyCustLedgEntry(CustomerEntryNo); + end; + + local procedure RequireInteractiveSession() + begin + if not GuiAllowed() then + Error(InteractiveSessionErr); + end; + + var + InteractiveSessionErr: Label 'Request settlement from an interactive session.'; +} diff --git a/microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.md b/microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.md new file mode 100644 index 0000000..11f78cd --- /dev/null +++ b/microsoft/knowledge/finance/apply-ledger-entries-through-application-codeunits.md @@ -0,0 +1,37 @@ +--- +bc-version: [all] +domain: finance +keywords: [cust-ledger-entry, vendor-ledger-entry, detailed-ledger-entry, remaining-amount, application, unapplication, open, closed-by-entry-no] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply and unapply entries through the application workflow, not status flags + +## Description + +Customer/vendor settlement is a posting operation involving detailed ledger entries, not just a change to `Open` on the main entry. Remaining amounts are calculated from detailed entries. Unapplication posts correcting entries and handles application-derived effects such as discounts and currency gains/losses; deleting details or changing `Unapplied` cannot reproduce that history. + +## Best Practice + +Use `"CustEntry-Apply Posted Entries"` / `"VendEntry-Apply Posted Entries"` and the supported application or unapplication workflow. Let it check application dates, entry state, and application ordering. The `ApplyCustEntryFormEntry` / `ApplyVendEntryFormEntry` and `UnApply...LedgEntry` methods are **interactive**: users select and confirm the operation. They are not unattended "mark paid" APIs. + +For programmatic posting, use the target version's public `Apply` / `PostUnApply...` APIs with properly prepared selection and `Apply Unapply Parameters`; handle cancellation and the workflow's transaction/commit behavior. `"Applying Entry"`, `"Applies-to ID"`, and `"Amount to Apply"` are legitimate application-preparation fields. Do not report their writes alone, temporary buffers, supported posting/compression internals, or unrelated operational/extension fields. + +See sample: [`apply-ledger-entries-through-application-codeunits.good.al`](apply-ledger-entries-through-application-codeunits.good.al). + +## Anti Pattern + +Implement customer/vendor payment matching, settlement, or reopening by directly persisting `Open`, `"Closed by Entry No."`, closure amounts/dates, or detailed-entry unapplication flags, or by deleting/rewriting detailed application amounts. Require confirmed writes to existing non-temporary customer/vendor or detailed customer/vendor entries and settlement intent. Item/inventory application records belong to SCM, not this rule. Do not suggest assigning a `Remaining Amount` FlowField as a fix. + +This article owns fabricated application state. Use the [posted-financial-content rule](do-not-modify-or-delete-posted-ledger-entries.md) for original accounting-value corrections, not a second finding prescribing the same application fix. + +See sample: [`apply-ledger-entries-through-application-codeunits.bad.al`](apply-ledger-entries-through-application-codeunits.bad.al). + +## References + +- [Apply and unapply customer transactions](https://learn.microsoft.com/en-us/dynamics365/business-central/receivables-how-apply-sales-transactions-manually). +- [Apply and unapply vendor transactions](https://learn.microsoft.com/en-us/dynamics365/business-central/payables-how-apply-purchase-transactions-manually). +- [BCApps: customer application workflow](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Receivables/CustEntryApplyPostedEntries.Codeunit.al). +- [BCApps: vendor application workflow](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Purchases/Payables/VendEntryApplyPostedEntries.Codeunit.al). diff --git a/microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.bad.al b/microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.bad.al new file mode 100644 index 0000000..7a7bec6 --- /dev/null +++ b/microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.bad.al @@ -0,0 +1,21 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50105 "Update Ledger Due Dates" +{ + procedure UpdateCustomerDueDate(EntryNo: Integer; NewDueDate: Date) + var + CustomerEntry: Record "Cust. Ledger Entry"; + begin + CustomerEntry.Get(EntryNo); + CustomerEntry.Validate("Due Date", NewDueDate); + CustomerEntry.Modify(true); + end; + + procedure UpdateVendorDueDate(EntryNo: Integer; NewDueDate: Date) + var + VendorEntry: Record "Vendor Ledger Entry"; + begin + VendorEntry.Get(EntryNo); + VendorEntry.Validate("Due Date", NewDueDate); + VendorEntry.Modify(true); + end; +} diff --git a/microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.good.al b/microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.good.al new file mode 100644 index 0000000..540548e --- /dev/null +++ b/microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.good.al @@ -0,0 +1,21 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50105 "Update Ledger Due Dates" +{ + procedure UpdateCustomerDueDate(EntryNo: Integer; NewDueDate: Date) + var + CustomerEntry: Record "Cust. Ledger Entry"; + begin + CustomerEntry.Get(EntryNo); + CustomerEntry.Validate("Due Date", NewDueDate); + Codeunit.Run(Codeunit::"Cust. Entry-Edit", CustomerEntry); + end; + + procedure UpdateVendorDueDate(EntryNo: Integer; NewDueDate: Date) + var + VendorEntry: Record "Vendor Ledger Entry"; + begin + VendorEntry.Get(EntryNo); + VendorEntry.Validate("Due Date", NewDueDate); + Codeunit.Run(Codeunit::"Vend. Entry-Edit", VendorEntry); + end; +} diff --git a/microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.md b/microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.md new file mode 100644 index 0000000..aca7b76 --- /dev/null +++ b/microsoft/knowledge/finance/change-ledger-due-dates-through-entry-edit.md @@ -0,0 +1,35 @@ +--- +bc-version: [all] +domain: finance +keywords: [due-date, initial-entry-due-date, cust-entry-edit, vend-entry-edit, detailed-ledger-entry, aging] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Change posted customer/vendor due dates through the entry-edit workflow + +## Description + +A posted customer or vendor due date is supported editable operational data, but it is also represented by `Initial Entry Due Date` on related detailed ledger entries. Validating `Due Date` and calling `Modify(true)` on the main entry does not perform all synchronization done by `"Cust. Entry-Edit"` or `"Vend. Entry-Edit"`. The main entry and due-date-based analysis can otherwise disagree. + +## Best Practice + +Fetch the existing entry, validate the proposed `Due Date`, and pass the changed record to the corresponding entry-edit codeunit, as the standard ledger pages do. Field validation enforces entry-state rules; the editor persists the supported change and synchronizes related detailed entries. Preserve both steps rather than treating table triggers as equivalent to the edit workflow. + +This is a due-date synchronization rule, not a prohibition on all operational edits after posting. Exclude temporary buffers, extension-only fields, supported editor internals, and code that demonstrably performs the equivalent synchronization under the supported workflow. Check the actual table/routine instead of assuming every `*Entry-Edit` accepts the same fields. + +See sample: [`change-ledger-due-dates-through-entry-edit.good.al`](change-ledger-due-dates-through-entry-edit.good.al). + +## Anti Pattern + +Change `Due Date` on an existing non-temporary `Cust. Ledger Entry` or `Vendor Ledger Entry` and persist it with `Modify`, `Modify(true)`, or `ModifyAll` without the edit workflow or equivalent related-entry update. A preceding `Validate("Due Date", ...)` is not sufficient evidence of synchronization. + +See sample: [`change-ledger-due-dates-through-entry-edit.bad.al`](change-ledger-due-dates-through-entry-edit.bad.al). + +## References + +- [Cust. Entry-Edit API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.sales.receivables.cust.-entry-edit). +- [Vend. Entry-Edit API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.purchases.payables.vend.-entry-edit). +- [BCApps: customer due-date synchronization](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Receivables/CustEntryEdit.Codeunit.al). +- [BCApps: vendor due-date synchronization](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Purchases/Payables/VendEntryEdit.Codeunit.al). diff --git a/microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.bad.al b/microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.bad.al new file mode 100644 index 0000000..2c5761c --- /dev/null +++ b/microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.bad.al @@ -0,0 +1,14 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50103 "Change Journal Dimension" +{ + procedure ChangeExistingDimension(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; DimensionCode: Code[20]; NewValue: Code[20]) + var + JournalLine: Record "Gen. Journal Line"; + DimensionSetEntry: Record "Dimension Set Entry"; + begin + JournalLine.Get(TemplateName, BatchName, LineNo); + DimensionSetEntry.Get(JournalLine."Dimension Set ID", DimensionCode); + DimensionSetEntry.Validate("Dimension Value Code", NewValue); + DimensionSetEntry.Modify(); + end; +} diff --git a/microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.good.al b/microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.good.al new file mode 100644 index 0000000..72d09b3 --- /dev/null +++ b/microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.good.al @@ -0,0 +1,18 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50103 "Change Journal Dimension" +{ + procedure ChangeExistingDimension(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; DimensionCode: Code[20]; NewValue: Code[20]) + var + JournalLine: Record "Gen. Journal Line"; + TempDimensionSetEntry: Record "Dimension Set Entry" temporary; + DimensionManagement: Codeunit DimensionManagement; + begin + JournalLine.Get(TemplateName, BatchName, LineNo); + DimensionManagement.GetDimensionSet(TempDimensionSetEntry, JournalLine."Dimension Set ID"); + TempDimensionSetEntry.Get(JournalLine."Dimension Set ID", DimensionCode); + TempDimensionSetEntry.Validate("Dimension Value Code", NewValue); + TempDimensionSetEntry.Modify(); + JournalLine.Validate("Dimension Set ID", DimensionManagement.GetDimensionSetID(TempDimensionSetEntry)); + JournalLine.Modify(true); + end; +} diff --git a/microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.md b/microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.md new file mode 100644 index 0000000..97b29f2 --- /dev/null +++ b/microsoft/knowledge/finance/do-not-edit-shared-dimension-sets.md @@ -0,0 +1,35 @@ +--- +bc-version: [all] +domain: finance +keywords: [dimension-set-entry, dimension-value-id, getdimensionset, getdimensionsetid, posted-dimensions, temporary] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Change a transaction's dimension set reference, not a shared set's membership + +## Description + +The same `Dimension Set ID` can be referenced by an unposted journal line and by many already-posted entries. Editing or deleting the persisted set's dimension/value rows therefore changes the meaning of unrelated transactions, including posting history. The dimension-set search tree also relies on those combinations remaining stable; a set is not a mutable child collection owned by one journal line. + +## Best Practice + +To change an unposted transaction's dimensions, load its set into a **temporary** `Dimension Set Entry` buffer with `DimensionManagement.GetDimensionSet`, change the buffer, and obtain a reusable ID with `GetDimensionSetID`. Validate dimension values in the buffer so `Dimension Value ID` matches the chosen value. Store the resulting ID on the transaction and synchronize its projections through that record's supported dimension validation. + +For already-posted G/L dimensions, use the supported dimension-correction workflow rather than changing shared rows. Read-only access, temporary buffers, and standard maintenance of projection metadata such as `Global Dimension No.` are not membership changes. This rule protects dimension sets reached from general-journal, financial-document, or Finance-ledger flows. It does not own Item, Value, Capacity, Warehouse, or inventory-application record writes, or prescribe custom-table/default-dimension wiring. + +See sample: [`do-not-edit-shared-dimension-sets.good.al`](do-not-edit-shared-dimension-sets.good.al). + +## Anti Pattern + +Follow a general-journal, financial-document, or Finance-ledger `Dimension Set ID` to a **persistent** `Dimension Set Entry` and modify, rename, or delete its dimension/value membership in order to change that one transaction. Inspect `IsTemporary` guards, aliases, and the fields written before reporting: the same operations on a temporary working copy are expected. + +See sample: [`do-not-edit-shared-dimension-sets.bad.al`](do-not-edit-shared-dimension-sets.bad.al). + +## References + +- [Dimension set entries overview](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-dimension-set-entries-overview). +- [Supported G/L dimension correction](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-troubleshooting-correcting-dimensions). +- [DimensionManagement API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.dimension.dimensionmanagement). +- [BCApps: Dimension Set Entry and its set-ID resolver](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/Dimension/DimensionSetEntry.Table.al). diff --git a/microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.bad.al b/microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.bad.al new file mode 100644 index 0000000..7160785 --- /dev/null +++ b/microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.bad.al @@ -0,0 +1,32 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50101 "Correct Posted Transaction" +{ + procedure RequestTransactionReversal(EntryNo: Integer) + var + GLEntry: Record "G/L Entry"; + begin + if not GuiAllowed() then + Error(InteractiveSessionErr); + GLEntry.Get(EntryNo); + GLEntry.TestField("Transaction No."); + GLEntry.Amount := -GLEntry.Amount; + GLEntry.Modify(true); + end; + + procedure UpdateDescription(EntryNo: Integer; NewDescription: Text[100]) + var + GLEntry: Record "G/L Entry"; + begin + GLEntry.Get(EntryNo); + GLEntry.Description := NewDescription; + Codeunit.Run(Codeunit::"G/L Entry-Edit", GLEntry); + end; + + procedure ClearSimulation(var TempGLEntry: Record "G/L Entry" temporary) + begin + TempGLEntry.DeleteAll(); + end; + + var + InteractiveSessionErr: Label 'Request the reversal from an interactive session.'; +} diff --git a/microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.good.al b/microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.good.al new file mode 100644 index 0000000..1aa2c8f --- /dev/null +++ b/microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.good.al @@ -0,0 +1,32 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50101 "Correct Posted Transaction" +{ + procedure RequestTransactionReversal(EntryNo: Integer) + var + GLEntry: Record "G/L Entry"; + ReversalEntry: Record "Reversal Entry"; + begin + if not GuiAllowed() then + Error(InteractiveSessionErr); + GLEntry.Get(EntryNo); + GLEntry.TestField("Transaction No."); + ReversalEntry.ReverseTransaction(GLEntry."Transaction No."); + end; + + procedure UpdateDescription(EntryNo: Integer; NewDescription: Text[100]) + var + GLEntry: Record "G/L Entry"; + begin + GLEntry.Get(EntryNo); + GLEntry.Description := NewDescription; + Codeunit.Run(Codeunit::"G/L Entry-Edit", GLEntry); + end; + + procedure ClearSimulation(var TempGLEntry: Record "G/L Entry" temporary) + begin + TempGLEntry.DeleteAll(); + end; + + var + InteractiveSessionErr: Label 'Request the reversal from an interactive session.'; +} diff --git a/microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.md b/microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.md new file mode 100644 index 0000000..33a8029 --- /dev/null +++ b/microsoft/knowledge/finance/do-not-modify-or-delete-posted-ledger-entries.md @@ -0,0 +1,37 @@ +--- +bc-version: [all] +domain: finance +keywords: [g-l-entry, ledger-entry, reversal, audit-trail, correction, financial-content, entry-edit] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Correct posted financial content through posting workflows, not row surgery + +## Description + +Changing a posted entry's original amount, account, posting date, or tax amounts in place does not correct the related ledger, register, or source document. Deleting one erroneous row has the same problem. Business Central provides reversing and correcting posting workflows that retain the relationship between the original transaction and its correction; this is not a blanket prohibition on every write to a posted table. + +## Best Practice + +Use a supported transaction/register reversal, credit memo, or correcting journal appropriate to the original posting and its current state. Request the standard reversal workflow rather than negating a single row or setting `Reversed` yourself. Let that workflow enforce eligibility; do not change source or application fields to make a rejected reversal pass. + +Supported operational edits are deliberate exceptions: for example, `"G/L Entry-Edit"` supports description changes, and `"Cust. Entry-Edit"` / `"Vend. Entry-Edit"` handle their table-specific editable fields. [Due-date synchronization](change-ledger-due-dates-through-entry-edit.md), [application/unapplication](apply-ledger-entries-through-application-codeunits.md), G/L dimension correction, and supported date compression have their own workflows. Do not flag their standard implementations, temporary simulation buffers, or extension-only metadata updates as financial row surgery. A subscriber is not exempt merely because it runs inside a supported workflow: inspect the fields it actually changes. + +This rule covers G/L, customer/vendor/detailed, VAT, and financial-posting/register records. Item, Value, Capacity, Warehouse, inventory-application, and other inventory-posting records are SCM concerns. The financial-row leg of one inventory-posting bypass is outside this rule when the same inventory correction resolves it; an independently actionable financial defect remains in scope regardless of the containing module's name. + +See sample: [`do-not-modify-or-delete-posted-ledger-entries.good.al`](do-not-modify-or-delete-posted-ledger-entries.good.al). + +## Anti Pattern + +Persist a change to original financial content, delete posted rows, or fabricate reversal flags/links to repair or undo a transaction outside the supported correction/maintenance workflow. Require an existing, non-temporary Finance-owned record and evidence of the fields or rows affected; a `Modify` token or `*Ledger Entry` name alone is insufficient. Settlement-state writes belong to the application article rather than a duplicate finding here. + +See sample: [`do-not-modify-or-delete-posted-ledger-entries.bad.al`](do-not-modify-or-delete-posted-ledger-entries.bad.al). + +## References + +- [Reverse journal postings](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-how-reverse-journal-posting). +- [Correct G/L dimensions](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-troubleshooting-correcting-dimensions). +- [BCApps: G/L Entry-Edit](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Ledger/GLEntryEdit.Codeunit.al). +- [BCApps: supported customer-ledger date compression](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Receivables/DateCompressCustomerLedger.Report.al). diff --git a/microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.bad.al b/microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.bad.al new file mode 100644 index 0000000..50ac8ff --- /dev/null +++ b/microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.bad.al @@ -0,0 +1,54 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50108 "Import Purchase Journal Total" +{ + procedure ImportExampleTotal(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer) + var + JournalLine: Record "Gen. Journal Line"; + VATPostingSetup: Record "VAT Posting Setup"; + GeneralLedgerSetup: Record "General Ledger Setup"; + SourceInvoice: JsonObject; + NetToken: JsonToken; + VATToken: JsonToken; + GrossToken: JsonToken; + ImportedNet: Decimal; + ImportedVAT: Decimal; + ImportedGross: Decimal; + begin + SourceInvoice.ReadFrom('{"netAmount":100,"vatAmount":25,"grossAmount":125}'); + SourceInvoice.Get('netAmount', NetToken); + SourceInvoice.Get('vatAmount', VATToken); + SourceInvoice.Get('grossAmount', GrossToken); + ImportedNet := NetToken.AsValue().AsDecimal(); + ImportedVAT := VATToken.AsValue().AsDecimal(); + ImportedGross := GrossToken.AsValue().AsDecimal(); + if ImportedGross <> ImportedNet + ImportedVAT then + Error(TotalsErr); + + JournalLine.Get(TemplateName, BatchName, LineNo); + JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account"); + JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account"); + JournalLine.TestField("Account No."); + JournalLine.TestField("Bal. Account No."); + JournalLine.TestField("Currency Code", ''); + JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::Purchase); + JournalLine.TestField("VAT Posting", JournalLine."VAT Posting"::"Automatic VAT Entry"); + JournalLine.TestField("VAT Calculation Type", JournalLine."VAT Calculation Type"::"Normal VAT"); + JournalLine.TestField("VAT %", 25); + JournalLine.TestField("VAT Difference", 0); + JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" "); + JournalLine.TestField("Bal. VAT %", 0); + VATPostingSetup.Get(JournalLine."VAT Bus. Posting Group", JournalLine."VAT Prod. Posting Group"); + VATPostingSetup.TestField("VAT Calculation Type", VATPostingSetup."VAT Calculation Type"::"Normal VAT"); + VATPostingSetup.TestField("VAT %", 25); + VATPostingSetup.TestField("Unrealized VAT Type", VATPostingSetup."Unrealized VAT Type"::" "); + GeneralLedgerSetup.Get(); + GeneralLedgerSetup.TestField("Additional Reporting Currency", ''); + GeneralLedgerSetup.TestField("Amount Rounding Precision", 0.01); + + JournalLine.Validate(Amount, ImportedNet); + JournalLine.Modify(true); + end; + + var + TotalsErr: Label 'The invoice total must equal its net amount plus VAT.'; +} diff --git a/microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.good.al b/microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.good.al new file mode 100644 index 0000000..961e234 --- /dev/null +++ b/microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.good.al @@ -0,0 +1,54 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50108 "Import Purchase Journal Total" +{ + procedure ImportExampleTotal(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer) + var + JournalLine: Record "Gen. Journal Line"; + VATPostingSetup: Record "VAT Posting Setup"; + GeneralLedgerSetup: Record "General Ledger Setup"; + SourceInvoice: JsonObject; + NetToken: JsonToken; + VATToken: JsonToken; + GrossToken: JsonToken; + ImportedNet: Decimal; + ImportedVAT: Decimal; + ImportedGross: Decimal; + begin + SourceInvoice.ReadFrom('{"netAmount":100,"vatAmount":25,"grossAmount":125}'); + SourceInvoice.Get('netAmount', NetToken); + SourceInvoice.Get('vatAmount', VATToken); + SourceInvoice.Get('grossAmount', GrossToken); + ImportedNet := NetToken.AsValue().AsDecimal(); + ImportedVAT := VATToken.AsValue().AsDecimal(); + ImportedGross := GrossToken.AsValue().AsDecimal(); + if ImportedGross <> ImportedNet + ImportedVAT then + Error(TotalsErr); + + JournalLine.Get(TemplateName, BatchName, LineNo); + JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account"); + JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account"); + JournalLine.TestField("Account No."); + JournalLine.TestField("Bal. Account No."); + JournalLine.TestField("Currency Code", ''); + JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::Purchase); + JournalLine.TestField("VAT Posting", JournalLine."VAT Posting"::"Automatic VAT Entry"); + JournalLine.TestField("VAT Calculation Type", JournalLine."VAT Calculation Type"::"Normal VAT"); + JournalLine.TestField("VAT %", 25); + JournalLine.TestField("VAT Difference", 0); + JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" "); + JournalLine.TestField("Bal. VAT %", 0); + VATPostingSetup.Get(JournalLine."VAT Bus. Posting Group", JournalLine."VAT Prod. Posting Group"); + VATPostingSetup.TestField("VAT Calculation Type", VATPostingSetup."VAT Calculation Type"::"Normal VAT"); + VATPostingSetup.TestField("VAT %", 25); + VATPostingSetup.TestField("Unrealized VAT Type", VATPostingSetup."Unrealized VAT Type"::" "); + GeneralLedgerSetup.Get(); + GeneralLedgerSetup.TestField("Additional Reporting Currency", ''); + GeneralLedgerSetup.TestField("Amount Rounding Precision", 0.01); + + JournalLine.Validate(Amount, ImportedGross); + JournalLine.Modify(true); + end; + + var + TotalsErr: Label 'The invoice total must equal its net amount plus VAT.'; +} diff --git a/microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.md b/microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.md new file mode 100644 index 0000000..6dda9ef --- /dev/null +++ b/microsoft/knowledge/finance/normal-vat-journal-amount-includes-vat.md @@ -0,0 +1,37 @@ +--- +bc-version: [all] +domain: finance +keywords: [normal-vat, automatic-vat-entry, gross-amount, net-amount, vat-posting-setup, gen-journal-line, purchase] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Supply a VAT-inclusive journal Amount for automatic Normal VAT + +## Description + +For a general-journal line using **Automatic VAT Entry** and **Normal VAT**, `Amount` includes VAT. The posting engine extracts tax from that total; it does not add tax to a VAT-exclusive expense imported into `Amount`. An LCY invoice with net 100 and VAT 25 therefore needs a journal total of 125, not 100. + +## Best Practice + +Map the source's VAT-inclusive total to journal `Amount` in this posting mode. Establish the intended account and posting-group combination before validating the final amount. Account-derived VAT defaults depend on `Copy VAT Setup to Jnl. Lines`; do not assume account selection always supplies the intended configuration. + +Require the actual input contract and calculation mode, not just a variable named `NetAmount`. The examples encode source net, VAT, and gross values plus a 25% Normal-VAT setup check. They target an LCY G/L purchase with 0.01 amount rounding and without balancing-side VAT, additional reporting currency, VAT differences, or unrealized VAT. Other calculation types, Manual VAT Entry, reverse charge, Full VAT, sales/use tax, unrealized tax, and other currency/rounding contexts need their own analysis; this is not a universal gross-up formula or country-specific tax advice. + +The concern is the supplied transaction total, not its deductible/non-deductible allocation. Non-deductible VAT features can change the allocation of that total, not turn the source's net amount into its gross amount. Do not infer a particular expense or deductible-VAT split from this rule. The samples therefore do not depend on later-version non-deductible-VAT fields. + +See sample: [`normal-vat-journal-amount-includes-vat.good.al`](normal-vat-journal-amount-includes-vat.good.al). + +## Anti Pattern + +In the demonstrated automatic Normal-VAT configuration, put a provably VAT-exclusive source amount into journal `Amount` while expecting posting to add tax. An amount assignment alone, unknown setup, or a suggestive variable name is insufficient. Do not report the correctly supplied gross amount or automatically rewrite tax calculations outside this scope. + +See sample: [`normal-vat-journal-amount-includes-vat.bad.al`](normal-vat-journal-amount-includes-vat.bad.al). + +## References + +- [VAT posting setup combinations](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-setup-vat#combine-vat-posting-groups-in-vat-posting-setups). +- [BCApps: journal amount validation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Journal/GenJournalLine.Table.al). +- [BCApps: Normal VAT extraction during posting](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Posting/GenJnlPostLine.Codeunit.al). +- [BCApps: journal VAT amount regression cases](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/Tests/VAT/ERMVATOnGenJournalLine.Codeunit.al). diff --git a/microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.bad.al b/microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.bad.al new file mode 100644 index 0000000..948976b --- /dev/null +++ b/microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.bad.al @@ -0,0 +1,60 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50100 "Post Transfer Journal" +{ + procedure PostTransferBatch(TemplateName: Code[10]; BatchName: Code[10]) + var + JournalLine: Record "Gen. Journal Line"; + LastGLEntry: Record "G/L Entry"; + NextEntryNo: Integer; + begin + JournalLine.SetRange("Journal Template Name", TemplateName); + JournalLine.SetRange("Journal Batch Name", BatchName); + if JournalLine.Count() <> 1 then + Error(SingleTransferErr); + JournalLine.FindFirst(); + JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account"); + JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account"); + JournalLine.TestField("Account No."); + JournalLine.TestField("Bal. Account No."); + JournalLine.TestField("Posting Date"); + JournalLine.TestField("Document No."); + JournalLine.TestField(Amount); + JournalLine.TestField("Currency Code", ''); + JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::" "); + JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" "); + + LastGLEntry.LockTable(); + if LastGLEntry.FindLast() then + NextEntryNo := LastGLEntry."Entry No." + 1 + else + NextEntryNo := 1; + InsertLedgerRow(JournalLine, NextEntryNo, JournalLine."Account No.", JournalLine.Amount); + InsertLedgerRow(JournalLine, NextEntryNo + 1, JournalLine."Bal. Account No.", -JournalLine.Amount); + end; + + local procedure InsertLedgerRow(JournalLine: Record "Gen. Journal Line"; EntryNo: Integer; AccountNo: Code[20]; Amount: Decimal) + var + GLEntry: Record "G/L Entry"; + begin + GLEntry.Init(); + GLEntry."Entry No." := EntryNo; + GLEntry."G/L Account No." := AccountNo; + GLEntry."Posting Date" := JournalLine."Posting Date"; + GLEntry."Document Type" := JournalLine."Document Type"; + GLEntry."Document No." := JournalLine."Document No."; + GLEntry."Source Code" := JournalLine."Source Code"; + GLEntry."Journal Batch Name" := JournalLine."Journal Batch Name"; + GLEntry."Dimension Set ID" := JournalLine."Dimension Set ID"; + GLEntry."Global Dimension 1 Code" := JournalLine."Shortcut Dimension 1 Code"; + GLEntry."Global Dimension 2 Code" := JournalLine."Shortcut Dimension 2 Code"; + GLEntry.Amount := Amount; + if Amount > 0 then + GLEntry."Debit Amount" := Amount + else + GLEntry."Credit Amount" := -Amount; + GLEntry.Insert(true); + end; + + var + SingleTransferErr: Label 'Use a journal batch containing exactly one self-balancing G/L transfer.'; +} diff --git a/microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.good.al b/microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.good.al new file mode 100644 index 0000000..4d3bee3 --- /dev/null +++ b/microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.good.al @@ -0,0 +1,29 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50100 "Post Transfer Journal" +{ + procedure PostTransferBatch(TemplateName: Code[10]; BatchName: Code[10]) + var + JournalLine: Record "Gen. Journal Line"; + PostBatch: Codeunit "Gen. Jnl.-Post Batch"; + begin + JournalLine.SetRange("Journal Template Name", TemplateName); + JournalLine.SetRange("Journal Batch Name", BatchName); + if JournalLine.Count() <> 1 then + Error(SingleTransferErr); + JournalLine.FindFirst(); + JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account"); + JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account"); + JournalLine.TestField("Account No."); + JournalLine.TestField("Bal. Account No."); + JournalLine.TestField("Posting Date"); + JournalLine.TestField("Document No."); + JournalLine.TestField(Amount); + JournalLine.TestField("Currency Code", ''); + JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::" "); + JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" "); + PostBatch.Run(JournalLine); + end; + + var + SingleTransferErr: Label 'Use a journal batch containing exactly one self-balancing G/L transfer.'; +} diff --git a/microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.md b/microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.md new file mode 100644 index 0000000..aefb6bb --- /dev/null +++ b/microsoft/knowledge/finance/post-ledger-entries-through-posting-codeunits.md @@ -0,0 +1,38 @@ +--- +bc-version: [all] +domain: finance +keywords: [g-l-entry, ledger-entry, gen-jnl-post-line, gen-jnl-post-batch, journal-line, register, insert] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Create financial ledger entries through the owning posting engine + +## Description + +Standard financial ledger entries are outputs of posting, not independent rows an extension manufactures. Even two manually inserted G/L rows with balanced amounts bypass posting checks, register bookkeeping, and transaction/source relationships. `G/L Entry.Insert(true)` runs the table trigger; it does not invoke the posting engine. + +## Best Practice + +Use the owning document or journal posting workflow. For a normal persisted general-journal batch, use `"Gen. Jnl.-Post Batch"`; the example posts an existing batch containing one self-balancing, non-VAT G/L transfer. Let posting allocate entries and maintain the register rather than reconstructing its tables. + +`"Gen. Jnl.-Post Line".RunWithCheck` is appropriate for a complete journal line inside a correctly owned posting lifecycle, but it does not invent a balancing account or document number, allocate numbering merely from `Posting No. Series`, or replace [batch document-balancing policy](preserve-journal-batch-document-balance.md). The line codeunit is stateful; its checked wrapper owns its start/continue/finish work. Normal batch posting owns its numbering and commits by default; do not imply these entry points are transaction-neutral. + +Exclude temporary buffers and the standard engine's own insertion points. A checked parent may legitimately use `RunWithoutCheck`; do not replace it without inspecting that parent. This rule owns `G/L Entry`, `Cust. Ledger Entry`, `Vendor Ledger Entry`, their detailed customer/vendor entries, `VAT Entry`, and financial-posting/register records, not a custom table merely named `Ledger Entry` or a supported, specifically reviewed migration/repair workflow. + +`Item Ledger Entry`, `Value Entry`, Capacity/Warehouse entries, `Item Application Entry`, and other inventory-posting records are SCM concerns, not this rule's financial-ledger scope. That exclusion includes the financial-row leg of a single inventory-posting bypass when restoring the inventory workflow corrects the whole operation. Distinct, independently actionable financial defects remain in scope. + +See sample: [`post-ledger-entries-through-posting-codeunits.good.al`](post-ledger-entries-through-posting-codeunits.good.al). + +## Anti Pattern + +Create posted financial effects by directly inserting the Finance-owned records named above outside their owning posting workflow. Resolve the actual record type, operation, and lifecycle; do not match `*Ledger Entry` as a wildcard. Balanced debit/credit values, copied dimensions, `Insert(true)`, and a lock around entry-number allocation do not turn raw inserts into a complete posting. + +See sample: [`post-ledger-entries-through-posting-codeunits.bad.al`](post-ledger-entries-through-posting-codeunits.bad.al). + +## References + +- [Posting engine structure](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-posting-engine-structure). +- [Gen. Jnl.-Post Line API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.generalledger.posting.gen.-jnl.-post-line). +- [BCApps: posting lifecycle and register maintenance](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Posting/GenJnlPostLine.Codeunit.al). diff --git a/microsoft/knowledge/finance/preserve-journal-batch-document-balance.bad.al b/microsoft/knowledge/finance/preserve-journal-batch-document-balance.bad.al new file mode 100644 index 0000000..1d5b5b3 --- /dev/null +++ b/microsoft/knowledge/finance/preserve-journal-batch-document-balance.bad.al @@ -0,0 +1,53 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50107 "Post Journal Allocation" +{ + procedure PostAllocation(TemplateName: Code[10]; BatchName: Code[10]; DebitAccount: Code[20]; CreditAccount: Code[20]; PostingDate: Date) + var + JournalTemplate: Record "Gen. Journal Template"; + JournalBatch: Record "Gen. Journal Batch"; + JournalLine: Record "Gen. Journal Line"; + LineToPost: Record "Gen. Journal Line"; + PostLine: Codeunit "Gen. Jnl.-Post Line"; + begin + JournalTemplate.Get(TemplateName); + JournalTemplate.TestField(Recurring, false); + JournalTemplate.TestField("Force Doc. Balance", true); + JournalTemplate.TestField("Source Code"); + JournalBatch.Get(TemplateName, BatchName); + JournalBatch.TestField("No. Series", ''); + JournalBatch.TestField("Posting No. Series", ''); + JournalLine.SetRange("Journal Template Name", TemplateName); + JournalLine.SetRange("Journal Batch Name", BatchName); + if not JournalLine.IsEmpty() then + Error(EmptyBatchErr); + + AddAllocationLine(JournalTemplate, BatchName, 10000, DebitAccount, PostingDate, 'ALLOC-A', 90); + AddAllocationLine(JournalTemplate, BatchName, 20000, CreditAccount, PostingDate, 'ALLOC-B', -90); + JournalLine.FindSet(); + repeat + LineToPost := JournalLine; + PostLine.RunWithCheck(LineToPost); + until JournalLine.Next() = 0; + end; + + local procedure AddAllocationLine(JournalTemplate: Record "Gen. Journal Template"; BatchName: Code[10]; LineNo: Integer; AccountNo: Code[20]; PostingDate: Date; DocumentNo: Code[20]; LineAmount: Decimal) + var + JournalLine: Record "Gen. Journal Line"; + begin + JournalLine.Init(); + JournalLine."Journal Template Name" := JournalTemplate.Name; + JournalLine."Journal Batch Name" := BatchName; + JournalLine."Line No." := LineNo; + JournalLine."Source Code" := JournalTemplate."Source Code"; + JournalLine.Validate("Posting Date", PostingDate); + JournalLine.Validate("Document No.", DocumentNo); + JournalLine.Validate("Account Type", JournalLine."Account Type"::"G/L Account"); + JournalLine.Validate("Account No.", AccountNo); + JournalLine.Validate("Gen. Posting Type", JournalLine."Gen. Posting Type"::" "); + JournalLine.Validate(Amount, LineAmount); + JournalLine.Insert(true); + end; + + var + EmptyBatchErr: Label 'Use an empty journal batch for this allocation.'; +} diff --git a/microsoft/knowledge/finance/preserve-journal-batch-document-balance.good.al b/microsoft/knowledge/finance/preserve-journal-batch-document-balance.good.al new file mode 100644 index 0000000..beff7f4 --- /dev/null +++ b/microsoft/knowledge/finance/preserve-journal-batch-document-balance.good.al @@ -0,0 +1,49 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50107 "Post Journal Allocation" +{ + procedure PostAllocation(TemplateName: Code[10]; BatchName: Code[10]; DebitAccount: Code[20]; CreditAccount: Code[20]; PostingDate: Date) + var + JournalTemplate: Record "Gen. Journal Template"; + JournalBatch: Record "Gen. Journal Batch"; + JournalLine: Record "Gen. Journal Line"; + PostBatch: Codeunit "Gen. Jnl.-Post Batch"; + begin + JournalTemplate.Get(TemplateName); + JournalTemplate.TestField(Recurring, false); + JournalTemplate.TestField("Force Doc. Balance", true); + JournalTemplate.TestField("Source Code"); + JournalBatch.Get(TemplateName, BatchName); + JournalBatch.TestField("No. Series", ''); + JournalBatch.TestField("Posting No. Series", ''); + JournalLine.SetRange("Journal Template Name", TemplateName); + JournalLine.SetRange("Journal Batch Name", BatchName); + if not JournalLine.IsEmpty() then + Error(EmptyBatchErr); + + AddAllocationLine(JournalTemplate, BatchName, 10000, DebitAccount, PostingDate, 'ALLOC-A', 90); + AddAllocationLine(JournalTemplate, BatchName, 20000, CreditAccount, PostingDate, 'ALLOC-A', -90); + JournalLine.FindFirst(); + PostBatch.Run(JournalLine); + end; + + local procedure AddAllocationLine(JournalTemplate: Record "Gen. Journal Template"; BatchName: Code[10]; LineNo: Integer; AccountNo: Code[20]; PostingDate: Date; DocumentNo: Code[20]; LineAmount: Decimal) + var + JournalLine: Record "Gen. Journal Line"; + begin + JournalLine.Init(); + JournalLine."Journal Template Name" := JournalTemplate.Name; + JournalLine."Journal Batch Name" := BatchName; + JournalLine."Line No." := LineNo; + JournalLine."Source Code" := JournalTemplate."Source Code"; + JournalLine.Validate("Posting Date", PostingDate); + JournalLine.Validate("Document No.", DocumentNo); + JournalLine.Validate("Account Type", JournalLine."Account Type"::"G/L Account"); + JournalLine.Validate("Account No.", AccountNo); + JournalLine.Validate("Gen. Posting Type", JournalLine."Gen. Posting Type"::" "); + JournalLine.Validate(Amount, LineAmount); + JournalLine.Insert(true); + end; + + var + EmptyBatchErr: Label 'Use an empty journal batch for this allocation.'; +} diff --git a/microsoft/knowledge/finance/preserve-journal-batch-document-balance.md b/microsoft/knowledge/finance/preserve-journal-batch-document-balance.md new file mode 100644 index 0000000..b3079ac --- /dev/null +++ b/microsoft/knowledge/finance/preserve-journal-batch-document-balance.md @@ -0,0 +1,35 @@ +--- +bc-version: [all] +domain: finance +keywords: [force-doc-balance, gen-journal-template, gen-jnl-post-batch, runwithcheck, document-no, posting-date, balancing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Preserve the journal batch's document-balancing policy + +## Description + +A balanced G/L total does not prove that a general-journal batch satisfies its template's document-balancing policy. `"Gen. Jnl.-Post Batch"` checks balances at posting-date boundaries and, when the **journal template's** `Force Doc. Balance` is enabled, document-type/document-number boundaries. A loop over `"Gen. Jnl.-Post Line".RunWithCheck` does not reproduce these batch-level checks. + +## Best Practice + +Post normal persisted general-journal batches through their owning batch workflow. Keep balancing lines in the appropriate document/date group when the template requires it. The examples use two LCY G/L lines: opposite amounts under different document numbers are not a document-balanced transfer when `Force Doc. Balance` is true; the good example groups them under one document and retains the batch checks. + +Do not claim every document must always balance: when that template option is false, the supported workflow can allow document imbalance while still checking the required aggregate balances. Standalone self-balancing line posting and purpose-built posting engines that demonstrably own equivalent aggregate policies are not prohibited. A `RunWithCheck` call or loop alone is not sufficient evidence of a defect. + +See sample: [`preserve-journal-batch-document-balance.good.al`](preserve-journal-batch-document-balance.good.al). + +## Anti Pattern + +Replace a normal persisted journal batch's posting path with per-line posting or only an aggregate-total check, bypassing a demonstrated template/document/date policy. For the document-imbalance finding, require evidence that `Force Doc. Balance` applies and that separate document groups can be unbalanced; do not infer the setting from its name or a comment alone. + +See sample: [`preserve-journal-batch-document-balance.bad.al`](preserve-journal-batch-document-balance.bad.al). + +## References + +- [Work with general journals](https://learn.microsoft.com/en-us/dynamics365/business-central/ui-work-general-journals). +- [Gen. Jnl.-Post Batch API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.generalledger.posting.gen.-jnl.-post-batch). +- [BCApps: batch balance checks](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Posting/GenJnlPostBatch.Codeunit.al). +- [BCApps: document-balance option regression cases](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/Tests/General%20Journal/ERMTestMultipleGenJnlLines.Codeunit.al). diff --git a/microsoft/knowledge/finance/reverse-transactions-by-transaction-number.bad.al b/microsoft/knowledge/finance/reverse-transactions-by-transaction-number.bad.al new file mode 100644 index 0000000..9f5d311 --- /dev/null +++ b/microsoft/knowledge/finance/reverse-transactions-by-transaction-number.bad.al @@ -0,0 +1,18 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50106 "Reverse Selected Posting" +{ + procedure RequestReversal(SelectedEntryNo: Integer) + var + GLEntry: Record "G/L Entry"; + ReversalEntry: Record "Reversal Entry"; + begin + if not GuiAllowed() then + Error(InteractiveSessionErr); + GLEntry.Get(SelectedEntryNo); + GLEntry.TestField("Transaction No."); + ReversalEntry.ReverseTransaction(GLEntry."Entry No."); + end; + + var + InteractiveSessionErr: Label 'Request the reversal from an interactive session.'; +} diff --git a/microsoft/knowledge/finance/reverse-transactions-by-transaction-number.good.al b/microsoft/knowledge/finance/reverse-transactions-by-transaction-number.good.al new file mode 100644 index 0000000..392d3cd --- /dev/null +++ b/microsoft/knowledge/finance/reverse-transactions-by-transaction-number.good.al @@ -0,0 +1,18 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50106 "Reverse Selected Posting" +{ + procedure RequestReversal(SelectedEntryNo: Integer) + var + GLEntry: Record "G/L Entry"; + ReversalEntry: Record "Reversal Entry"; + begin + if not GuiAllowed() then + Error(InteractiveSessionErr); + GLEntry.Get(SelectedEntryNo); + GLEntry.TestField("Transaction No."); + ReversalEntry.ReverseTransaction(GLEntry."Transaction No."); + end; + + var + InteractiveSessionErr: Label 'Request the reversal from an interactive session.'; +} diff --git a/microsoft/knowledge/finance/reverse-transactions-by-transaction-number.md b/microsoft/knowledge/finance/reverse-transactions-by-transaction-number.md new file mode 100644 index 0000000..296d902 --- /dev/null +++ b/microsoft/knowledge/finance/reverse-transactions-by-transaction-number.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: finance +keywords: [reversetransaction, reverseregister, transaction-no, entry-no, reversal-entry, g-l-register] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass the transaction number, not a ledger-entry number, to ReverseTransaction + +## Description + +`Reversal Entry.ReverseTransaction` expects a **transaction number**, while `ReverseRegister` expects a **G/L register number**. Neither parameter means a ledger `Entry No.`. All are integers, so the compiler accepts the wrong identity; a coincidentally matching integer can select another transaction instead of the posting the user intended to reverse. + +## Best Practice + +When starting from a `G/L Entry`, fetch that entry and pass **its** `Transaction No.` to `Reversal Entry.ReverseTransaction`, as the sample does. The same identity distinction applies to customer/vendor ledger entries when using their supported transaction-reversal path. If the starting point is a G/L register, use `Reversal Entry.ReverseRegister` with that register's number. The interactive workflow collects the participating entries and validates reversal eligibility before the user posts the reversal. + +Do not infer eligibility from `Open` alone or bypass a rejection by changing origin, application, or reversal fields. The supported path depends on source and state; some postings require unapplication or a correcting document first. A request to reverse is not a guarantee that reversal will be permitted. This rule concerns the two named `Reversal Entry` APIs, not routines such as `UnApplyCustLedgEntry` that legitimately accept a ledger entry number. + +See sample: [`reverse-transactions-by-transaction-number.good.al`](reverse-transactions-by-transaction-number.good.al). + +## Anti Pattern + +Pass a ledger entry's `Entry No.` or a register number into `ReverseTransaction`, or pass a ledger-entry/transaction number into `ReverseRegister`. Require visible value provenance, not merely a suspicious variable name or an arbitrary integer. A correctly sourced transaction number is valid even when the variable is poorly named. + +See sample: [`reverse-transactions-by-transaction-number.bad.al`](reverse-transactions-by-transaction-number.bad.al). + +## References + +- [Reversal Entry API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/table/microsoft.finance.generalledger.reversal.reversal-entry). +- [Reverse journal postings](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-how-reverse-journal-posting). +- [BCApps: reversal entry selection](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Reversal/ReversalEntry.Table.al). diff --git a/microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.bad.al b/microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.bad.al new file mode 100644 index 0000000..f54d42b --- /dev/null +++ b/microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.bad.al @@ -0,0 +1,20 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50102 "Copy Journal Dimensions" +{ + procedure CopyAllLineDimensions(TemplateName: Code[10]; BatchName: Code[10]; SourceLineNo: Integer; TargetLineNo: Integer) + var + SourceLine: Record "Gen. Journal Line"; + TargetLine: Record "Gen. Journal Line"; + begin + SourceLine.Get(TemplateName, BatchName, SourceLineNo); + TargetLine.Get(TemplateName, BatchName, TargetLineNo); + TargetLine."Shortcut Dimension 1 Code" := SourceLine."Shortcut Dimension 1 Code"; + TargetLine."Shortcut Dimension 2 Code" := SourceLine."Shortcut Dimension 2 Code"; + TargetLine.Modify(true); + end; + + procedure HasShortcutDimension1(JournalLine: Record "Gen. Journal Line"; DimensionValue: Code[20]): Boolean + begin + exit(JournalLine."Shortcut Dimension 1 Code" = DimensionValue); + end; +} diff --git a/microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.good.al b/microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.good.al new file mode 100644 index 0000000..330c0de --- /dev/null +++ b/microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.good.al @@ -0,0 +1,19 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50102 "Copy Journal Dimensions" +{ + procedure CopyAllLineDimensions(TemplateName: Code[10]; BatchName: Code[10]; SourceLineNo: Integer; TargetLineNo: Integer) + var + SourceLine: Record "Gen. Journal Line"; + TargetLine: Record "Gen. Journal Line"; + begin + SourceLine.Get(TemplateName, BatchName, SourceLineNo); + TargetLine.Get(TemplateName, BatchName, TargetLineNo); + TargetLine.Validate("Dimension Set ID", SourceLine."Dimension Set ID"); + TargetLine.Modify(true); + end; + + procedure HasShortcutDimension1(JournalLine: Record "Gen. Journal Line"; DimensionValue: Code[20]): Boolean + begin + exit(JournalLine."Shortcut Dimension 1 Code" = DimensionValue); + end; +} diff --git a/microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.md b/microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.md new file mode 100644 index 0000000..0764f20 --- /dev/null +++ b/microsoft/knowledge/finance/write-dimensions-as-dimension-set-entries.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: finance +keywords: [dimension-set-id, shortcut-dimension, global-dimension, journal-line, posting, copy-dimensions] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Copy complete posting dimension sets, not only shortcut projections + +## Description + +When a journal line or posting document inherits dimensions, its `Dimension Set ID` identifies the complete combination of `Dimension Set Entry` rows. Global and shortcut dimensions expose selected dimensions, not the complete set; the eight shortcut dimensions are not a limit on set membership. Copying only these projections can leave the destination's posting dimensions unchanged or silently lose dimensions outside the shortcuts. + +## Best Practice + +For an intentional **complete** dimension transfer, copy the source set ID and synchronize the destination's projections through its supported validation or dimension-management routine. On `Gen. Journal Line`, `Validate("Dimension Set ID", SourceSetID)` updates the two shortcut fields. Do not assume another table has the same validation trigger. + +When line-specific dimensions must survive a header change, use the appropriate set-combination or delta routine instead of blindly replacing the line's entire set. Reading or filtering a known global dimension is legitimate; it is not a claim to enumerate every dimension. This rule owns transfers through general-journal and financial-document posting records, not writes to Item, Value, Capacity, Warehouse, or inventory-application records owned by SCM. Generic custom-table or master `Default Dimension` wiring belongs to data modeling. + +See sample: [`write-dimensions-as-dimension-set-entries.good.al`](write-dimensions-as-dimension-set-entries.good.al). + +## Anti Pattern + +A routine intended to copy **all** posting dimensions copies only global/shortcut codes, or assigns a set ID without synchronizing the destination's stored projections. Require evidence of a complete-transfer intent and inspect surrounding validation; an explicit change to one selected dimension or a read-only filter is not this defect. + +See sample: [`write-dimensions-as-dimension-set-entries.bad.al`](write-dimensions-as-dimension-set-entries.bad.al). + +## References + +- [Dimension set entries overview](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-dimension-set-entries-overview). +- [DimensionManagement API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.dimension.dimensionmanagement). +- [BCApps: Gen. Journal Line dimension validation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Journal/GenJournalLine.Table.al). diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 7cfe07b..df476f3 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -29,6 +29,7 @@ sub-skills: - microsoft/skills/review/al-appsource-review.md - microsoft/skills/review/al-telemetry-review.md - microsoft/skills/review/al-scm-review.md + - microsoft/skills/review/al-finance-review.md --- # AL code review diff --git a/microsoft/skills/review/al-finance-review.md b/microsoft/skills/review/al-finance-review.md new file mode 100644 index 0000000..06090b1 --- /dev/null +++ b/microsoft/skills/review/al-finance-review.md @@ -0,0 +1,146 @@ +--- +kind: action-skill +id: al-finance-review +version: 1 +title: AL Finance review +description: Reviews financial journal posting, ledger corrections, applications, VAT handling, and posting-linked dimensions against BCQuality Finance guidance. +inputs: [pr-diff, file-path, folder-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL Finance review + +Reviews the `finance` knowledge domain. This is a leaf action skill composed by +`al-code-review`; it invokes no other skills. Application-area metadata does +not gate Finance coverage; resolved source records and operations do. + +## Source + +Apply the source-surface gate in Relevance before retrieving knowledge. +If it passes, use READ's **Bounded retrieval for review skills** workflow with +`-Domain finance`. Consume every catalog page across enabled layers, preserving +each exact path and applicability metadata. Do not select a top-k catalog or +deduplicate by basename. Open complete bodies only for exact Worklist paths, +in stable chunks of at most eight, consuming every continuation. If the helper +or prepared index is unavailable or invalid, use READ's explicit path-discovery +and bounded native-read fallback; a retrieval error is not an empty corpus. + +## Relevance + +Apply READ's frontmatter matching rules using only known task dimensions. +Use the target application version from `app.json` when available; do not invent +a country or application area from a filename or UI `ApplicationArea` token. +Retain conditional articles only when configured, cap resulting confidence at +`medium`, and name every unknown dimension in the finding. + +Inspect the supplied AL scope and its enclosing declarations. Admit only +changed executable behavior involving at least one of these surfaces: + +- General-journal construction or posting, journal-batch processing, or an + event subscriber whose resolved publisher is in the financial posting path. +- Writes or correction/application/reversal calls involving `G/L Entry`, + `Cust. Ledger Entry`, `Vendor Ledger Entry`, `Detailed Cust. Ledg. Entry`, + `Detailed Vendor Ledg. Entry`, `VAT Entry`, or financial-posting/G/L-register + records. +- Dimension transfer or dimension-set mutation connected by visible data flow + to an existing general journal, financial posting document, or Finance-owned + ledger record. + +Exclude `Item Ledger Entry`, `Value Entry`, Capacity/Warehouse entries, +`Item Application Entry`, and other inventory-posting records owned by SCM. +Do not adopt their findings when the SCM skill is absent or disabled. For one +inventory-originated posting bypass, equivalent findings have one SCM primary +owner; distinct independent financial defects remain Finance. Classify the +operation and actual record, not an inventory/finance word in a module name. + +Return `not-applicable` when none is present. Imports, object names, comments, +read-only ledger displays, generic `Amount`/`Date`/`Open` fields, and calls to +`DimensionManagement` without posting-linked context do not establish relevance. +For a diff, retain surrounding variable types, field provenance, event +attributes, and reachable helpers; do not review isolated added lines without +the context needed to classify their record or call. + +## Worklist + +Match the complete relevant catalog's keywords, titles, and descriptions to +the admitted source surfaces. Add an exact catalog path only when its concern +maps to the changed behavior; generic financial vocabulary is not enough. +The following deterministic cues must select their named articles even if +keyword ranking would otherwise omit them: + +- Persistent Finance-owned ledger inserts reached from extension posting + code — `post-ledger-entries-through-posting-codeunits`. +- Persisted general-journal lines posted through a line-codeunit loop or custom + aggregate check, with visible template/document/date balancing context — + `preserve-journal-batch-document-balance`. +- Imported net/tax/gross values mapped into `Gen. Journal Line.Amount`, with + evidence of the VAT posting mode and posting-setup combination — + `normal-vat-journal-amount-includes-vat`. +- Persisted original Finance accounting-value changes, deletion of Finance rows, or + fabricated reversal flags/links — `do-not-modify-or-delete-posted-ledger-entries`. +- Customer/vendor settlement/reopening code writing `Open`, closure fields, + detailed customer/vendor application amounts, or unapplication flags — + `apply-ledger-entries-through-application-codeunits`. +- A due-date change persisted on an existing customer/vendor ledger entry — + `change-ledger-due-dates-through-entry-edit`. +- A `Reversal Entry.ReverseTransaction` or `ReverseRegister` argument with + visible ledger-entry, transaction, or register provenance — + `reverse-transactions-by-transaction-number`. +- A complete Finance posting-dimension transfer represented by shortcut/global + fields or a `Dimension Set ID` assignment — + `write-dimensions-as-dimension-set-entries`. +- A dimension/value membership change on `Dimension Set Entry`, reached from + a general-journal/financial-document/Finance-ledger set ID — + `do-not-edit-shared-dimension-sets`. + +These are retrieval cues, not findings. Use the selected articles' normative +exceptions and ownership boundaries to classify standard workflows, temporary +records, operational edits, and extension fields. Do not select a Finance +ledger rule from `*Ledger Entry` or `Insert`/`Modify` alone. Finance does not +own SCM records, generic custom-table/master dimension wiring, number-series +API migration, or general AL validation, locking, transaction, and event-style +advice. Do not add those concerns as Finance agent findings. + +Resolve actual normative conflicts across layers per READ and record suppressed +candidates per DO. Keep every remaining exact path in a stable worklist. +Return `no-knowledge` if no applicable Finance knowledge survives filtering or +configuration; return `completed` with no findings when applicable knowledge +exists but no article matches the admitted changes. + +## Action + +Evaluate every worklist article in full against the changed behavior and its +surrounding control flow. Establish record type, existing versus newly prepared +state, temporariness, fields actually persisted, argument provenance, and the +posting/edit API boundary before emitting a finding. Do not infer a financial +defect from a method name, missing external setup, or unsupported speculation +about callers. + +Use the most specific article for the correction: application-state, due-date, +and shared-dimension findings must not also become generic posted-row findings +for the same change. Do not emit an equivalent Finance finding for the +financial-row leg of one SCM-owned inventory posting bypass; evaluate a +distinct financial defect only when its corrective action is independent. +Emit `major` for a demonstrated financial-correctness +violation and reserve `blocker` for directly evidenced destructive corruption +under DO's severity rules. Applicability alone produces no finding. + +Set `high` confidence only for established source evidence and known matching +context. Domain-scoped agent findings follow DO's precision bar and remain +capped at `minor`/`medium`; do not broaden this pass into other AL domains. +Provide literal `suggested-code` for complete, local, unambiguous fixes. +Otherwise give `suggested-code-omission-reason`, particularly when selecting +the correct posting workflow requires business context. + +Follow DO's acceptance gate and outcome rules. Report `partial` rather than +silently dropping worklist items when a budget is reached, and `failed` for an +unrecoverable retrieval or evaluation error. + +## Output + +Output conforms to the DO findings-report contract. Every finding this skill +emits MUST set `findings[].domain` to `"Finance"`. From 0a8c9a85560204e2fe75a1180767330bcfb62581 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:48:17 +0200 Subject: [PATCH 30/51] 18 AL/BC patterns: style, data-modeling, web-services, appsource, breaking-changes, performance, testing (#156) * Add 18 community AL/BC patterns across style, data-modeling, web-services, appsource, breaking-changes, performance, and testing Contributed by CURABIS ApS, generalized from patterns observed across real AppSource/PTE development. Each article follows the knowledge file format (frontmatter, Description/Best Practice/Anti Pattern, sibling .good.al/.bad.al samples). * Address Jesper Schulz-Wedde's review on PR #156 - Rename 3 articles so their .good.al/.bad.al companion stems match (do-not-change-primary-key, testfield-required-setup-field, al-identifiers-english), fixing the R14 orphan-sample errors. - do-not-change-primary-key.good.al: include Flow in the new table's own primary key so it actually models the discriminating dimension. - al-build-output-must-not-pollute-project-root.md: drop the unsubstantiated AL0197 causal claim and the non-existent al.outputPath setting; reframe as build-artifact hygiene sourced from ALTool --outfolder / al_build outputPath. - prefer-email-module.md: Email Message is Codeunit 8904, not a table; distinguish it from the underlying Sent/Outbox/Draft storage. - file-datatype-saas.md: File.Open/Create/Read/Write fails to compile against a Cloud-scoped project, it does not compile and silently fail at runtime. - namespace-must-be-verified-from-source.md: narrow to "resolve from the referenced object's source or symbols," since source-file line one is not the only authoritative source (symbol packages, comments before the namespace line). - test-data-must-be-random-and-complete.md: drop "assume an empty database" and "collision-free" absolutes; reframe around independence from unrelated business records and reserving explicit values for scenario-defining inputs. - binary-choice-must-be-boolean.md: scope to genuine true/false semantics, not mechanical two-member-enum-to-boolean conversion. - document-report-word-layout.md: scope down to a sourced Microsoft Learn recommendation instead of an unconditional performance guarantee; cite the three Learn pages. - Wire the new articles into their review skills' candidate-selection signals (file-datatype-saas, prefer-email-module, namespace-must-be-verified-from-source, var-parameters-require-an- addressable-variable) so they can actually enter a worklist. Co-Authored-By: Claude Sonnet 5 * Fix dimension-management-wiring.md: ValidateShortcutDimCode and CreateDim do not exist on the current DimensionManagement codeunit Verified against microsoft/BCApps: the real master-table validation procedure is ValidateDimValueCode (or ValidateShortcutDimValues when a DimSetID is also needed), and the real document-side inheritance procedure is GetDefaultDimID, not CreateDim. Caught from Jesper Schulz-Wedde's review thread, which had been partially hidden by GitHub's comment folding. Co-Authored-By: Claude Sonnet 5 * Address second round of Jesper Schulz-Wedde's review on PR #156 - dimension-management-wiring.md/.good.al: split into the two distinct models the article was conflating - master data (Default Dimension records via ValidateDimValueCode/SaveDefaultDim) vs. transactional/ document data (a single Dimension Set ID assembled via AddDimSource + GetDefaultDimID, verified against BCApps' ExchRateAdjmtProcess.Codeunit.al). Added a compiling document-table example alongside the existing master table one. - Deleted api-page-flowfields-must-be-calcfields (.md/.good.al/.bad.al): Microsoft's own FlowFields documentation states a FlowField used as a control's direct source expression is automatically calculated on any page - no API-page exception is documented, and none could be reproduced. - prefer-email-module.bad.al/.md: Codeunit Mail has no Send/GetErrorDesc members; fixed to the real current 7-argument CreateMessage signature, and corrected the claim that the legacy path "still runs" - its base implementation no longer sends anything, only raises integration events. - check-post-line-batch-pattern.md/.good.al: reframed from a universal invariant to the standard shape, naming the real Gen./Item/CA/Res./Job/ Insurance/Mfg. Item/FA Jnl.-Check Line/-Post Line/-Post Batch codeunits it's based on. Added the missing Check Line companion codeunit so the good fixture is internally complete. - test-data-must-be-random-and-complete.good.al: removed leftover "collision-free" wording contradicting the already-corrected article text. - fixed-choice-set-must-use-enum-not-integer.md: removed the reintroduced state-count heuristic ("the line is the state count"), aligned with binary-choice-must-be-boolean.md's semantics-based distinction. - namespace-must-be-verified-from-source.md: removed the false claim that the compiler and AL Language Server use different namespace-resolution rules. - intrinsic-al-functions-must-use-modern-casing.md: removed the unverified claim that PascalCase is the VS Code formatter's default output. Worklist completeness: added cues for the 8 rules in data-modeling, testing, performance, and web-services that had none (Jesper's explicit ask), plus the same gap in all 7 style rules from this PR (not explicitly named this round, but the identical systemic issue) - 15 cues total across al-data-modeling-review.md, al-testing-review.md, al-performance-review.md, al-web-services-review.md, and al-style-review.md. Co-Authored-By: Claude Sonnet 5 * Fix remaining correctness issues from Jesper's 2026-09-15 re-review - dimension-management-wiring: SaveDefaultDim's third argument is the shortcut dimension number (1-8), not the field's AL field ID; the fixture passed FieldNo(...) = 10. GetDefaultDimID's InheritFromDimSetID must be 0 when recomputing after the linking record changes, not the document's existing Dimension Set ID (which would retain the previous customer's leftover dimensions). Verified against DimensionManagement.Codeunit.al and BankDepositHeader.Table.al in the BCApps reference clone. - check-post-line-batch-pattern: "Post Line writes exactly one line to the ledger" overclaimed - Gen. Jnl.-Post Line alone calls InsertGLEntry from a dozen call sites (balancing entry, VAT, currency rounding, deferrals) and can write several G/L Entries per journal line. Reworded to "posts exactly one journal line" and softened the "distinct, non-overlapping responsibilities" absolute. - namespace-must-be-verified-from-source.bad.al: dropped the "resolves in a local build, fails in VS Code" comment (taught an inherent compiler/language-server disagreement that isn't real); reframed as stale/cached symbols, matching the prose fix already made. - file-datatype-saas.good.al: replaced the deprecated 5-argument UploadIntoStream overload with the current 2-argument one, and actually staged through TempBlob as the article's own Best Practice instructs (the declared TempBlob variable was previously unused). - test-data-must-be-random-and-complete: no longer treats a short-but-valid value as defective merely for being "underfilled" - AL field lengths are maxima, not minimums. Scoped to missing values or a scenario with an explicit length/format requirement (e.g. a truncation test). Updated the al-testing-review.md routing cue to match. - stored-derived-fields-must-not-be-exposed-directly: stopped mandating source-field exposure as part of the core pattern: the good fixture exposed only one of the derived value's two inputs (Hours Used, not Budgeted Hours), making the claimed "so the consumer can verify it" impossible. Reframed as an optional, all-or-nothing addition and fixed the fixture to expose both inputs. Rebased onto upstream/main to resolve conflicts in al-breaking-changes-review.md, al-data-modeling-review.md, al-performance-review.md, and al-style-review.md against merged PRs #148 and #153; all sides' worklist tokens/cues retained. * Fix remaining READ-convention sample links across this PR's 18 articles The same plain-backtick "See sample: \`x.good.al\`." form fixed on al-methods-limited-during-write-transactions (PR #161) turned up repo-wide on 15 more of this PR's articles - Knowledge-Retrieval.ps1 requires the markdown-link form to associate a sample with its article. All 16 fixed; the four local validators (frontmatter, knowledge-index, knowledge-retrieval, review-fixtures, skill-index) pass. * Fix two merge-critical correctness issues from Jesper's 2026-09-22 review - api-page-key-fields-must-be-editable-on-insert.good.al and stored-derived-fields-must-not-be-exposed-directly.good.al: both were writable API pages missing DelayedInsert = true, contradicting this repo's own api-page-delayedinsert-true rule - the canonical "good" samples were teaching code BCQuality itself flags. - dimension-management-wiring.good.al: UpdateDimensionSetID exited early when Customer.Get failed, leaving the previous customer's shortcut dimension and Dimension Set ID in place - the same staleness bug the InheritFromDimSetID = 0 fix (from the prior review round) was meant to prevent, just triggered by a failed lookup instead of a successful one. Now clears the shortcut field and recomputes with an empty source list on a failed lookup too, so GetDefaultDimID correctly returns an empty Dimension Set ID instead of never running. --------- Co-authored-by: Claude Sonnet 5 --- .../appsource/file-datatype-saas.bad.al | 13 +++ .../appsource/file-datatype-saas.good.al | 24 +++++ .../knowledge/appsource/file-datatype-saas.md | 28 ++++++ .../prefer-email-module.bad.al | 9 ++ .../prefer-email-module.good.al | 11 +++ .../breaking-changes/prefer-email-module.md | 28 ++++++ .../check-post-line-batch-pattern.bad.al | 22 +++++ .../check-post-line-batch-pattern.good.al | 42 +++++++++ .../check-post-line-batch-pattern.md | 28 ++++++ .../dimension-management-wiring.bad.al | 13 +++ .../dimension-management-wiring.good.al | 89 +++++++++++++++++++ .../dimension-management-wiring.md | 35 ++++++++ .../do-not-change-primary-key.bad.al | 14 +++ .../do-not-change-primary-key.good.al | 24 +++++ .../do-not-change-primary-key.md | 28 ++++++ .../testfield-required-setup-field.bad.al | 12 +++ .../testfield-required-setup-field.good.al | 9 ++ .../testfield-required-setup-field.md | 28 ++++++ .../document-report-word-layout.bad.al | 16 ++++ .../document-report-word-layout.good.al | 17 ++++ .../document-report-word-layout.md | 34 +++++++ ...ld-output-must-not-pollute-project-root.md | 24 +++++ .../style/al-identifiers-english.bad.al | 11 +++ .../style/al-identifiers-english.good.al | 11 +++ .../knowledge/style/al-identifiers-english.md | 28 ++++++ .../binary-choice-must-be-boolean.bad.al | 22 +++++ .../binary-choice-must-be-boolean.good.al | 16 ++++ .../style/binary-choice-must-be-boolean.md | 28 ++++++ ...hoice-set-must-use-enum-not-integer.bad.al | 22 +++++ ...oice-set-must-use-enum-not-integer.good.al | 17 ++++ ...ed-choice-set-must-use-enum-not-integer.md | 28 ++++++ ...al-functions-must-use-modern-casing.bad.al | 12 +++ ...l-functions-must-use-modern-casing.good.al | 12 +++ ...sic-al-functions-must-use-modern-casing.md | 28 ++++++ ...espace-must-be-verified-from-source.bad.al | 13 +++ ...space-must-be-verified-from-source.good.al | 16 ++++ .../namespace-must-be-verified-from-source.md | 28 ++++++ ...ers-require-an-addressable-variable.bad.al | 15 ++++ ...rs-require-an-addressable-variable.good.al | 17 ++++ ...ameters-require-an-addressable-variable.md | 28 ++++++ ...st-data-must-be-random-and-complete.bad.al | 14 +++ ...t-data-must-be-random-and-complete.good.al | 13 +++ .../test-data-must-be-random-and-complete.md | 30 +++++++ ...y-fields-must-be-editable-on-insert.bad.al | 27 ++++++ ...-fields-must-be-editable-on-insert.good.al | 25 ++++++ ...e-key-fields-must-be-editable-on-insert.md | 28 ++++++ ...fields-must-not-be-exposed-directly.bad.al | 24 +++++ ...ields-must-not-be-exposed-directly.good.al | 33 +++++++ ...ved-fields-must-not-be-exposed-directly.md | 28 ++++++ .../skills/review/al-appsource-review.md | 1 + .../review/al-breaking-changes-review.md | 4 +- .../skills/review/al-data-modeling-review.md | 4 + .../skills/review/al-performance-review.md | 1 + microsoft/skills/review/al-style-review.md | 12 ++- microsoft/skills/review/al-testing-review.md | 1 + .../skills/review/al-web-services-review.md | 2 + 56 files changed, 1144 insertions(+), 3 deletions(-) create mode 100644 microsoft/knowledge/appsource/file-datatype-saas.bad.al create mode 100644 microsoft/knowledge/appsource/file-datatype-saas.good.al create mode 100644 microsoft/knowledge/appsource/file-datatype-saas.md create mode 100644 microsoft/knowledge/breaking-changes/prefer-email-module.bad.al create mode 100644 microsoft/knowledge/breaking-changes/prefer-email-module.good.al create mode 100644 microsoft/knowledge/breaking-changes/prefer-email-module.md create mode 100644 microsoft/knowledge/data-modeling/check-post-line-batch-pattern.bad.al create mode 100644 microsoft/knowledge/data-modeling/check-post-line-batch-pattern.good.al create mode 100644 microsoft/knowledge/data-modeling/check-post-line-batch-pattern.md create mode 100644 microsoft/knowledge/data-modeling/dimension-management-wiring.bad.al create mode 100644 microsoft/knowledge/data-modeling/dimension-management-wiring.good.al create mode 100644 microsoft/knowledge/data-modeling/dimension-management-wiring.md create mode 100644 microsoft/knowledge/data-modeling/do-not-change-primary-key.bad.al create mode 100644 microsoft/knowledge/data-modeling/do-not-change-primary-key.good.al create mode 100644 microsoft/knowledge/data-modeling/do-not-change-primary-key.md create mode 100644 microsoft/knowledge/data-modeling/testfield-required-setup-field.bad.al create mode 100644 microsoft/knowledge/data-modeling/testfield-required-setup-field.good.al create mode 100644 microsoft/knowledge/data-modeling/testfield-required-setup-field.md create mode 100644 microsoft/knowledge/performance/document-report-word-layout.bad.al create mode 100644 microsoft/knowledge/performance/document-report-word-layout.good.al create mode 100644 microsoft/knowledge/performance/document-report-word-layout.md create mode 100644 microsoft/knowledge/style/al-build-output-must-not-pollute-project-root.md create mode 100644 microsoft/knowledge/style/al-identifiers-english.bad.al create mode 100644 microsoft/knowledge/style/al-identifiers-english.good.al create mode 100644 microsoft/knowledge/style/al-identifiers-english.md create mode 100644 microsoft/knowledge/style/binary-choice-must-be-boolean.bad.al create mode 100644 microsoft/knowledge/style/binary-choice-must-be-boolean.good.al create mode 100644 microsoft/knowledge/style/binary-choice-must-be-boolean.md create mode 100644 microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.bad.al create mode 100644 microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.good.al create mode 100644 microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.md create mode 100644 microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.bad.al create mode 100644 microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.good.al create mode 100644 microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.md create mode 100644 microsoft/knowledge/style/namespace-must-be-verified-from-source.bad.al create mode 100644 microsoft/knowledge/style/namespace-must-be-verified-from-source.good.al create mode 100644 microsoft/knowledge/style/namespace-must-be-verified-from-source.md create mode 100644 microsoft/knowledge/style/var-parameters-require-an-addressable-variable.bad.al create mode 100644 microsoft/knowledge/style/var-parameters-require-an-addressable-variable.good.al create mode 100644 microsoft/knowledge/style/var-parameters-require-an-addressable-variable.md create mode 100644 microsoft/knowledge/testing/test-data-must-be-random-and-complete.bad.al create mode 100644 microsoft/knowledge/testing/test-data-must-be-random-and-complete.good.al create mode 100644 microsoft/knowledge/testing/test-data-must-be-random-and-complete.md create mode 100644 microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.bad.al create mode 100644 microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.good.al create mode 100644 microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.md create mode 100644 microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.bad.al create mode 100644 microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.good.al create mode 100644 microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.md diff --git a/microsoft/knowledge/appsource/file-datatype-saas.bad.al b/microsoft/knowledge/appsource/file-datatype-saas.bad.al new file mode 100644 index 0000000..05ab0ff --- /dev/null +++ b/microsoft/knowledge/appsource/file-datatype-saas.bad.al @@ -0,0 +1,13 @@ +codeunit 50104 "Import File Reader" +{ + procedure ImportFile() + var + ImportFile: File; + InStream: InStream; + begin + ImportFile.WriteMode(false); + ImportFile.TextMode(true); + ImportFile.Open('C:\Import\data.txt'); // fails in SaaS — no local filesystem + ImportFile.CreateInStream(InStream); + end; +} diff --git a/microsoft/knowledge/appsource/file-datatype-saas.good.al b/microsoft/knowledge/appsource/file-datatype-saas.good.al new file mode 100644 index 0000000..8703c33 --- /dev/null +++ b/microsoft/knowledge/appsource/file-datatype-saas.good.al @@ -0,0 +1,24 @@ +codeunit 50104 "Import File Reader" +{ + procedure ImportFile() + var + TempBlob: Codeunit "Temp Blob"; + FromInStream: InStream; + ToOutStream: OutStream; + InStream: InStream; + begin + if not UploadIntoStream('All Files (*.*)|*.*', FromInStream) then + exit; + + TempBlob.CreateOutStream(ToOutStream); + CopyStream(ToOutStream, FromInStream); + + TempBlob.CreateInStream(InStream); + ParseStream(InStream); + end; + + local procedure ParseStream(var InStream: InStream) + begin + // parse InStream content here + end; +} diff --git a/microsoft/knowledge/appsource/file-datatype-saas.md b/microsoft/knowledge/appsource/file-datatype-saas.md new file mode 100644 index 0000000..f46a565 --- /dev/null +++ b/microsoft/knowledge/appsource/file-datatype-saas.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: appsource +keywords: [file-datatype, saas, onprem, uploadintostream, downloadfromstream, instream, outstream, streaming] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# The File data type's direct I/O methods are OnPrem-only + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +The classic `File` variable type — `Open`/`Create`/`Read`/`Write`/`Close` against a path on the local or server filesystem — is scoped OnPrem-only. Code targeting Business Central Online that calls `File.Open`, `File.Create`, `File.Read`, or `File.Write` fails to compile against a Cloud-scoped project; it does not compile successfully and fail or get silently skipped at runtime. Separately, and regardless of the compile-time scoping, no server/local filesystem path is available to an extension actually running in Business Central Online. + +## Best Practice + +Use the stream-based equivalents: `UploadIntoStream` to read user-selected file content into an `InStream`, and `DownloadFromStream` to write an `OutStream`'s content to a file the user saves. Stage the content in a `TempBlob` between the stream and the rest of the parsing/formatting code. + +See sample: [`file-datatype-saas.good.al`](file-datatype-saas.good.al). + +## Anti Pattern + +Opening a hardcoded or user-supplied filesystem path with the `File` variable type. This is a strong signal the code was written for on-premises only, or copied from material that predates the cloud-first streaming APIs. + +See sample: [`file-datatype-saas.bad.al`](file-datatype-saas.bad.al). diff --git a/microsoft/knowledge/breaking-changes/prefer-email-module.bad.al b/microsoft/knowledge/breaking-changes/prefer-email-module.bad.al new file mode 100644 index 0000000..78e593f --- /dev/null +++ b/microsoft/knowledge/breaking-changes/prefer-email-module.bad.al @@ -0,0 +1,9 @@ +codeunit 50103 "Order Confirmation Notifier" +{ + procedure Send(ToAddress: Text; Subject: Text; Body: Text) + var + Mail: Codeunit Mail; + begin + Mail.CreateMessage(ToAddress, '', '', Subject, Body, false, false); + end; +} diff --git a/microsoft/knowledge/breaking-changes/prefer-email-module.good.al b/microsoft/knowledge/breaking-changes/prefer-email-module.good.al new file mode 100644 index 0000000..c9a1da4 --- /dev/null +++ b/microsoft/knowledge/breaking-changes/prefer-email-module.good.al @@ -0,0 +1,11 @@ +codeunit 50103 "Order Confirmation Notifier" +{ + procedure Send(ToAddress: Text; Subject: Text; Body: Text) + var + Email: Codeunit Email; + EmailMessage: Codeunit "Email Message"; + begin + EmailMessage.Create(ToAddress, Subject, Body, true); + Email.Send(EmailMessage, Enum::"Email Scenario"::Default); + end; +} diff --git a/microsoft/knowledge/breaking-changes/prefer-email-module.md b/microsoft/knowledge/breaking-changes/prefer-email-module.md new file mode 100644 index 0000000..6c04b37 --- /dev/null +++ b/microsoft/knowledge/breaking-changes/prefer-email-module.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: breaking-changes +keywords: [email, codeunit-mail, email-message, email-scenario, email-account, smtp, sending-email] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Send email through the Email module, not Codeunit Mail (397) + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Older AL code sends email by calling `Codeunit Mail (397)`. Business Central's current extensibility model is a different, richer object set — `Codeunit Email`, `Codeunit "Email Message"`, `enum "Email Scenario"`, and the `Email Account`/`Email Connector` interface (Microsoft 365, Current User, SMTP, or a custom connector). `Codeunit "Email Message"` is the in-memory object you build the message on; it is not itself the persisted Sent/Outbox/Draft record — that storage is managed separately once the message is queued or sent. New code built on `Codeunit Mail` inherits its SMTP-era, single-connector assumptions and leaves no Sent/Outbox trail behind. + +## Best Practice + +Build on `Codeunit Email` and `Codeunit "Email Message"`. Route the message through an `Email Scenario` so different document types can use different accounts without the calling code needing to know which account that is, and get a tracked Sent/Outbox/Draft record for free. + +See sample: [`prefer-email-module.good.al`](prefer-email-module.good.al). + +## Anti Pattern + +Calling `Codeunit Mail`'s `CreateMessage`. It still compiles and runs, but current `Codeunit Mail`'s own implementation of `CreateMessage` no longer sends anything by itself — it only raises integration events for a legacy subscriber to act on — so building new code on it means depending on whatever compatibility shim happens to still be wired up, with no first-class connector selection and no queryable Sent/Outbox/Draft record. `Send` and `GetErrorDesc` are not current members of `Codeunit Mail` at all; do not reference them. + +See sample: [`prefer-email-module.bad.al`](prefer-email-module.bad.al). diff --git a/microsoft/knowledge/data-modeling/check-post-line-batch-pattern.bad.al b/microsoft/knowledge/data-modeling/check-post-line-batch-pattern.bad.al new file mode 100644 index 0000000..0a13e1c --- /dev/null +++ b/microsoft/knowledge/data-modeling/check-post-line-batch-pattern.bad.al @@ -0,0 +1,22 @@ +codeunit 50101 "Meter Jnl.-Post" +{ + procedure Post(var MeterJnlLine: Record "Meter Journal Line") + var + MeterLedgEntry: Record "Meter Ledger Entry"; + begin + // Validation, Journal access, and posting all mixed in one routine. + if not Confirm('Post journal lines?') then + exit; + + if MeterJnlLine.FindSet() then + repeat + if MeterJnlLine.Quantity = 0 then + Error('Quantity must not be zero.'); + + MeterLedgEntry.Init(); + MeterLedgEntry.TransferFields(MeterJnlLine); + MeterLedgEntry.Insert(); + MeterJnlLine.Delete(); + until MeterJnlLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/data-modeling/check-post-line-batch-pattern.good.al b/microsoft/knowledge/data-modeling/check-post-line-batch-pattern.good.al new file mode 100644 index 0000000..6bfa93e --- /dev/null +++ b/microsoft/knowledge/data-modeling/check-post-line-batch-pattern.good.al @@ -0,0 +1,42 @@ +codeunit 50100 "Meter Jnl.-Check Line" +{ + procedure CheckLine(var MeterJnlLine: Record "Meter Journal Line") + begin + // Reads setup/dimension data only, shows no UI beyond errors. + if MeterJnlLine.Quantity = 0 then + Error('Quantity must not be zero.'); + end; +} + +codeunit 50101 "Meter Jnl.-Post Line" +{ + procedure PostLine(var MeterJnlLine: Record "Meter Journal Line") + var + MeterLedgEntry: Record "Meter Ledger Entry"; + begin + // Posts exactly one journal line; never touches the Journal table. + MeterLedgEntry.Init(); + MeterLedgEntry.TransferFields(MeterJnlLine); + MeterLedgEntry.Insert(); + end; +} + +codeunit 50102 "Meter Jnl.-Post Batch" +{ + procedure PostBatch(var MeterJnlLine: Record "Meter Journal Line") + var + CheckLine: Codeunit "Meter Jnl.-Check Line"; + PostLine: Codeunit "Meter Jnl.-Post Line"; + begin + if MeterJnlLine.FindSet() then + repeat + CheckLine.CheckLine(MeterJnlLine); + until MeterJnlLine.Next() = 0; + + if MeterJnlLine.FindSet() then + repeat + PostLine.PostLine(MeterJnlLine); + MeterJnlLine.Delete(); + until MeterJnlLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/data-modeling/check-post-line-batch-pattern.md b/microsoft/knowledge/data-modeling/check-post-line-batch-pattern.md new file mode 100644 index 0000000..e6b1229 --- /dev/null +++ b/microsoft/knowledge/data-modeling/check-post-line-batch-pattern.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [posting-routine, check-line, post-line, post-batch, companion-codeunit, yes-no-wrapper, journal] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Split posting routines into Check Line / Post Line / Post Batch + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Business Central's own journal-based posting routines consistently follow a three-codeunit split, each with one primary responsibility — `Codeunit "Gen. Jnl.-Check Line"` / `"Gen. Jnl.-Post Line"` / `"Gen. Jnl.-Post Batch"` for the general journal, and the same `-Check Line` / `-Post Line` / `-Post Batch` shape repeated for Item, Resource, Job, Fixed Asset, Insurance, and Cost Accounting journals: `Check Line` validates one line, `Post Line` posts exactly one journal line — `Gen. Jnl.-Post Line` itself can write more than one G/L Entry per call (a balancing entry, VAT, currency rounding, deferrals), so "exactly one line" describes its input, not a one-entry-out guarantee — and `Post Batch` loops both across the journal. A document posting routine (posting one document at a time) calls `Post Line` directly and skips `Post Batch`. This is the standard shape to evaluate a new journal-based posting routine against, not a platform-enforced constraint — a routine with a genuinely different transaction/reuse shape may legitimately organize itself differently, and the three codeunits' responsibilities are a useful default split, not a guarantee that every implementation keeps them non-overlapping. But a new routine that blurs this split without a specific reason either misses functionality other code expects to call directly, or exposes an interaction surface it shouldn't. + +## Best Practice + +`Check Line` reads setup/dimension data only on its first call and shows no UI beyond errors. `Post Line` only operates on the record passed to it — never the Journal table — so it can be called directly by other posting code, including a document posting routine. `Post Batch` is the only one of the three that reads and updates the Journal table, and it is the only one invoked from the Post action on a journal page. A `-Post` document codeunit is never called directly from a page; a page calls a `-Post (Yes/No)` confirmation wrapper instead, so the same `-Post` codeunit can also run unattended from a batch-posting report. + +See sample: [`check-post-line-batch-pattern.good.al`](check-post-line-batch-pattern.good.al). + +## Anti Pattern + +A single monolithic posting codeunit that reads the Journal table, validates lines, writes ledger entries, and shows confirmation dialogs all in one procedure. It cannot be reused by another posting routine without fabricating journal records, and it cannot run unattended because it insists on user interaction. + +See sample: [`check-post-line-batch-pattern.bad.al`](check-post-line-batch-pattern.bad.al). diff --git a/microsoft/knowledge/data-modeling/dimension-management-wiring.bad.al b/microsoft/knowledge/data-modeling/dimension-management-wiring.bad.al new file mode 100644 index 0000000..8e3bce4 --- /dev/null +++ b/microsoft/knowledge/data-modeling/dimension-management-wiring.bad.al @@ -0,0 +1,13 @@ +table 50100 "Course" +{ + fields + { + field(1; "No."; Code[20]) { } + field(10; "Global Dimension 1 Code"; Code[20]) + { + // No CaptionClass, no OnValidate call into DimensionManagement. + // Accepts any value; never becomes a Default Dimension record. + TableRelation = "Dimension Value".Code; + } + } +} diff --git a/microsoft/knowledge/data-modeling/dimension-management-wiring.good.al b/microsoft/knowledge/data-modeling/dimension-management-wiring.good.al new file mode 100644 index 0000000..b5feae0 --- /dev/null +++ b/microsoft/knowledge/data-modeling/dimension-management-wiring.good.al @@ -0,0 +1,89 @@ +// Master data: Default Dimension records, no Dimension Set ID field. +table 50100 "Course" +{ + fields + { + field(1; "No."; Code[20]) { } + field(10; "Global Dimension 1 Code"; Code[20]) + { + CaptionClass = '1,1,1'; + TableRelation = "Dimension Value".Code where( + "Global Dimension No." = const(1), Blocked = const(false)); + + trigger OnValidate() + var + DimMgt: Codeunit DimensionManagement; + begin + DimMgt.ValidateDimValueCode(1, "Global Dimension 1 Code"); + DimMgt.SaveDefaultDim(Database::Course, "No.", 1, "Global Dimension 1 Code"); + end; + } + } + + trigger OnDelete() + var + DimMgt: Codeunit DimensionManagement; + begin + DimMgt.DeleteDefaultDim(Database::Course, "No."); + end; +} + +// Transactional/document data: a single Dimension Set ID, inherited from the +// related master record and overridable via shortcut dimension fields. +table 50101 "Course Registration Header" +{ + fields + { + field(1; "No."; Code[20]) { } + field(2; "Customer No."; Code[20]) + { + TableRelation = Customer; + + trigger OnValidate() + begin + UpdateDimensionSetID(); + end; + } + field(10; "Shortcut Dimension 1 Code"; Code[20]) + { + CaptionClass = '1,1,1'; + TableRelation = "Dimension Value".Code where( + "Global Dimension No." = const(1), Blocked = const(false)); + + trigger OnValidate() + var + DimMgt: Codeunit DimensionManagement; + begin + DimMgt.ValidateShortcutDimValues(1, "Shortcut Dimension 1 Code", "Dimension Set ID"); + end; + } + field(480; "Dimension Set ID"; Integer) + { + Editable = false; + TableRelation = "Dimension Set Entry"."Dimension Set ID"; + } + } + + local procedure UpdateDimensionSetID() + var + Customer: Record Customer; + DimMgt: Codeunit DimensionManagement; + DefaultDimSource: List of [Dictionary of [Integer, Code[20]]]; + GlobalDim2Code: Code[20]; + begin + // Recompute from scratch (InheritFromDimSetID = 0) whether or not the + // customer lookup succeeds. Passing the existing "Dimension Set ID" + // here would inherit dimensions from whichever record the document + // was previously linked to, and exiting early on a failed Get would + // leave that same stale data in place — both defeat the point of + // this procedure. Clearing the shortcut field and recomputing with + // an empty source list (when the customer doesn't exist) correctly + // clears the document's dimensions instead of leaving old ones. + "Shortcut Dimension 1 Code" := ''; + if Customer.Get("Customer No.") then + DimMgt.AddDimSource(DefaultDimSource, Database::Customer, "Customer No."); + "Dimension Set ID" := + DimMgt.GetDefaultDimID( + DefaultDimSource, '', "Shortcut Dimension 1 Code", GlobalDim2Code, 0, 0); + end; +} diff --git a/microsoft/knowledge/data-modeling/dimension-management-wiring.md b/microsoft/knowledge/data-modeling/dimension-management-wiring.md new file mode 100644 index 0000000..d7eec38 --- /dev/null +++ b/microsoft/knowledge/data-modeling/dimension-management-wiring.md @@ -0,0 +1,35 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [dimensions, dimensionmanagement, global-dimension, shortcut-dimension, default-dimension, validatedimvaluecode, getdefaultdimid] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Wire dimension support through DimensionManagement, not ad hoc fields + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Adding dimension support to a custom table is not just a matter of adding a `Code[20]` field, and master tables and document/transactional tables wire into `Codeunit "Dimension Management"` through two different models — treating them as one mechanism is itself the mistake this article corrects: + +- **Master data** (a custom master table, e.g. "Course") persists **Default Dimension** records: each shortcut dimension field validates through `ValidateDimValueCode`, then the result is saved via `SaveDefaultDim`, and `DeleteDefaultDim` removes them again in `OnDelete`. Both `ValidateDimValueCode` and `SaveDefaultDim` take the shortcut dimension *number* (1-8, matching `General Ledger Setup`'s "Shortcut Dimension N Code" fields) as their first/third argument respectively — not the AL field ID of the table field being validated. The master record itself carries no `Dimension Set ID` field. +- **Transactional/document data** (a custom document or journal-line table) carries a single **`Dimension Set ID`** field — a pointer to a shared, deduplicated set of dimension values in `Dimension Set Entry`, assembled from whatever the document inherited plus whatever the user overrode. A document does not acquire that ID by calling `SaveDefaultDim`; it builds a source list with `AddDimSource` (naming the related master table and its key, e.g. `Database::Customer`), then calls `GetDefaultDimID` to compute a new `Dimension Set ID` that inherits the master's Default Dimension records. Editing a shortcut dimension field directly on the document validates through `ValidateShortcutDimValues`, which updates the same `Dimension Set ID` in place rather than writing a separate Default Dimension record. + +Skipping the model that actually matches the table's kind produces a field that looks correct in the designer but silently fails to save, validate, or carry through to postings — or, for a document, one that never picks up the customer's/vendor's own dimensions at all. + +## Best Practice + +For a master table, validate each shortcut dimension field through `ValidateDimValueCode`, save the result with `SaveDefaultDim`, and delete the matching Default Dimension records in `OnDelete`. + +For a document table, when the field that attaches the document to a master record changes (e.g. `Customer No.`), call `AddDimSource` naming that master table and key, then `GetDefaultDimID` to compute the document's new `Dimension Set ID`, inheriting the master's Default Dimension records. Pass `0` for `GetDefaultDimID`'s `InheritFromDimSetID` argument in this case — passing the document's *existing* `Dimension Set ID` instead inherits whatever dimensions were already in it, so a value the previous linked record supplied can survive into the new one even where the new record has no default for that dimension. Run this same recompute — clear the shortcut field, call `GetDefaultDimID` with no source added — when the lookup on the new key fails (blank or an invalid value), too: exiting early instead leaves the previous record's dimensions in place, which is the same staleness bug the `InheritFromDimSetID = 0` rule exists to prevent. Validate the document's own Shortcut Dimension fields through `ValidateShortcutDimValues`, which updates that same `Dimension Set ID` in place rather than persisting a separate Default Dimension record. + +See sample: [`dimension-management-wiring.good.al`](dimension-management-wiring.good.al). + +## Anti Pattern + +Adding a dimension-looking field with only a `TableRelation` to Dimension Value, and no call into `DimensionManagement` at all. The field accepts input but never becomes a real Default Dimension record, so it does not validate against blocked values and does not flow into postings. + +See sample: [`dimension-management-wiring.bad.al`](dimension-management-wiring.bad.al). diff --git a/microsoft/knowledge/data-modeling/do-not-change-primary-key.bad.al b/microsoft/knowledge/data-modeling/do-not-change-primary-key.bad.al new file mode 100644 index 0000000..cc52ae2 --- /dev/null +++ b/microsoft/knowledge/data-modeling/do-not-change-primary-key.bad.al @@ -0,0 +1,14 @@ +table 50100 "Period Stats" +{ + fields + { + field(1; "Period Start"; Date) { } + field(2; Flow; Enum "Some Flow") { } + } + keys + { + // Table already shipped with key(PK; "Period Start"). + // Adding Flow here breaks every upgrade with AS0009. + key(PK; Flow, "Period Start") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/data-modeling/do-not-change-primary-key.good.al b/microsoft/knowledge/data-modeling/do-not-change-primary-key.good.al new file mode 100644 index 0000000..4739c49 --- /dev/null +++ b/microsoft/knowledge/data-modeling/do-not-change-primary-key.good.al @@ -0,0 +1,24 @@ +table 50100 "Period Stats" +{ + fields + { + field(1; "Period Start"; Date) { } + } + keys + { + key(PK; "Period Start") { Clustered = true; } + } +} + +table 50101 "Period Stats By Flow" +{ + fields + { + field(1; "Period Start"; Date) { } + field(2; Flow; Enum "Some Flow") { } + } + keys + { + key(PK; Flow, "Period Start") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/data-modeling/do-not-change-primary-key.md b/microsoft/knowledge/data-modeling/do-not-change-primary-key.md new file mode 100644 index 0000000..37cb3e3 --- /dev/null +++ b/microsoft/knowledge/data-modeling/do-not-change-primary-key.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [primary-key, clustered-key, table-design, appsource, breaking-change, schema-upgrade] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Never Change a Published Table's Primary or Clustered Key Field List + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Once a table has shipped — to AppSource, or to any customer environment that has already upgraded onto it — its primary key, and any other key marked `Clustered = true`, is frozen. This includes adding a field to the key, not only removing or reordering one: Business Central identifies existing rows by their key value, so any change to which fields compose that key invalidates every row already stored under the old shape, and the platform's upgrade validation rejects it outright (`AS0009`). This is easy to trip over because it doesn't look like the well-known "don't delete a field" mistake — the field being added is often brand new, and folding a new discriminating dimension straight into the existing key feels like the natural, un-denormalized way to model it. On an unpublished table that is correct; on a published one it is a breaking schema change regardless of which direction the field list changed, and there is no in-place fix once the upgrade is rejected, only reverting the key to its published shape. + +## Best Practice + +Leave a published table's key exactly as shipped. Model a new discriminating dimension as a separate table with its own key instead of adding a field to the existing key, and branch orchestration code by the new dimension rather than filtering one shared table on an extra key field. + +See sample: [`do-not-change-primary-key.good.al`](do-not-change-primary-key.good.al). + +## Anti Pattern + +Adding a field to a published table's primary or clustered key to distinguish a new case. This fails AppSource validation or any customer upgrade with `AS0009` as soon as rows already exist under the old key shape, whether the field is being added, removed, or reordered. + +See sample: [`do-not-change-primary-key.bad.al`](do-not-change-primary-key.bad.al). diff --git a/microsoft/knowledge/data-modeling/testfield-required-setup-field.bad.al b/microsoft/knowledge/data-modeling/testfield-required-setup-field.bad.al new file mode 100644 index 0000000..2ee7bc1 --- /dev/null +++ b/microsoft/knowledge/data-modeling/testfield-required-setup-field.bad.al @@ -0,0 +1,12 @@ +codeunit 50100 "Tax Posting Helper" +{ + procedure GetTaxAccount(var Setup: Record "Sales & Receivables Setup"): Code[20] + var + DefaultTaxAccountTxt: Label 'DEFAULT-TAX'; + begin + Setup.Get(); + if Setup."Tax Account No." <> '' then + exit(Setup."Tax Account No."); + exit(DefaultTaxAccountTxt); + end; +} diff --git a/microsoft/knowledge/data-modeling/testfield-required-setup-field.good.al b/microsoft/knowledge/data-modeling/testfield-required-setup-field.good.al new file mode 100644 index 0000000..d35db26 --- /dev/null +++ b/microsoft/knowledge/data-modeling/testfield-required-setup-field.good.al @@ -0,0 +1,9 @@ +codeunit 50100 "Tax Posting Helper" +{ + procedure GetTaxAccount(var Setup: Record "Sales & Receivables Setup"): Code[20] + begin + Setup.Get(); + Setup.TestField("Tax Account No."); + exit(Setup."Tax Account No."); + end; +} diff --git a/microsoft/knowledge/data-modeling/testfield-required-setup-field.md b/microsoft/knowledge/data-modeling/testfield-required-setup-field.md new file mode 100644 index 0000000..37ad575 --- /dev/null +++ b/microsoft/knowledge/data-modeling/testfield-required-setup-field.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [testfield, setup-table, configuration, mandatory-field, silent-fallback, correctness] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# TestField a Setup-Table Value Before Using It in a Correctness-Critical Branch + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A procedure that reads a field from a setup or configuration table inside a branch where the surrounding logic has already decided that value is required needs to guard against it being blank. A common anti-pattern silently treats "blank" as "feature not wanted": it checks the field for emptiness and falls through to a default instead of raising an error. `Get()` succeeding on the setup record only proves the record exists, not that the specific field was ever configured, so the fallback path makes a missing configuration indistinguishable from a deliberate one — and the wrong outcome, especially in financial, tax, or compliance postings, surfaces silently rather than as a crash a tester would notice. + +## Best Practice + +Once a business rule has decided that a setup-table field's value is required for a branch to behave correctly, call `TestField` on it before use, even though a plain read would "work" by returning a blank or zero without erroring. Write a test that blanks the setup field and asserts the resulting error, so the guard itself is verified rather than merely present. + +See sample: [`testfield-required-setup-field.good.al`](testfield-required-setup-field.good.al). + +## Anti Pattern + +Reading a required setup-table field behind a presence check that falls through to a default value instead of erroring. This looks defensive because it never crashes, but it converts "administrator forgot to configure this" into "system silently did something else" — worse than a hard failure, because nobody is told anything went wrong. + +See sample: [`testfield-required-setup-field.bad.al`](testfield-required-setup-field.bad.al). diff --git a/microsoft/knowledge/performance/document-report-word-layout.bad.al b/microsoft/knowledge/performance/document-report-word-layout.bad.al new file mode 100644 index 0000000..0da274f --- /dev/null +++ b/microsoft/knowledge/performance/document-report-word-layout.bad.al @@ -0,0 +1,16 @@ +report 50105 "Sales Quote Confirmation" +{ + UsageCategory = Documents; + ApplicationArea = All; + + rendering + { + layout(RDLC) + { + Type = RDLC; + LayoutFile = './Layouts/SalesQuoteConfirmation.rdl'; + } + } + + DefaultRenderingLayout = RDLC; +} diff --git a/microsoft/knowledge/performance/document-report-word-layout.good.al b/microsoft/knowledge/performance/document-report-word-layout.good.al new file mode 100644 index 0000000..f9e5e24 --- /dev/null +++ b/microsoft/knowledge/performance/document-report-word-layout.good.al @@ -0,0 +1,17 @@ +report 50105 "Sales Quote Confirmation" +{ + UsageCategory = Documents; + ApplicationArea = All; + + rendering + { + layout(Word) + { + Type = Word; + LayoutFile = './Layouts/SalesQuoteConfirmation.docx'; + Caption = 'Word Layout'; + } + } + + DefaultRenderingLayout = Word; +} diff --git a/microsoft/knowledge/performance/document-report-word-layout.md b/microsoft/knowledge/performance/document-report-word-layout.md new file mode 100644 index 0000000..06159aa --- /dev/null +++ b/microsoft/knowledge/performance/document-report-word-layout.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: performance +keywords: [report-layout, word-layout, rdlc, document-report, sandbox-app-domain, rendering] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Document reports should default to a Word layout, not RDLC + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +For a document report — an invoice, statement, order confirmation, or any report meant to be printed, emailed, or exported as a single-record document — Microsoft's own guidance recommends a `Word` rendering layout over `RDLC`: "RDL layouts can result in slower performance with document reports, regarding actions that are related to the user interface (for example, like sending emails) compared to Word layouts," and "we recommend that you design Word layouts instead of RDL" for this report shape (see Sources). This is a documented recommendation, not a universal guarantee that Word outperforms RDLC for every workload, and it does not apply to every report: tabular/list reports with heavy aggregation or calculated columns are still often a better fit for RDLC or Excel. + +## Best Practice + +For document reports, prefer a `Word` layout (`DefaultRenderingLayout = Word`) over RDLC unless specific layout requirements favor RDLC. Reserve RDLC (or Excel) for reports that represent a data listing rather than a document. + +## Sources + +- [Report Design Overview](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-report-design-overview) +- [Creating an RDL layout report](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-report-performance) +- [Troubleshooting reports / Report performance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-reports-troubleshooting) + +See sample: [`document-report-word-layout.good.al`](document-report-word-layout.good.al). + +## Anti Pattern + +Defaulting a document report's layout to RDLC out of habit or because a template happened to use it. This inherits RDLC's sandboxed-app-domain performance cost with no benefit tied to the report's actual content or calculation needs. + +See sample: [`document-report-word-layout.bad.al`](document-report-word-layout.bad.al). diff --git a/microsoft/knowledge/style/al-build-output-must-not-pollute-project-root.md b/microsoft/knowledge/style/al-build-output-must-not-pollute-project-root.md new file mode 100644 index 0000000..4e5a57a --- /dev/null +++ b/microsoft/knowledge/style/al-build-output-must-not-pollute-project-root.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [build, output, alpackages, artifact-hygiene, outfolder, project-root] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Write AL Build Artifacts to an Intentional Output Location + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +An AL project's compiled `.app` file can be written to the project root by default, and current tooling explicitly supports choosing a different destination instead — `ALTool`'s `--outfolder` option and the `al_build` agent tool's `outputPath` parameter both exist for this. The problem this rule addresses is not that root-level output is technically invalid; it is agents leaving generated `.app` files scattered through arbitrary source locations, or treating a compiled artefact as if it were part of the source tree (committing it, editing around it, referencing it as a dependency by hand). + +## Best Practice + +Write build artifacts to a deliberate, dedicated output location — configured via the build tool actually in use (e.g. `ALTool --outfolder`, or an explicit `outputPath` on the agent build tool) — and add that folder to `.gitignore`. Treat a compiled `.app` as a build artifact, never as a source file to commit or hand-edit around. + +## Anti Pattern + +Letting `.app` files accumulate in arbitrary or unversioned locations without a deliberate output path, or committing compiled artefacts into source control alongside the AL files that produced them. diff --git a/microsoft/knowledge/style/al-identifiers-english.bad.al b/microsoft/knowledge/style/al-identifiers-english.bad.al new file mode 100644 index 0000000..e4d62da --- /dev/null +++ b/microsoft/knowledge/style/al-identifiers-english.bad.al @@ -0,0 +1,11 @@ +codeunit 50100 "Sales Amount Calculator" +{ + procedure BeregnTotalbeloeb(var Salgslinje: Record "Sales Line"): Decimal + var + Beloeb: Decimal; + begin + Salgslinje.CalcSums(Amount); + Beloeb := Salgslinje.Amount; + exit(Beloeb); + end; +} diff --git a/microsoft/knowledge/style/al-identifiers-english.good.al b/microsoft/knowledge/style/al-identifiers-english.good.al new file mode 100644 index 0000000..0b17ec1 --- /dev/null +++ b/microsoft/knowledge/style/al-identifiers-english.good.al @@ -0,0 +1,11 @@ +codeunit 50100 "Sales Amount Calculator" +{ + procedure CalculateTotalAmount(var SalesLine: Record "Sales Line"): Decimal + var + TotalAmount: Decimal; + begin + SalesLine.CalcSums(Amount); + TotalAmount := SalesLine.Amount; + exit(TotalAmount); + end; +} diff --git a/microsoft/knowledge/style/al-identifiers-english.md b/microsoft/knowledge/style/al-identifiers-english.md new file mode 100644 index 0000000..5feda9f --- /dev/null +++ b/microsoft/knowledge/style/al-identifiers-english.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [identifiers, naming, english, captions, translation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Write AL Identifiers in English Only + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +All AL identifiers — variables, procedures, parameters, fields, object names, enum values, and label identifiers — must be written in English, regardless of the developer's native language. Translations are handled separately through captions, tooltips, and XLIFF files, never by writing Danish, German, or other non-English identifiers directly into AL source code. This is not a stylistic preference: the public `microsoft/BCApps` codebase, maintained by engineers of many nationalities across hundreds of thousands of lines of AL, uses exclusively English identifiers, with all localization handled via caption properties and XLIFF rather than by changing identifier names. On any multi-contributor codebase, non-English identifiers make the code unreadable to contributors who don't share that language. + +## Best Practice + +Write every identifier in English, and translate developer intent rather than transliterating it — when a requirement is described in another language, the resulting variable, procedure, and field names should still read as English. Captions and tooltips may carry target-language text in the source file, with locale translations managed through XLIFF. + +See sample: [`al-identifiers-english.good.al`](al-identifiers-english.good.al). + +## Anti Pattern + +Using native-language identifiers such as a Danish variable or procedure name in AL source code, relying on the fact that the code still compiles and runs correctly. This makes the code unreadable to non-native-language contributors and mixes localization concerns into source that should stay language-neutral. + +See sample: [`al-identifiers-english.bad.al`](al-identifiers-english.bad.al). diff --git a/microsoft/knowledge/style/binary-choice-must-be-boolean.bad.al b/microsoft/knowledge/style/binary-choice-must-be-boolean.bad.al new file mode 100644 index 0000000..2201ea3 --- /dev/null +++ b/microsoft/knowledge/style/binary-choice-must-be-boolean.bad.al @@ -0,0 +1,22 @@ +table 50100 "Sales Task" +{ + fields + { + field(1; "No."; Code[20]) { } + field(10; Status; Option) + { + OptionMembers = Active,Blocked; + } + } +} + +codeunit 50100 "Sales Task Check" +{ + procedure IsOverdue(DueDate: Date): Integer + begin + // 0 = No, 1 = Yes + if DueDate < Today then + exit(1); + exit(0); + end; +} diff --git a/microsoft/knowledge/style/binary-choice-must-be-boolean.good.al b/microsoft/knowledge/style/binary-choice-must-be-boolean.good.al new file mode 100644 index 0000000..ac62b0f --- /dev/null +++ b/microsoft/knowledge/style/binary-choice-must-be-boolean.good.al @@ -0,0 +1,16 @@ +table 50100 "Sales Task" +{ + fields + { + field(1; "No."; Code[20]) { } + field(10; Blocked; Boolean) { } + } +} + +codeunit 50100 "Sales Task Check" +{ + procedure IsOverdue(DueDate: Date): Boolean + begin + exit(DueDate < Today); + end; +} diff --git a/microsoft/knowledge/style/binary-choice-must-be-boolean.md b/microsoft/knowledge/style/binary-choice-must-be-boolean.md new file mode 100644 index 0000000..ec0c4a5 --- /dev/null +++ b/microsoft/knowledge/style/binary-choice-must-be-boolean.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [boolean, option, yes-no, magic-number, variable-typing, field-typing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Binary yes/no choices must be typed as Boolean, not Option or Integer + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +When a field or variable represents a genuine true/false state — yes/no, on/off, active/inactive, blocked/not blocked — it should be typed `Boolean`. Modeling that same predicate as an `Option`/`Enum` with two members, or as an `Integer` with two magic-number values, adds a layer of indirection a reader has to resolve before understanding the code. This is about semantics, not member count: a domain concept that currently has exactly two named alternatives — Inbound/Outbound, Debit/Credit, Buy/Sell — is not automatically a Boolean in disguise. An `Enum` can be the clearer model there, including when it needs to implement an interface, preserve an existing contract, or leave room for a future third value. The distinction is whether the domain is genuinely a stable predicate, not how many states it currently has. + +## Best Practice + +Type a field or variable as `Boolean` when the domain concept is inherently a true/false state. Do not replace a meaningful two-option domain model with a Boolean solely because it currently has two values. + +See sample: [`binary-choice-must-be-boolean.good.al`](binary-choice-must-be-boolean.good.al). + +## Anti Pattern + +Modeling a yes/no choice as an `Option` with two members, or as an `Integer` with magic-number values, forces every caller to remember which value means what and leaves room for a meaningless third value. + +See sample: [`binary-choice-must-be-boolean.bad.al`](binary-choice-must-be-boolean.bad.al). diff --git a/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.bad.al b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.bad.al new file mode 100644 index 0000000..77facce --- /dev/null +++ b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.bad.al @@ -0,0 +1,22 @@ +table 50101 "Course" +{ + fields + { + field(1; "No."; Code[20]) { } + // 1 = Beginner, 2..3 = Intermediate, 4+ = Advanced + field(10; Difficulty; Integer) { } + } +} + +codeunit 50100 "Course Level Helper" +{ + procedure LevelText(Difficulty: Integer): Text + begin + case Difficulty of + 1: + exit('Beginner'); + 2, 3: + exit('Intermediate'); + end; + end; +} diff --git a/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.good.al b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.good.al new file mode 100644 index 0000000..ef0175b --- /dev/null +++ b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.good.al @@ -0,0 +1,17 @@ +enum 50100 "Course Difficulty" +{ + Extensible = true; + + value(0; Beginner) { } + value(1; Intermediate) { } + value(2; Advanced) { } +} + +table 50101 "Course" +{ + fields + { + field(1; "No."; Code[20]) { } + field(10; Difficulty; Enum "Course Difficulty") { } + } +} diff --git a/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.md b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.md new file mode 100644 index 0000000..112b977 --- /dev/null +++ b/microsoft/knowledge/style/fixed-choice-set-must-use-enum-not-integer.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [enum, option, integer, magic-number, variable-typing, field-typing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A fixed set of named choices must use Enum, not a raw Integer + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +When a variable or field represents a fixed set of named, mutually exclusive states — a difficulty level, a document type, a processing status — it should be typed as `Enum` (or `Option` when extending an object that still uses the legacy type). Representing that same state as a plain `Integer` and tracking the meaning of each value in a comment or in a developer's head is a magic-number anti-pattern: the compiler cannot catch an out-of-range value, and branches read as opaque numbers instead of names. This is about semantics, not member count, matching `binary-choice-must-be-boolean.md`'s own distinction: a domain concept that is genuinely a stable true/false predicate belongs in `Boolean` even if someone represents it as a two-value `Enum`, while a domain concept with exactly two current named states is not automatically a Boolean in disguise — it stays an `Enum` when the states are named alternatives rather than a yes/no flag, or when it needs to implement an interface, preserve an existing contract, or leave room for a future third value. + +## Best Practice + +Declare an `Enum` with named values and branch on the enum value, not a raw number. + +See sample: [`fixed-choice-set-must-use-enum-not-integer.good.al`](fixed-choice-set-must-use-enum-not-integer.good.al). + +## Anti Pattern + +Using a plain `Integer` field with the meaning of each value tracked only in a comment pushes the documentation of the states into something the compiler cannot check and a future maintainer cannot rely on. + +See sample: [`fixed-choice-set-must-use-enum-not-integer.bad.al`](fixed-choice-set-must-use-enum-not-integer.bad.al). diff --git a/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.bad.al b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.bad.al new file mode 100644 index 0000000..755991b --- /dev/null +++ b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.bad.al @@ -0,0 +1,12 @@ +codeunit 50100 "Sales Task Notify" +{ + procedure NotifyUpdate(Count: Integer; CustomerNo: Code[20]) + begin + MESSAGE('%1 records updated.', Count); + + IF NOT CONFIRM('Delete %1?', FALSE, CustomerNo) THEN + EXIT; + + ERROR(STRSUBSTNO('%1 must not be blank.', CustomerNo)); + end; +} diff --git a/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.good.al b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.good.al new file mode 100644 index 0000000..a8551b4 --- /dev/null +++ b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.good.al @@ -0,0 +1,12 @@ +codeunit 50100 "Sales Task Notify" +{ + procedure NotifyUpdate(Count: Integer; CustomerNo: Code[20]) + begin + Message('%1 records updated.', Count); + + if not Confirm('Delete %1?', false, CustomerNo) then + exit; + + Error(StrSubstNo('%1 must not be blank.', CustomerNo)); + end; +} diff --git a/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.md b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.md new file mode 100644 index 0000000..761650b --- /dev/null +++ b/microsoft/knowledge/style/intrinsic-al-functions-must-use-modern-casing.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [casing, intrinsic-function, built-in-function, message, error, confirm, strsubstno, legacy, c-al, pascalcase] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Intrinsic AL function calls use modern casing, not legacy ALL-CAPS + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +AL is case-insensitive, so `MESSAGE(...)`, `ERROR(...)`, `CONFIRM(...)`, and `STRSUBSTNO(...)` compile and run identically to `Message(...)`, `Error(...)`, `Confirm(...)`, and `StrSubstNo(...)`. Modern AL — Microsoft's own current samples and current reference codebases — writes intrinsic/built-in function calls in the casing Microsoft assigns to the function's declared name, typically PascalCase. This is a codebase-convention claim, not a claim about what the VS Code formatter enforces: the formatter normalizes particular syntax but is not a mechanism for recasing every intrinsic function call, so do not cite formatter behavior as the reason to follow this convention. ALL-CAPS calls are a holdover from classic C/AL and signal code that has not been modernized, even though it compiles and runs correctly. Reserved keywords such as `if`, `begin`, and `for` are a separate, already-tooled concern; intrinsic function names are identifiers, not keywords, so that tooling does not catch ALL-CAPS intrinsic function calls. + +## Best Practice + +Call intrinsic functions in their modern, PascalCase form. + +See sample: [`intrinsic-al-functions-must-use-modern-casing.good.al`](intrinsic-al-functions-must-use-modern-casing.good.al). + +## Anti Pattern + +ALL-CAPS intrinsic function calls trip no compiler error, but they are a reliable signal that a code block was copied from old C/AL material or outdated training content rather than written against current AL conventions. + +See sample: [`intrinsic-al-functions-must-use-modern-casing.bad.al`](intrinsic-al-functions-must-use-modern-casing.bad.al). diff --git a/microsoft/knowledge/style/namespace-must-be-verified-from-source.bad.al b/microsoft/knowledge/style/namespace-must-be-verified-from-source.bad.al new file mode 100644 index 0000000..0cd54b7 --- /dev/null +++ b/microsoft/knowledge/style/namespace-must-be-verified-from-source.bad.al @@ -0,0 +1,13 @@ +namespace Contoso.Extensions; + +using System.Performance; // guessed; never verified against the source file + +codeunit 50100 "Tooling Extension" +{ + procedure Run() + var + ToolingPage: Page "Some Tooling Page"; // guessed namespace; can still resolve against a stale or cached symbol package, then fail once checked against the object's current source or a freshly downloaded one + begin + ToolingPage.Run(); + end; +} diff --git a/microsoft/knowledge/style/namespace-must-be-verified-from-source.good.al b/microsoft/knowledge/style/namespace-must-be-verified-from-source.good.al new file mode 100644 index 0000000..2a8e34a --- /dev/null +++ b/microsoft/knowledge/style/namespace-must-be-verified-from-source.good.al @@ -0,0 +1,16 @@ +// Verified by reading line 1 of the source file for "Some Tooling Page": +// namespace System.Tooling; + +namespace Contoso.Extensions; + +using System.Tooling; + +codeunit 50100 "Tooling Extension" +{ + procedure Run() + var + ToolingPage: Page "Some Tooling Page"; + begin + ToolingPage.Run(); + end; +} diff --git a/microsoft/knowledge/style/namespace-must-be-verified-from-source.md b/microsoft/knowledge/style/namespace-must-be-verified-from-source.md new file mode 100644 index 0000000..52e8bbc --- /dev/null +++ b/microsoft/knowledge/style/namespace-must-be-verified-from-source.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [namespace, verification, source-of-truth, using-statement] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Resolve a namespace from the referenced object's source or symbols, never by guessing + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Since Business Central 2024 release wave 1, Microsoft's own objects are organized under a deep `Microsoft.*` namespace tree that has been renamed and restructured repeatedly. When adding a `using` directive for an existing AL object (table, codeunit, page, enum, interface, etc.), guessing its namespace from the object's name, from an older codebase, or from general familiarity produces a statement that can look plausible and still resolve to the wrong object, or fail to resolve at all, once checked against the object's actual current namespace. The reliable sources are the object's own source file (its `namespace` declaration) or, for a dependency without accessible source, its AL symbol package — not the object's name or a remembered convention. + +## Best Practice + +When referencing an existing AL object, resolve its namespace from that object's actual source file or symbol definition — never infer or invent one from its name, functional area, or naming convention. + +See sample: [`namespace-must-be-verified-from-source.good.al`](namespace-must-be-verified-from-source.good.al). + +## Anti Pattern + +Writing a `using` statement from memory, from an incomplete path, or from a plausible-looking guess. It can appear correct while actually resolving to the wrong object, or fail to resolve, once checked against stale or mismatched symbols, a different build configuration, or the object's actual current source — not because the compiler and the AL Language Server apply different namespace-resolution rules; they don't. + +See sample: [`namespace-must-be-verified-from-source.bad.al`](namespace-must-be-verified-from-source.bad.al). diff --git a/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.bad.al b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.bad.al new file mode 100644 index 0000000..5c9dbe7 --- /dev/null +++ b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.bad.al @@ -0,0 +1,15 @@ +codeunit 50100 "Customer Lookup" +{ + procedure GetCustomerName(CustomerNo: Code[20]; var Name: Text[100]) + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + Name := Customer.Name; + end; + + procedure Sample() + begin + GetCustomerName('10000', 'placeholder'); // compile error: literal is not addressable + end; +} diff --git a/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.good.al b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.good.al new file mode 100644 index 0000000..3c20da1 --- /dev/null +++ b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.good.al @@ -0,0 +1,17 @@ +codeunit 50100 "Customer Lookup" +{ + procedure GetCustomerName(CustomerNo: Code[20]; var Name: Text[100]) + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + Name := Customer.Name; + end; + + procedure Sample() + var + CustName: Text[100]; + begin + GetCustomerName('10000', CustName); + end; +} diff --git a/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.md b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.md new file mode 100644 index 0000000..81f6fbb --- /dev/null +++ b/microsoft/knowledge/style/var-parameters-require-an-addressable-variable.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [var-parameter, by-reference, pass-by-reference, literal, constant, compile-error, procedure-call] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A `var` parameter must be called with a variable, never a literal or expression + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +When a procedure declares a parameter with `var`, that parameter is passed by reference: the callee writes back into the caller's own memory location. This means the argument at the call site must be an actual variable, something with an address. A string literal, a numeric constant, or a computed expression has no address to write back to, so passing one to a `var` parameter fails to compile. Before writing a call, check the callee's signature for `var` on each parameter position being supplied a literal or expression — if present, a variable declared in the caller's own scope must be used instead. + +## Best Practice + +Declare a variable in the caller's scope and pass it to the `var` parameter. + +See sample: [`var-parameters-require-an-addressable-variable.good.al`](var-parameters-require-an-addressable-variable.good.al). + +## Anti Pattern + +Passing a literal or a computed expression to a `var` parameter position fails to compile, because neither has an address the callee can write back to. + +See sample: [`var-parameters-require-an-addressable-variable.bad.al`](var-parameters-require-an-addressable-variable.bad.al). diff --git a/microsoft/knowledge/testing/test-data-must-be-random-and-complete.bad.al b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.bad.al new file mode 100644 index 0000000..34c13e4 --- /dev/null +++ b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.bad.al @@ -0,0 +1,14 @@ +[Test] +procedure PostsSalesOrderForCashCustomer() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; +begin + // Assumes a 'CASH' customer already exists in the environment — + // fails on any database where it doesn't. + Customer.Get('CASH'); + LibrarySales.CreateSalesHeader( + SalesHeader, SalesHeader."Document Type"::Order, Customer."No."); + + // ... add lines, post, assert ... +end; diff --git a/microsoft/knowledge/testing/test-data-must-be-random-and-complete.good.al b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.good.al new file mode 100644 index 0000000..75518f1 --- /dev/null +++ b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.good.al @@ -0,0 +1,13 @@ +[Test] +procedure PostsSalesOrderForRandomCustomer() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; +begin + // Freshly created customer, owned by this test — no assumption about what exists. + LibrarySales.CreateCustomer(Customer); + LibrarySales.CreateSalesHeader( + SalesHeader, SalesHeader."Document Type"::Order, Customer."No."); + + // ... add lines, post, assert ... +end; diff --git a/microsoft/knowledge/testing/test-data-must-be-random-and-complete.md b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.md new file mode 100644 index 0000000..c78a0d5 --- /dev/null +++ b/microsoft/knowledge/testing/test-data-must-be-random-and-complete.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: testing +keywords: [testing, test-data, random, library, any] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Generate Test Data Programmatically, Never Assume Existing Records + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A BC test company normally contains initialized system/setup data — an AL test suite should not assume an empty database, but it must be independent of unrelated business records: create the records and setup it owns rather than looking up a specific code, number, or name assumed to already exist, since that makes the test fail for reasons unrelated to the code under test. Every mandatory field on a created record also needs an actual value — leaving one blank because setup-time validation happens to allow it produces a record that doesn't reflect a real one and can fail later, elsewhere in the flow (posting, a report, a later assertion), for a reason unrelated to what the test claims to check. A short-but-valid value is not itself a defect: AL field lengths are maxima, not minimums, so a two-character value in a `Text[100]` field is fine unless the scenario specifically depends on the field's length or shape — for example, a test that verifies truncation or a format check needs a value chosen to exercise that boundary, not an arbitrary short one. + +Not every value should be generated, though. Incidental fixture data — identifiers, names, descriptions — should generally come from the standard library codeunits rather than be tied to specific existing data. But values that materially define the scenario under test — amounts, quantities, percentages, dates, thresholds, rounding precision — should stay explicit and deliberately chosen, not randomized: a rounding test needs values placed deliberately around the rounding boundary, not a random one that might miss it entirely. + +## Best Practice + +Use the standard library codeunits (`Library - ERM`, `Library - Inventory`, `Library - Sales`, `Library - Utility`) to generate incidental fixture values — they produce valid, unique-enough data via number series and controlled randomness, not a mathematical collision-free guarantee — and fill every mandatory field with correctly-sized data. Keep values that define the scenario's expected outcome explicit and fixed. Reserve hardcoded values for tests that validate an external contract itself — a fixed JSON schema, an EDIFACT message, a counterparty code — where the hardcoded value documents the specification rather than arbitrary test logic. + +See sample: [`test-data-must-be-random-and-complete.good.al`](test-data-must-be-random-and-complete.good.al). + +## Anti Pattern + +Looking up a record assumed to already exist (a hardcoded payment method or customer number) instead of creating it, or leaving a mandatory field empty because setup-time validation happens to allow it. Also an anti-pattern, narrower: using a value that doesn't satisfy a scenario's explicit length or format requirement — for example a truncation test that never actually exceeds the field it's meant to overflow. + +See sample: [`test-data-must-be-random-and-complete.bad.al`](test-data-must-be-random-and-complete.bad.al). diff --git a/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.bad.al b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.bad.al new file mode 100644 index 0000000..8b8e240 --- /dev/null +++ b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.bad.al @@ -0,0 +1,27 @@ +page 50101 "Customer Info API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'customerInfo'; + EntitySetName = 'customerInfos'; + SourceTable = Customer; + ODataKeyFields = "No."; + InsertAllowed = true; + + layout + { + area(content) + { + repeater(General) + { + field(customerNo; Rec."No.") + { + Editable = false; // consumer must supply "No." on POST — this rejects it + } + field(name; Rec.Name) { } + } + } + } +} diff --git a/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.good.al b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.good.al new file mode 100644 index 0000000..af6c5de --- /dev/null +++ b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.good.al @@ -0,0 +1,25 @@ +page 50101 "Customer Info API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'sales'; + APIVersion = 'v1.0'; + EntityName = 'customerInfo'; + EntitySetName = 'customerInfos'; + SourceTable = Customer; + ODataKeyFields = "No."; + InsertAllowed = true; + DelayedInsert = true; + + layout + { + area(content) + { + repeater(General) + { + field(customerNo; Rec."No.") { } + field(name; Rec.Name) { } + } + } + } +} diff --git a/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.md b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.md new file mode 100644 index 0000000..84887c1 --- /dev/null +++ b/microsoft/knowledge/web-services/api-page-key-fields-must-be-editable-on-insert.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: web-services +keywords: [api-page, key-fields, editable, insert, odata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep Consumer-Provided Key Fields Editable on API Pages + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A field listed in `ODataKeyFields` cannot have `Editable = false` when the API page allows inserts and the field's value must be supplied by the caller. Marking it read-only removes the field from the OData write schema, so a POST that includes it is rejected as an unknown property. This only applies to keys the consumer must supply — a system-generated key such as `SystemId` is a valid exception, since Business Central assigns its value automatically on insert. + +## Best Practice + +Leave every consumer-supplied key field referenced in `ODataKeyFields` without `Editable = false` on pages where `InsertAllowed = true`, so the OData layer accepts it as a writable property on POST. + +See sample: [`api-page-key-fields-must-be-editable-on-insert.good.al`](api-page-key-fields-must-be-editable-on-insert.good.al). + +## Anti Pattern + +Marking a consumer-provided key field `Editable = false`, out of habit or for perceived safety. This silently breaks create operations with a generic `BadRequest` instead of a clear validation error. + +See sample: [`api-page-key-fields-must-be-editable-on-insert.bad.al`](api-page-key-fields-must-be-editable-on-insert.bad.al). diff --git a/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.bad.al b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.bad.al new file mode 100644 index 0000000..9938612 --- /dev/null +++ b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.bad.al @@ -0,0 +1,24 @@ +page 50102 "Project Task API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'jobs'; + APIVersion = 'v1.0'; + EntityName = 'projectTask'; + EntitySetName = 'projectTasks'; + SourceTable = "Project Task"; + + layout + { + area(content) + { + repeater(General) + { + // "Remaining Hours" is a stored field, set inside the + // OnValidate of "Budgeted Hours" — it goes stale whenever + // "Hours Used" changes through any other path. + field(remainingHours; Rec."Remaining Hours") { } + } + } + } +} diff --git a/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.good.al b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.good.al new file mode 100644 index 0000000..1ab78db --- /dev/null +++ b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.good.al @@ -0,0 +1,33 @@ +page 50102 "Project Task API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'jobs'; + APIVersion = 'v1.0'; + EntityName = 'projectTask'; + EntitySetName = 'projectTasks'; + SourceTable = "Project Task"; + DelayedInsert = true; + + layout + { + area(content) + { + repeater(General) + { + field(remainingHours; RemainingHoursCalc) { } + field(budgetedHours; Rec."Budgeted Hours") { } + field(hoursUsed; Rec."Hours Used") { } + } + } + } + + trigger OnAfterGetRecord() + begin + Rec.CalcFields("Hours Used"); + RemainingHoursCalc := Rec."Budgeted Hours" - Rec."Hours Used"; + end; + + var + RemainingHoursCalc: Decimal; +} diff --git a/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.md b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.md new file mode 100644 index 0000000..2d02f93 --- /dev/null +++ b/microsoft/knowledge/web-services/stored-derived-fields-must-not-be-exposed-directly.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: web-services +keywords: [api-page, derived-fields, exposure, odata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Recalculate Stored Derived Fields Before Exposing Them on API Pages + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +A stored field whose value is derived from other fields inside an `OnValidate` trigger only updates when that specific trigger fires. If the underlying source data changes through some other path, the stored value goes stale without raising any error. Exposing such a field directly on an API page hands external consumers a snapshot that may be significantly out of date. + +## Best Practice + +Recalculate the derived value in `OnAfterGetRecord` from its authoritative source — typically a FlowField — using a page-level variable, and expose that recalculated value instead of the stale stored field. Whether to also expose the source fields is a separate design decision, not a requirement of this pattern; keep the API contract scoped to what consumers actually need. If letting the consumer verify the recalculation is itself a requirement, expose every field the calculation reads, not just one of them — a derived value with two inputs needs both exposed, or the "verification" is incomplete. + +See sample: [`stored-derived-fields-must-not-be-exposed-directly.good.al`](stored-derived-fields-must-not-be-exposed-directly.good.al). + +## Anti Pattern + +Exposing the stored field directly via `Rec`, trusting that it was kept in sync by whichever trigger last touched it. + +See sample: [`stored-derived-fields-must-not-be-exposed-directly.bad.al`](stored-derived-fields-must-not-be-exposed-directly.bad.al). diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index 3f6a221..2efc049 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -52,6 +52,7 @@ The following targeted checks cover every current `appsource` article across the - A page or report that repository context identifies as a direct user entry point omits `UsageCategory` or sets it to `None` — `set-usagecategory-on-searchable-entry-points`. Do not select this article based only on object type; exclude supporting parts, dialogs, API pages, and objects intentionally reached through another page. - A `DateTime` assignment adds or subtracts a fixed duration to represent an assumed regional offset — `do-not-hard-code-time-zone-offsets`. Require contextual evidence such as an hour-sized constant, offset-oriented name, or time-zone comment; do not flag deadlines, schedules, or elapsed-time calculations. - For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. +- Changed code declares or calls `File.Open`/`File.Create`/`File.Read`/`File.Write` in an app targeting Business Central Online — `file-datatype-saas`. 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`. diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md index 7f30713..5e2f3aa 100644 --- a/microsoft/skills/review/al-breaking-changes-review.md +++ b/microsoft/skills/review/al-breaking-changes-review.md @@ -50,7 +50,9 @@ The following targeted checks cover every current `breaking-changes` article: - A published procedure changes parameter count/order/type/name, `var`, return type, or array shape instead of preserving the old signature and adding an overload — `do-not-change-published-procedure-signatures`. - A public procedure/event/interface exposes a credential or other sensitive value through `Text` or an externally callable contract — `do-not-expose-sensitive-data-through-public-api`. - Code already marked obsolete is expanded with new behavior instead of routing new callers to its replacement — `do-not-modify-code-already-marked-obsolete`. -- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data — `obsolete-table-fields-instead-of-deleting-them`. +- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data — `obsolete-table-fields-instead-of-deleting-them`. This owns AS0005 field-name changes; do not substitute the namespace article. +- A published object's namespace changes between the base and changed source while its identity otherwise remains — `namespace-is-part-of-published-object-identity`. Do not apply it to a new, unshipped object or to an ordinary object-name change with no namespace change. +- New or changed code calls `Codeunit Mail`'s `CreateMessage`/`Send`/`GetErrorDesc` instead of `Codeunit Email`/`Codeunit "Email Message"` — `prefer-email-module`. For `obsolete-table-fields-instead-of-deleting-them`, compare the baseline ID and name before emitting. When the original field remains under the same ID and name with `ObsoleteState = Pending`, and the replacement uses a new ID, the change follows the rule and must not be flagged. diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 3fca8ec..2318304 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -54,6 +54,10 @@ The following targeted checks cover every current `data-modeling` article. Treat - A `Media` or `MediaSet` field is assigned directly between different table types or different field IDs instead of registering each shared item with `MediaSet.Insert` — `share-mediaset-items-with-insert-not-field-assignment`. - A custom document header assigns defaults outside an `InitRecord` boundary, calls `InitRecord` before assigning its number, or places UI-independent defaults only in a page trigger — `initialize-document-defaults-in-initrecord`. - Directed `Round` calls use `'<'` as mathematical floor or `'>'` as mathematical ceiling, especially where negative amounts are possible — `round-direction-symbols-use-magnitude`. +- A new field is typed `Code`/`Text` and its `OnValidate` calls `DimensionManagement`/`DimMgt`, or a table adds Shortcut Dimension fields, a `Dimension Set ID` field, or `AddDimSource`/`GetDefaultDimID` — `dimension-management-wiring`. A master table calling `SaveDefaultDim` and a document/journal table computing its own `Dimension Set ID` are two different valid shapes; do not flag a master table for lacking a `Dimension Set ID` field or a document for lacking `SaveDefaultDim`. +- A journal-based posting codeunit is added or changed and validation, Journal-table access, ledger writes, and user-interaction (`Confirm`/dialogs) all occur in one procedure or one codeunit, rather than split across `Check Line`/`Post Line`/`Post Batch`-shaped companions — `check-post-line-batch-pattern`. A document posting routine calling `Post Line` directly without a `Post Batch` companion is not this anti-pattern. +- An existing, already-published table's `keys` block adds, removes, or reorders a field in its primary key or any `Clustered = true` key — `do-not-change-primary-key`. A new table defining its own key for the first time is not this anti-pattern; requires repository/publication context to know the table has already shipped. +- Code reads a setup/configuration-table field inside a branch that has already decided the value is required, and blank/zero is handled with a fallback to a default rather than `TestField`/an equivalent guard — `testfield-required-setup-field`. A read that is genuinely optional in that branch, or one already guarded by `TestField`, is not this anti-pattern. 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`. diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 7829d70..6109f7b 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -58,6 +58,7 @@ Apply these targeted cues even when simple token overlap would rank the article - Worklist `job-queue-on-hold-does-not-stop-running-work.md` when a running job queue handler polls the entry's `Status` or `On Hold` value as a cancellation signal. Exclude application-owned stop requests that are checked before every bounded unit of work, including the first, when completed work and its checkpoint remain consistent and resume logic clears the request. - Worklist `job-queue-category-code-serializes-conflicting-jobs.md` when two or more job queue entries in the same company are shown by the changed context to require mutual exclusion but have empty or different Job Queue Category Codes. Do not infer a conflict merely because jobs touch the same tables, and do not recommend a category to coordinate across companies, environments, or workers outside the job queue dispatcher. - Worklist `store-scheduled-task-id-to-avoid-duplicate-tasks.md` when `TaskScheduler.CreateTask` runs from initialization, login, setup, or another repeatable path without persisting its returned GUID and checking it with `TaskScheduler.TaskExists` before creating a replacement. Exclude one-shot creation and correctly persisted check-before-create flows; concurrent callers still require serialization around that sequence. +- A new or changed report object whose usage/name/caption identifies it as a single-record document (invoice, statement, order confirmation) sets or retains `DefaultRenderingLayout = RDLC` — `document-report-word-layout.md`. Do not worklist this from a tabular/list report with heavy aggregation or calculated columns; RDLC/Excel remains the better fit there. These targeted inclusions and exclusions override generic token overlap. Do not retain an excluded article solely because the diff contains one of its keywords. diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 5b0b6b6..0cc747e 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -40,8 +40,8 @@ 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. For each relevant file, compute overlap against: - Changed AL objects — especially API pages (`PageType = API`), tables and pages declaring Labels/TextConsts, codeunits issuing `Error`/`Message`/`Confirm`, and any file whose name violates the `..al` convention. -- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, `DateFormula` declarations and their `Evaluate` call sites, error-handling call sites, and API declarations. -- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `DateFormula`, `Evaluate`, `CalcDate`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `StrSubstNo`). +- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, option fields, `DateFormula` declarations and their `Evaluate` call sites, error-handling call sites, API declarations, and codeunit-internal method calls. +- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `DateFormula`, `Evaluate`, `CalcDate`, `OptionMembers`, `OptionCaption`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`, `namespace`, `using`, `var `). 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 or declaration. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -51,6 +51,14 @@ Apply these high-signal mappings before fuzzy topic ranking: - A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder — `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged. - A normal two-argument `Evaluate` has a resolved `DateFormula` destination and a hard-coded non-angle-bracket date-formula literal, directly or through a visible constant — `dateformula-evaluate-needs-language-independent-literals.md`. Do not use this cue for dynamic/localized external input, already invariant `<...>` input, or direct `CalcDate(Text, ...)` calls. +- `function-call-parentheses-required.md` applies only to a zero-argument invocation written without `()`. Never worklist it from an invocation that already has parentheses or supplies arguments, including `Error(Label, Arg1, Arg2)`. +- A new `.app` build artifact appears at the project root or another unversioned/arbitrary location, or is added to source control alongside the AL source that produced it — `al-build-output-must-not-pollute-project-root.md`. A deliberate `--outfolder`/`outputPath` destination added to `.gitignore` is the compliant shape, not the signal to flag. +- A new or renamed AL identifier (variable, procedure, parameter, field, object, enum value, or label identifier) contains non-English words — `al-identifiers-english.md`. A caption, tooltip, or other user-facing text value in a non-English language is not this anti-pattern; only the identifier itself is in scope. +- A field or variable is typed `Boolean` and its two possible values are genuinely named domain alternatives (a status pair like Inbound/Outbound, Debit/Credit, Buy/Sell) rather than a true/false predicate, or an `Option`/`Enum`/`Integer` models a domain concept that is intrinsically a yes/no flag — `binary-choice-must-be-boolean.md`. The signal is a semantic mismatch between the type and the domain concept, not the current number of states. +- A field or variable is typed `Integer` with the meaning of each value tracked only in a comment, or an `Enum` with more than two members is proposed as `Boolean`-like — `fixed-choice-set-must-use-enum-not-integer.md`. Do not flag a genuinely two-state `Enum`/`Option` for having "too few" members; that overlaps `binary-choice-must-be-boolean.md` instead when the domain is a true/false predicate. +- A new `using` directive is added for an existing AL object without the diff also showing that object's own `namespace` declaration or a symbol-package lookup backing the choice — `namespace-must-be-verified-from-source.md`. Require repository/dependency context; a single new `using` line cannot itself prove whether the namespace was verified or guessed. +- An intrinsic/built-in AL function call (`MESSAGE`, `ERROR`, `CONFIRM`, `STRSUBSTNO`, etc.) is written in ALL-CAPS or another non-PascalCase form — `intrinsic-al-functions-must-use-modern-casing.md`. +- A procedure call passes a literal or a computed expression (not a caller-scope variable) to a parameter position the callee declares `var` — `var-parameters-require-an-addressable-variable.md`. This is a compile-time-guaranteed shape; flag it only when the callee's declared signature is visible in the diff or resolvable from context. Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index c96ac83..de0c9be 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -51,6 +51,7 @@ The following targeted checks cover every current `testing` article. Treat each - 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`. - `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. +- A test's `[GIVEN]`/setup looks up a hardcoded code/number/name assumed to already exist instead of creating it, leaves a mandatory field on a created record empty, uses a value that doesn't satisfy the scenario's own explicit length/format requirement (for example a truncation test whose value never exceeds the field), or a scenario-defining value (amount, quantity, percentage, date, threshold, rounding precision) is generated/randomized instead of an explicit chosen value — `test-data-must-be-random-and-complete`. Generating incidental fixture values (identifiers, names, descriptions) via the standard library codeunits is the compliant shape, not the signal to flag, and neither is a short-but-valid value in an otherwise-unremarkable field. 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`. diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index 19d0735..c486c88 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -40,6 +40,8 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially pages declared with `PageType = API`, API page `part` controls, queries declared with `QueryType = API`, and procedures that expose bound actions. - The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`, `SourceTableTemporary`), navigation metadata (`SubPageLink`, `Multiplicity`, and visible singleton or collection semantics), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields. - Webhook subscriber handlers and subscription lifecycle code, especially code that creates or renews subscriptions, handles `validationToken`, schedules from `expirationDateTime`, or targets resources whose eligibility is visible in the diff. +- An API page (`PageType = API`) with `InsertAllowed = true` lists a field in `ODataKeyFields` and that same field control sets `Editable = false` — `api-page-key-fields-must-be-editable-on-insert.md`. A system-generated key such as `SystemId` marked read-only is not this anti-pattern. +- An API page exposes a field via `Rec` directly, and that field is a stored value computed from other fields inside an `OnValidate` trigger rather than a FlowField recalculated in `OnAfterGetRecord` — `stored-derived-fields-must-not-be-exposed-directly.md`. Exposing a genuine FlowField, or a value already recalculated in `OnAfterGetRecord`, is not this anti-pattern. - 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. From 186691f815c288a06cd538075729e98dc5469013 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:49:11 +0200 Subject: [PATCH 31/51] 3 AL/BC patterns from CURABIS's internal automated-testing training material (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add 3 more AL/BC patterns from CURABIS Academy testing course material Third batch from CURABIS ApS: item-ledger-entry document-no lookup after Ship-and-Invoice posting, TestPage.Visible()/.Enabled() as the mechanism for verifying field UI state, and LibraryUtility.GenerateGUID() for collision-free test fixture values. * Address Jesper Schulz-Wedde's review on PR #158 - use-generateguid-for-unique-test-fixture-values.md: GenerateGUID() is a Code[10] number-series value, not a real GUID; truncating it with CopyStr for a shorter field cuts off the changing digits. Point to GenerateRandomCode/GenerateRandomCodeWithLength/GenerateRandomXMLText instead, which verify uniqueness against the actual table. - Split use-testpage-visible-enabled-to-verify-field-ui-state.md: drop its editability claim (the sample opens with OpenView() and asserts Enabled(), which verifies enabled state, not editability — Editable() and Enabled() are distinct TestField methods). New companion article use-testpage-editable-to-verify-field-editability.md covers Editable() with OpenEdit() specifically. - Wire GenerateGUID/CopyStr and TestPage Visible/Enabled/Editable cues into al-testing-review.md, and the Item Ledger Entry/Last Shipping No. posting cue into al-data-modeling-review.md. The Item Ledger Entry article itself was independently verified against current BCApps source and needs no changes. Co-Authored-By: Claude Sonnet 5 * Address second round of Jesper Schulz-Wedde's review on PR #158 - use-generateguid-for-unique-test-fixture-values.md/.good.al: documented each LibraryUtility helper's actual behavior, verified against LibraryUtility.Codeunit.al. GenerateRandomCode opens the target table as a temporary RecordRef, so despite taking TableNo it never checks real data. GenerateRandomXMLText performs no table lookup at all. Only GenerateRandomCodeWithLength/GenerateRandomCode20 (capped at Code[10]/ Code[20]) genuinely verify against the real table. Fixture switched to GenerateRandomCodeWithLength where the comment claims verified uniqueness. - al-testing-review.md: rewired the cue to catch the actual anti-pattern (hardcoded literals, hand-built uniqueness, short-field GUID truncation) instead of only matching the compliant GenerateGUID()+CopyStr shape; broadened tokens to include TestPage, Library - Utility, and .Visible()/.Enabled()/.Editable(). - al-data-modeling-review.md: restricted the Item Ledger Entry Last-Shipping-No. cue to sales combined posting; purchase combined posting is Receive+Invoice and uses different fields entirely. Co-Authored-By: Claude Sonnet 5 * Fix remaining correctness issues from Jesper's 2026-09-15 re-review - use-generateguid-for-unique-test-fixture-values.md: narrowed the collision rule to primary-key/unique-lookup fields - an ordinary descriptive field carries no uniqueness constraint, so a hardcoded or deterministic value there isn't the anti-pattern (the article and its al-testing-review.md worklist cue both said "primary-key or descriptive field"). Also corrected GenerateRandomCode: it opens its target table as a temporary RecordRef that starts and stays empty, so its repeat/until loop always exits after one iteration and never retries even within a single test run - the "non-colliding within a test run" claim was false. It's the rightmost N characters of GenerateGUID()'s sequential series, so a short field's value cycles (Code[1] repeats every 10 calls, Code[2] every 100). Verified against LibraryUtility.Codeunit.al in the BCApps reference clone. - item-ledger-entry-document-no-follows-last-shipping-no: both fixtures called FindSet() without consuming its optional Boolean, which raises a runtime error on an empty result set - the opposite of the article's own claimed "silently matches zero rows, no error" behavior. Wrapped in `if ... then;` per the existing guard-database-reads.good.al idiom. - al-data-modeling-review.md: widened both not-applicable scope clauses (intro and outcome) to include dimension wiring, posting- routine structure, and Item Ledger Entry document-number lookups - the leaf declared itself not-applicable outside setup/master/key/ numbering/block/audit surfaces despite having a targeted cue for this PR's own new article. - Converted this PR's 8 plain-backtick "See sample: `x.good.al`." references (across all 4 new articles) to the READ-convention markdown-link form required by Knowledge-Retrieval.ps1. Rebased onto upstream/main (one conflict in al-data-modeling-review.md intro wording, merged). * Stop routing GenerateRandomCode20 as compliant for shorter fields al-testing-review.md's cue presented GenerateRandomCodeWithLength and GenerateRandomCode20 as interchangeable options for "a shorter field needing real verified uniqueness." They aren't: verified against LibraryUtility.Codeunit.al, GenerateRandomCode20 truncates GenerateGUID()'s sequential value down to the target field's length by keeping the leftmost (slowest-changing) characters via PadStr, so its retry loop against a field shorter than 20 can churn through the same truncated prefix for a long time. GenerateRandomCodeWithLength has no such problem (it generates exactly the requested length of random text). The knowledge article itself already scoped GenerateRandomCode20 to Code[20] correctly - only the skill cue needed narrowing to match. --------- Co-authored-by: Claude Sonnet 5 --- ...ocument-no-follows-last-shipping-no.bad.al | 12 +++++++ ...cument-no-follows-last-shipping-no.good.al | 13 ++++++++ ...ry-document-no-follows-last-shipping-no.md | 26 +++++++++++++++ ...guid-for-unique-test-fixture-values.bad.al | 10 ++++++ ...uid-for-unique-test-fixture-values.good.al | 21 ++++++++++++ ...rateguid-for-unique-test-fixture-values.md | 33 +++++++++++++++++++ ...ditable-to-verify-field-editability.bad.al | 27 +++++++++++++++ ...itable-to-verify-field-editability.good.al | 26 +++++++++++++++ ...ge-editable-to-verify-field-editability.md | 26 +++++++++++++++ ...le-enabled-to-verify-field-ui-state.bad.al | 14 ++++++++ ...e-enabled-to-verify-field-ui-state.good.al | 15 +++++++++ ...isible-enabled-to-verify-field-ui-state.md | 26 +++++++++++++++ .../skills/review/al-data-modeling-review.md | 5 +-- microsoft/skills/review/al-testing-review.md | 4 ++- 14 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.bad.al create mode 100644 microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.good.al create mode 100644 microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.md create mode 100644 microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.bad.al create mode 100644 microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.good.al create mode 100644 microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.md create mode 100644 microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.bad.al create mode 100644 microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.good.al create mode 100644 microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.md create mode 100644 microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.bad.al create mode 100644 microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.good.al create mode 100644 microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.md diff --git a/microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.bad.al b/microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.bad.al new file mode 100644 index 0000000..f068342 --- /dev/null +++ b/microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.bad.al @@ -0,0 +1,12 @@ +codeunit 50130 "Sample Item Ledger Lookup" +{ + procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry") + var + LibrarySales: Codeunit "Library - Sales"; + InvoiceNo: Code[20]; + begin + InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true); + ItemLedgerEntry.SetRange("Document No.", InvoiceNo); + if ItemLedgerEntry.FindSet() then; + end; +} diff --git a/microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.good.al b/microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.good.al new file mode 100644 index 0000000..f396d9b --- /dev/null +++ b/microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.good.al @@ -0,0 +1,13 @@ +codeunit 50130 "Sample Item Ledger Lookup" +{ + procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry") + var + LibrarySales: Codeunit "Library - Sales"; + ShippingNo: Code[20]; + begin + LibrarySales.PostSalesDocument(SalesHeader, true, true); + ShippingNo := SalesHeader."Last Shipping No."; + ItemLedgerEntry.SetRange("Document No.", ShippingNo); + if ItemLedgerEntry.FindSet() then; + end; +} diff --git a/microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.md b/microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.md new file mode 100644 index 0000000..3b5d939 --- /dev/null +++ b/microsoft/knowledge/data-modeling/item-ledger-entry-document-no-follows-last-shipping-no.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [item-ledger-entry, document-no, last-shipping-no, ship-and-invoice, posting, sales-order] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# After Ship-and-Invoice posting, Item Ledger Entry carries the shipment document number + +## Description + +Posting a sales order with both Ship and Invoice in one call creates the Item Ledger Entry during the shipment leg of that combined post, so the entry's `Document No.` is stamped with the value assigned to the shipment — `Sales Header."Last Shipping No."` — not the posted sales invoice number the posting call returns. Code that filters Item Ledger Entry by the invoice number instead finds nothing: `SetRange`/`FindSet` simply return zero rows, with no error to signal the mistake. + +## Best Practice + +After posting a sales order with Ship and Invoice together, read `SalesHeader."Last Shipping No."` (populated during the post) and filter Item Ledger Entry by that value, not by the invoice number the posting routine returns. + +See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.good.al`](item-ledger-entry-document-no-follows-last-shipping-no.good.al). + +## Anti Pattern + +Filtering Item Ledger Entry by the posted sales invoice number after a combined Ship-and-Invoice post. The filter compiles and runs without error but matches zero rows, because the entry belongs to the shipment leg of the posting, not the invoice leg. + +See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.bad.al`](item-ledger-entry-document-no-follows-last-shipping-no.bad.al). diff --git a/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.bad.al b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.bad.al new file mode 100644 index 0000000..c718bc7 --- /dev/null +++ b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.bad.al @@ -0,0 +1,10 @@ +codeunit 50132 "Sample Customer Type Library" +{ + procedure CreateCustomerType(var CustomerType: Record "Customer Type") + begin + CustomerType.Init(); + CustomerType.Code := 'TEST001'; + CustomerType.Description := 'Test Customer Type'; + CustomerType.Insert(true); + end; +} diff --git a/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.good.al b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.good.al new file mode 100644 index 0000000..25a194e --- /dev/null +++ b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.good.al @@ -0,0 +1,21 @@ +codeunit 50132 "Sample Customer Type Library" +{ + var + LibraryUtility: Codeunit "Library - Utility"; + + procedure CreateCustomerType(var CustomerType: Record "Customer Type") + begin + CustomerType.Init(); + // Code is shorter than GenerateGUID()'s 10 characters, and this field's + // uniqueness matters, so use GenerateRandomCodeWithLength: it opens the + // real (non-temporary) table and loops until the value doesn't collide. + // GenerateRandomCode would not do this — it opens the table as temporary, + // so its own emptiness check never inspects real rows. + CustomerType.Code := + LibraryUtility.GenerateRandomCodeWithLength(CustomerType.FieldNo(Code), Database::"Customer Type", MaxStrLen(CustomerType.Code)); + // Description is long enough to hold the full GenerateGUID() value + // untruncated, and only needs to be incidental, not verified-unique. + CustomerType.Description := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(CustomerType.Description)); + CustomerType.Insert(true); + end; +} diff --git a/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.md b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.md new file mode 100644 index 0000000..e06f738 --- /dev/null +++ b/microsoft/knowledge/testing/use-generateguid-for-unique-test-fixture-values.md @@ -0,0 +1,33 @@ +--- +bc-version: [all] +domain: testing +keywords: [generateguid, library-utility, test-fixtures, uniqueness, generaterandomcode, maxstrlen] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Generate unique test fixture values with LibraryUtility helpers, not hardcoded literals + +## Description + +A fixture helper that assigns a hardcoded literal to a primary-key field, or to any field the test relies on as a unique lookup identifier, collides the moment two tests, or two runs of the same test, create that fixture without cleanup — and a literal longer than the field allows raises a truncation or insert error. An ordinary descriptive field carries no such constraint: two rows with the same description do not collide on insert, and a deterministic descriptive value is often exactly what an exact-match assertion needs, so none of this applies to it. `LibraryUtility.GenerateGUID()` is not a real GUID — it is a `Code[10]` number-series value (`GU00000000`–`GU99999999`) — and it returns the full 10 characters unshortened. Truncating it yourself with `CopyStr(..., 1, MaxStrLen(ShorterField))` for a field under 10 characters is unsafe: the changing digits sit at the right end and are exactly what gets cut off, so consecutive calls into a short field can produce the same truncated value. `GenerateGUID()` is only safe as-is for a field that holds the full 10 characters. + +## Best Practice + +For a field that holds the full 10 characters, assign `LibraryUtility.GenerateGUID()` directly. For a shorter field, do not truncate a GUID yourself — but also do not assume every `LibraryUtility` helper verifies uniqueness against the real table, because they don't all behave the same way: + +- `GenerateRandomCode(FieldNo, TableNo)` opens the target table as a **temporary** `RecordRef`: the buffer starts and stays empty, so its `repeat...until RecRef.IsEmpty()` loop always exits after one iteration — despite taking `TableNo`, it never checks the real table, and it never retries even within its own call. Its value is the rightmost `FieldRef.Length` characters of `GenerateGUID()`'s sequential `GU00000000`–`GU99999999` series, so for a short field that window of digits cycles: a 1-character field repeats every 10 calls, a 2-character field every 100, and so on. It is a finite short-field namespace with a low collision *chance* within one test run — not a guarantee at any scope, unlike the table-checking helpers below. +- `GenerateRandomCodeWithLength(FieldNo, TableNo, CodeLength)` opens the real (non-temporary) table and loops until the generated value doesn't collide — a genuine verified-unique guarantee — but it returns `Code[10]` regardless of the requested `CodeLength`, so it's only useful for a field of 10 characters or fewer. +- `GenerateRandomCode20(FieldNo, TableNo)` is the same real, verified-against-the-table pattern as `GenerateRandomCodeWithLength`, sized for a `Code[20]` field. +- `GenerateRandomXMLText(Length)` performs no table lookup at all — it's a plain random-text generator, appropriate for a descriptive/incidental field where uniqueness doesn't matter, not for a value that needs to be collision-checked. + +Pick `GenerateRandomCodeWithLength`/`GenerateRandomCode20` when the test genuinely needs a code verified unique against the table; use `GenerateRandomCode`/`GenerateGUID`/`GenerateRandomXMLText` for incidental values where a low collision *chance* is enough. + +See sample: [`use-generateguid-for-unique-test-fixture-values.good.al`](use-generateguid-for-unique-test-fixture-values.good.al). + +## Anti Pattern + +Hardcoding a primary-key or unique-lookup fixture value such as `'TEST001'`, which collides across parallel or repeated test runs — a fixed descriptive value is not this anti-pattern, since the field carries no uniqueness constraint. Equally an anti-pattern: truncating `GenerateGUID()`'s result with `CopyStr(..., 1, MaxStrLen(Field))` for a field shorter than 10 characters — the truncation removes the part of the value that actually varies. + +See sample: [`use-generateguid-for-unique-test-fixture-values.bad.al`](use-generateguid-for-unique-test-fixture-values.bad.al). diff --git a/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.bad.al b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.bad.al new file mode 100644 index 0000000..ddaf0ff --- /dev/null +++ b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.bad.al @@ -0,0 +1,27 @@ +codeunit 50134 "Sample Customer Type Edit Test" +{ + Subtype = Test; + + [Test] + procedure CustomerTypeFieldNotEditable_WhenLocked() + var + Assert: Codeunit Assert; + CustomerType: Record "Customer Type"; + CustomerTypeCard: TestPage "Customer Type Card"; + begin + // [GIVEN] a customer type record whose Locked flag is set + CustomerType.Init(); + CustomerType.Locked := true; + CustomerType.Insert(true); + + // [WHEN] the page is opened in VIEW mode — editability logic that only + // applies in edit mode is not exercised the same way + CustomerTypeCard.OpenView(); + CustomerTypeCard.GoToRecord(CustomerType); + + // [THEN] wrong function: Enabled() does not verify editability + Assert.IsFalse(CustomerTypeCard.Description.Enabled(), 'Description should not be editable while Locked is set.'); + + CustomerTypeCard.Close(); + end; +} diff --git a/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.good.al b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.good.al new file mode 100644 index 0000000..d683f0b --- /dev/null +++ b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.good.al @@ -0,0 +1,26 @@ +codeunit 50133 "Sample Customer Type Edit Test" +{ + Subtype = Test; + + [Test] + procedure CustomerTypeFieldNotEditable_WhenLocked() + var + Assert: Codeunit Assert; + CustomerType: Record "Customer Type"; + CustomerTypeCard: TestPage "Customer Type Card"; + begin + // [GIVEN] a customer type record whose Locked flag is set + CustomerType.Init(); + CustomerType.Locked := true; + CustomerType.Insert(true); + + // [WHEN] the page is opened in edit mode on that record + CustomerTypeCard.OpenEdit(); + CustomerTypeCard.GoToRecord(CustomerType); + + // [THEN] the field's actual editable state reflects the lock + Assert.IsFalse(CustomerTypeCard.Description.Editable(), 'Description should not be editable while Locked is set.'); + + CustomerTypeCard.Close(); + end; +} diff --git a/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.md b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.md new file mode 100644 index 0000000..f94c792 --- /dev/null +++ b/microsoft/knowledge/testing/use-testpage-editable-to-verify-field-editability.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [testpage, editable, openedit, ui-state, field-verification] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Verify field editability with TestPage.Editable(), opened in edit mode + +## Description + +Whether a field can actually be changed is a distinct state from whether it is shown or enabled — `Editable()` and `Enabled()` are separate `TestField` functions. Verifying editability also requires opening the `TestPage` with `OpenEdit()`, not `OpenView()`: `OpenView()` opens the page in view mode, so it does not exercise the field's own conditional editability logic the way an actual edit-mode session does. + +## Best Practice + +Open the `TestPage` with `OpenEdit()`, navigate to the relevant record, then assert against `TestPageField.Editable()` to verify whether the field can be changed under the given precondition. + +See sample: [`use-testpage-editable-to-verify-field-editability.good.al`](use-testpage-editable-to-verify-field-editability.good.al). + +## Anti Pattern + +Asserting `Enabled()` (or checking nothing at all) when the actual claim is about editability, or opening the page with `OpenView()` when the field's editability depends on business logic that only applies in edit mode. + +See sample: [`use-testpage-editable-to-verify-field-editability.bad.al`](use-testpage-editable-to-verify-field-editability.bad.al). diff --git a/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.bad.al b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.bad.al new file mode 100644 index 0000000..410af39 --- /dev/null +++ b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.bad.al @@ -0,0 +1,14 @@ +codeunit 50131 "Sample Customer Type UI Test" +{ + Subtype = Test; + + [Test] + procedure CustomerTypeFieldIsEnabledOnCustomerCard() + var + CustomerCard: TestPage "Customer Card"; + begin + // Confirms only that the page opens - never checks the field's actual UI state + CustomerCard.OpenView(); + CustomerCard.Close(); + end; +} diff --git a/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.good.al b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.good.al new file mode 100644 index 0000000..6e03c06 --- /dev/null +++ b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.good.al @@ -0,0 +1,15 @@ +codeunit 50131 "Sample Customer Type UI Test" +{ + Subtype = Test; + + [Test] + procedure CustomerTypeFieldIsEnabledOnCustomerCard() + var + Assert: Codeunit Assert; + CustomerCard: TestPage "Customer Card"; + begin + CustomerCard.OpenView(); + Assert.IsTrue(CustomerCard."Customer Type".Enabled(), 'Customer Type should be enabled on the Customer Card.'); + Assert.IsTrue(CustomerCard."Customer Type".Visible(), 'Customer Type should be visible on the Customer Card.'); + end; +} diff --git a/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.md b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.md new file mode 100644 index 0000000..ff6bd86 --- /dev/null +++ b/microsoft/knowledge/testing/use-testpage-visible-enabled-to-verify-field-ui-state.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [testpage, visible, enabled, ui-state, headless-test, field-verification] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Verify field visibility and enabled state with TestPage.Visible()/.Enabled() + +## Description + +A UI test codeunit does not need to inspect table or page properties indirectly to confirm a field is shown or enabled under given conditions. The `TestPage` object exposes a `Visible()` and an `Enabled()` function on each field, reflecting the page's actual rendered state, callable directly from a `[Test]` procedure. `Enabled()` and `Editable()` are distinct states — this article covers visibility/enabled state specifically; see `use-testpage-editable-to-verify-field-editability.md` for verifying whether a field can actually be changed. + +## Best Practice + +Open the `TestPage`, navigate to the relevant record if needed, then assert against `TestPageField.Visible()` and `TestPageField.Enabled()` to verify the field's shown/enabled state, rather than checking an unrelated table/page property or skipping the check. + +See sample: [`use-testpage-visible-enabled-to-verify-field-ui-state.good.al`](use-testpage-visible-enabled-to-verify-field-ui-state.good.al). + +## Anti Pattern + +A test that opens the `TestPage` but never asserts against `Visible()`/`Enabled()` on the field in question — confirming only that the page opens, not that the field behaves as expected. + +See sample: [`use-testpage-visible-enabled-to-verify-field-ui-state.bad.al`](use-testpage-visible-enabled-to-verify-field-ui-state.bad.al). diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 2318304..e58b6b0 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. The skill returns `not-applicable` when none of those apply. ## Source @@ -46,6 +46,7 @@ A file enters the candidate worklist when its `keywords` intersect the extracted The following targeted checks cover every current `data-modeling` 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 `* 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`. +- Code reads `Item Ledger Entry."Document No."` (or `"Last Shipping No."`/`"Last Posting No."`) after a combined Ship+Invoice **sales** post — `item-ledger-entry-document-no-follows-last-shipping-no`. This is a sales-specific rule: purchase combined posting is Receive+Invoice and uses receiving fields such as `"Last Receiving No."`, not the shipment/document-number behavior this article describes. Do not worklist it from purchase posting code. - 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`. - A master gains or changes `Blocked`, or a document line, journal line, reference-field `OnValidate`, or posting routine uses that master without `TestField(Blocked, false)` at the point of use; also cue when the check is placed only in the master's own triggers — `check-blocked-in-referencing-code-not-in-master`. @@ -87,7 +88,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable data-modeling knowledge survived filtering. -- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface. +- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, audit-field, dimension-wiring, posting-routine-structure, or Item-Ledger-Entry-document-number surface. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index de0c9be..6986977 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`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Library - Utility`, `LibraryUtility`, `GenerateGUID`, `GenerateRandomCode`, `TestPage`, `.Visible(`, `.Enabled(`, `.Editable(`, `OpenNew`, `OpenView`, `OpenEdit`, `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. @@ -50,6 +50,8 @@ The following targeted checks cover every current `testing` article. Treat each - 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`. - `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`. +- Test fixture code assigns a hardcoded literal to a primary-key field or a field the test relies on as a unique lookup identifier, hand-builds a "unique" value for such a field (string concatenation, a counter, `Format(CurrentDateTime)`), or truncates `LibraryUtility.GenerateGUID()`'s result with `CopyStr` for such a field shorter than 10 characters — `use-generateguid-for-unique-test-fixture-values`. Calling `GenerateGUID()` untruncated into a full-length field, `GenerateRandomCodeWithLength` for a shorter field needing real verified uniqueness, or `GenerateRandomCode20` specifically for a `Code[20]` field, is the compliant shape, not the signal to flag. `GenerateRandomCode20` is not a substitute for `GenerateRandomCodeWithLength` on a shorter field — it truncates `GenerateGUID()`'s sequential value down to the field's length by keeping the *leftmost* characters, which change the slowest, so retries against a short field can churn through the same truncated prefix far longer than `GenerateRandomCodeWithLength`'s equivalent. A hardcoded or deterministic value in an ordinary descriptive field is not this anti-pattern — that field carries no uniqueness constraint. Do not claim `GenerateRandomCode` (without `WithLength`/`20`) or `GenerateRandomXMLText` verify uniqueness against the real table, or that `GenerateRandomCode` is collision-free even within one test run for a short field — none of that is true. +- A test asserts against a `TestPage` field's `.Visible()` or `.Enabled()` — `use-testpage-visible-enabled-to-verify-field-ui-state`. When the assertion is against `.Editable()`, or the page is opened with `OpenEdit()` specifically to check editability — `use-testpage-editable-to-verify-field-editability`. - 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. - A test's `[GIVEN]`/setup looks up a hardcoded code/number/name assumed to already exist instead of creating it, leaves a mandatory field on a created record empty, uses a value that doesn't satisfy the scenario's own explicit length/format requirement (for example a truncation test whose value never exceeds the field), or a scenario-defining value (amount, quantity, percentage, date, threshold, rounding precision) is generated/randomized instead of an explicit chosen value — `test-data-must-be-random-and-complete`. Generating incidental fixture values (identifiers, names, descriptions) via the standard library codeunits is the compliant shape, not the signal to flag, and neither is a short-but-valid value in an otherwise-unremarkable field. From 130d5de6c4bd72d2158240fe431862b6434c735a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolaj=20Jakobi=20N=C3=B8ttrup?= Date: Tue, 29 Sep 2026 12:49:55 +0200 Subject: [PATCH 32/51] Fix indirect permission letters in indirect-permissions-for-elevated-access (#199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each letter of a permission value is one permission, so `ri` is indirect read plus indirect insert. The good sample granted a read-only "Report Runner" role indirect insert on G/L Entry. Use `r` in the sample, name the lowercase letters in Best Practice, replace the ri/ii/mi/di keywords and cite the Microsoft Learn pages that define the letters. Co-authored-by: Nikolaj Jakobi Nøttrup <242577394+Nikolaj1407@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../indirect-permissions-for-elevated-access.good.al | 2 +- .../security/indirect-permissions-for-elevated-access.md | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al index 9fef5e4..f69dbc3 100644 --- a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al @@ -1,4 +1,4 @@ permissionset 50203 "Sec Sample Report Runner" { - Permissions = tabledata "G/L Entry" = ri; + Permissions = tabledata "G/L Entry" = r; } diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md index 2a39b9c..8e3d620 100644 --- a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: security -keywords: [permissionset, indirect-permissions, ri, ii, mi, di, code-mediated] +keywords: [permissionset, indirect-permissions, lowercase, code-mediated] technologies: [al] countries: [w1] application-area: [all] @@ -15,8 +15,12 @@ In a `permissionset`, uppercase letters (`R`, `I`, `M`, `D`) grant **direct** pe ## Best Practice -Use indirect permissions (`ri`, `ii`, `mi`, `di`) when a role needs access to a sensitive table only through a specific codeunit or report — for example, a "Report Runner" role that reads `G/L Entry` only via published reports. Pair the indirect grant with the codeunit or report that mediates access; that object's own permissions (or InherentPermissions) supply the direct rights. Document why indirect permissions are required in the permission set or in the consuming object's comments. See sample: [`indirect-permissions-for-elevated-access.good.al`](indirect-permissions-for-elevated-access.good.al). +Use indirect permissions (the lowercase letters `r`, `i`, `m`, `d`) when a role needs access to a sensitive table only through a specific codeunit or report — for example, a "Report Runner" role that reads `G/L Entry` only via published reports, granted `tabledata "G/L Entry" = r`. Each letter is one permission: `ri` grants indirect read **and** indirect insert, so grant only the letters the role needs. Pair the indirect grant with the codeunit or report that mediates access; that object's own permissions (or InherentPermissions) supply the direct rights. Document why indirect permissions are required in the permission set or in the consuming object's comments. See sample: [`indirect-permissions-for-elevated-access.good.al`](indirect-permissions-for-elevated-access.good.al). ## Anti Pattern Granting `RIMD` on a sensitive table when the role only needs to view it through a report — for example `tabledata "G/L Entry" = RIMD` on a "Report Runner" role. Users assigned that role can now query and modify ledger entries directly through any client that respects the permission, bypassing the report entirely. Reviewers should look for uppercase grants on system-of-record tables (G/L Entry, ledger entries, posted documents) where the consuming code path is clearly read-through-report or read-through-API. See sample: [`indirect-permissions-for-elevated-access.bad.al`](indirect-permissions-for-elevated-access.bad.al). + +## References + +[Permissions property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-permissions-property) and [Permissions on database objects](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-permissions-on-database-objects) define one letter per permission: `R`/`r` read, `I`/`i` insert, `M`/`m` modify, `D`/`d` delete, uppercase for direct and lowercase for indirect. From 4287233f80678fd4fb02aca6455d9cf046ca9262 Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:03:39 +0200 Subject: [PATCH 33/51] Strengthen review contracts and add AL reliability guidance (#196) * Strengthen review contracts and HTTP guidance - add outbound HttpClient transport and HTTP status review rules with paired fixtures`n- resolve layered action-skill overrides deterministically across enabled layers`n- validate findings reports and enforce measurable changed-fixture coverage * 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 * Fix findings report rollup validation * Validate findings report rollups * Enforce merged finding identity * Fix locationless finding deduplication * Reject conflicting merged corrections * Detect conflicting leaf corrections * Route HTTP error checks to canonical web-services knowledge Let the Error Handling leaf conditionally retrieve the existing HTTP owner articles, preserving applicability and exact-path provenance. Add deterministic source-contract and retrieval regressions without duplicating knowledge rules. Copilot-Session-Id: a92a7788-103e-4651-9b84-19e34caffb94 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: wenjiefan Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jesper Schulz-Wedde --- .github/scripts/Test-SkillIndex.ps1 | 79 ++- .github/scripts/validate_frontmatter.py | 8 +- .github/workflows/review-fixtures.yml | 21 +- docs/customizing-bcquality.md | 9 + docs/standalone-runner.md | 16 +- evaluation/README.md | 16 + evaluation/review-fixtures.json | 23 +- ...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 ++ ...atus-before-consuming-response-body.bad.al | 21 + ...tus-before-consuming-response-body.good.al | 23 + ...p-status-before-consuming-response-body.md | 31 + ...-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 ++ ...form-failure-before-response-access.bad.al | 18 + ...orm-failure-before-response-access.good.al | 18 + ...platform-failure-before-response-access.md | 31 + microsoft/skills/review/al-code-review.md | 8 +- .../skills/review/al-error-handling-review.md | 8 + 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 | 21 +- skills/do.md | 25 +- tools/Build-SkillIndex.ps1 | 16 +- tools/Resolve-SkillWorklist.ps1 | 92 +++ tools/Test-KnowledgeRetrieval.ps1 | 54 ++ tools/Test-ReviewContract.ps1 | 359 +++++++++++- tools/Test-ReviewFixtures.ps1 | 82 ++- tools/Validate-FindingsReport.ps1 | 540 ++++++++++++++++++ 41 files changed, 1974 insertions(+), 38 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-http-status-before-consuming-response-body.bad.al create mode 100644 microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al create mode 100644 microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.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 create mode 100644 microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al create mode 100644 microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al create mode 100644 microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md create mode 100644 tools/Resolve-SkillWorklist.ps1 create mode 100644 tools/Validate-FindingsReport.ps1 diff --git a/.github/scripts/Test-SkillIndex.ps1 b/.github/scripts/Test-SkillIndex.ps1 index b694a5b..bc635f2 100644 --- a/.github/scripts/Test-SkillIndex.ps1 +++ b/.github/scripts/Test-SkillIndex.ps1 @@ -30,9 +30,10 @@ function Assert-ThrowsLike { } $generator = Join-Path $Root 'tools/Build-SkillIndex.ps1' +$resolver = Join-Path $Root 'tools/Resolve-SkillWorklist.ps1' $indexSchema = Join-Path $Root 'schemas/skill-index.schema.json' $reportSchema = Join-Path $Root 'schemas/findings-report.schema.json' -foreach ($path in $generator, $indexSchema, $reportSchema) { +foreach ($path in $generator, $resolver, $indexSchema, $reportSchema) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required contract file not found: $path" } @@ -235,9 +236,83 @@ Output. Assert-ThrowsLike -Pattern '*Nested super-skills are not supported*' -Action { & $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'nested.json') } + + Remove-Item -LiteralPath (Join-Path $fixtureSkills 'al-nested-review.md') -Force + $validSuper = @' +--- +kind: action-skill +id: al-code-review +version: 1 +title: Review +description: Test super-skill. +inputs: [file-path] +outputs: [findings-report] +sub-skills: + - microsoft/skills/review/al-leaf-review.md +--- + +# Review + +## Source +Source. +## Relevance +Relevance. +## Worklist +Worklist. +## Action +Action. +## Output +Output. +'@ + Set-Content -LiteralPath $superPath -Value $validSuper -Encoding utf8NoBOM + + $customSkills = Join-Path -Path $fixtureRoot -ChildPath 'custom/skills/review' + New-Item -ItemType Directory -Path $customSkills -Force | Out-Null + $customLeaf = $leaf.Replace('title: Leaf', 'title: Custom Leaf') + Set-Content -LiteralPath (Join-Path $customSkills 'custom-leaf-review.md') -Value $customLeaf -Encoding utf8NoBOM + + $layeredPath = Join-Path $tmp 'layered.json' + & $generator -BCQualityRoot $fixtureRoot -IndexPath $layeredPath | Out-Null + $layeredIndex = Get-Content -LiteralPath $layeredPath -Raw | ConvertFrom-Json + $layeredLeaves = @($layeredIndex.skills | Where-Object id -eq 'al-leaf-review') + if ($layeredLeaves.Count -ne 2) { + throw "Expected both layered al-leaf-review implementations, found $($layeredLeaves.Count)." + } + if ((@($layeredLeaves.layer | Sort-Object) -join ',') -cne 'custom,microsoft') { + throw 'Layered al-leaf-review implementations did not preserve custom and microsoft records.' + } + + $resolved = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath ( + 'microsoft/skills/review/al-code-review.md' + ) + if ($resolved.subSkills.Count -ne 1 -or + $resolved.subSkills[0].path -cne 'custom/skills/review/custom-leaf-review.md') { + throw 'The custom implementation did not win the layered leaf slot.' + } + + $microsoftOnly = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath ( + 'microsoft/skills/review/al-code-review.md' + ) -EnabledLayers microsoft + if ($microsoftOnly.subSkills.Count -ne 1 -or + $microsoftOnly.subSkills[0].path -cne 'microsoft/skills/review/al-leaf-review.md') { + throw 'Disabling the custom layer did not fall back to the Microsoft implementation.' + } + + $customDisabled = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath ( + 'microsoft/skills/review/al-code-review.md' + ) -DisabledSkills 'custom/skills/review/custom-leaf-review.md' + if ($customDisabled.subSkills.Count -ne 1 -or + $customDisabled.subSkills[0].path -cne 'microsoft/skills/review/al-leaf-review.md') { + throw 'Disabling the custom implementation did not fall back to Microsoft.' + } + + Set-Content -LiteralPath (Join-Path $customSkills 'duplicate-leaf-review.md') -Value $customLeaf -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*Duplicate action-skill IDs within a layer: custom:al-leaf-review*' -Action { + & $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'duplicate-layer.json') + } } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } -Write-Output "Skill-index check PASSED: deterministic, schema-valid, and all $($expectedLeaves.Count) review leaves preserved in order." +Write-Output "Skill-index check PASSED: deterministic, schema-valid, layered overrides resolved, and all $($expectedLeaves.Count) review leaves preserved in order." diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index 20f33d6..51ff546 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -688,12 +688,16 @@ def run(root: Path) -> Report: if domain_dir.is_dir(): validate_samples_in_domain(domain_dir, root, report) - # Third pass: R24 unique ids within kind + # Third pass: R24 unique ids within kind. Layered action-skill overrides + # may share an id, but two definitions in one layer are ambiguous. by_kind: dict[str, dict[str, list[Path]]] = {} for rec in skill_records: if rec.skill_id is None: continue - by_kind.setdefault(rec.kind, {}).setdefault(rec.skill_id, []).append(rec.path) + scope = rec.kind + if rec.kind == "action-skill": + scope = f"{rec.kind}:{rec.path.relative_to(root).parts[0]}" + by_kind.setdefault(scope, {}).setdefault(rec.skill_id, []).append(rec.path) for kind, by_id in by_kind.items(): for sid, paths in by_id.items(): if len(paths) > 1: diff --git a/.github/workflows/review-fixtures.yml b/.github/workflows/review-fixtures.yml index 0f39d9a..0de9a02 100644 --- a/.github/workflows/review-fixtures.yml +++ b/.github/workflows/review-fixtures.yml @@ -12,7 +12,26 @@ jobs: steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Collect changed paths + shell: pwsh + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + $paths = if (-not $env:BASE_SHA -or $env:BASE_SHA -match '^0+$') { + @(git diff-tree --no-commit-id --name-only -r $env:GITHUB_SHA) + } else { + @(git diff --name-only $env:BASE_SHA $env:GITHUB_SHA) + } + $paths | Set-Content -LiteralPath "$env:RUNNER_TEMP/changed-paths.txt" -Encoding utf8NoBOM - name: Validate review evaluation corpus shell: pwsh - run: ./tools/Test-ReviewFixtures.ps1 -Root . -PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures" + run: | + ./tools/Test-ReviewFixtures.ps1 -Root . ` + -PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures" ` + -ChangedPathsFile "$env:RUNNER_TEMP/changed-paths.txt" ` + -CoverageReportPath "$env:RUNNER_TEMP/review-coverage.json" + Get-Content -LiteralPath "$env:RUNNER_TEMP/review-coverage.json" diff --git a/docs/customizing-bcquality.md b/docs/customizing-bcquality.md index d3d9148..4809fcd 100644 --- a/docs/customizing-bcquality.md +++ b/docs/customizing-bcquality.md @@ -44,6 +44,15 @@ Otherwise the layers are additive. A matching filename alone does not suppress an article; the [READ contract](../skills/read.md#layer-precedence) governs knowledge conflicts. Review reports record displaced knowledge in `suppressed`. +Action skills use the same layer order but override by frontmatter `id`. A +custom leaf with the same `id` as a Community or Microsoft leaf replaces that +leaf in every super-skill slot while its layer is enabled. The files may have +different names. IDs must remain unique within each layer. Disabling the custom +layer or the custom skill path makes composition fall back to the next enabled +implementation. Hosts should build the skill index and use +`tools/Resolve-SkillWorklist.ps1`; they must not implement this selection from +filenames. + Layer selection is **not an access-control boundary**. A plugin installation still contains excluded layers on disk. An integration requiring genuine exclusion must remove denied files from its own content copy before the agent diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 5829e4e..e5e2a0c 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -49,16 +49,20 @@ only result. action skills to run. Do not reproduce its routing logic. 3. Execute every dispatched action skill with the exact input subset in its dispatch record. Read `skills/read.md` and `skills/do.md` on demand. -4. When an action skill declares `sub-skills`, execute every relevant leaf as a - discrete invocation. Leaves are independent and may be scheduled serially - or concurrently. +4. When an action skill declares `sub-skills`, resolve its ordered leaf slots + with `tools/Resolve-SkillWorklist.ps1`, passing the enabled layers and + disabled skill paths from the task context. Execute every resolved leaf as + a discrete invocation. Leaves are independent and may be scheduled serially + or concurrently. 5. Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in private artifacts or host logs. Before the full DO acceptance gate, create a normalized candidate only for DO's bounded optional-range case, record that normalization separately in private telemetry, and accept the candidate only if the entire copy passes the - unchanged strict gate. The accepted report contains no undeclared telemetry - fields. + unchanged strict gate. Use `tools/Validate-FindingsReport.ps1`, passing the + exact source paths and fully retrieved article paths; pass `-SkillKind super` + for the final rolled-up report. The accepted report contains no undeclared + telemetry fields. 6. Collect each accepted findings-report into `sub-results` in the declared `sub-skills` order, not completion order. Run the super-skill self-review only after all leaves have finished. @@ -92,6 +96,8 @@ A compatible runner: - invokes every worklisted leaf exactly once unless a documented retry replaces a failed attempt; +- resolves same-ID leaf implementations by `custom > community > microsoft`, + preserves declared slot order, and falls back when a higher layer is disabled; - keeps leaf contexts isolated and passes only the inputs they declare; - preserves each raw Task return unchanged for audit and distinguishes it from any normalized accepted copy; diff --git a/evaluation/README.md b/evaluation/README.md index bce3208..5863fcc 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -4,6 +4,15 @@ The evaluation is convention-driven. The harness discovers every `/skills `review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different `article`, add context when the generic convention cannot express a scenario, or use an `articles` array when one domain needs explicit regression coverage for several paired articles. Specify either `article` or `articles`, not both. The first selected article retains the stable `-bad` and `-good` manifest IDs; additional articles use slug-qualified IDs. Overrides should remain empty in the normal case. +CI also measures selected paired articles against every effective article that +has both AL companions. A changed paired article must be selected by the +domain convention or an override. When adding it would not provide a useful +deterministic regression, add a narrow `coverageWaivers` entry with its exact +article path and a non-empty reason. Waivers are reviewable exceptions, not a +substitute for domain coverage. The generated coverage report includes totals +and per-domain ratios; the ratio is informational, while changed-file coverage +is mandatory. + Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer. The SCM `articles` override deliberately selects every rule in the initial @@ -36,6 +45,13 @@ pwsh ./tools/Test-ReviewFixtures.ps1 -Root . This credential-free check proves every selected leaf maps to a same-named knowledge domain with at least one complete AL sample pair and that all configured overrides are valid. +To reproduce the changed-file gate and emit the same measurable report as CI: + +```powershell +git diff --name-only origin/main...HEAD | Set-Content .changed-paths.txt +pwsh ./tools/Test-ReviewFixtures.ps1 -Root . -ChangedPathsFile .changed-paths.txt -CoverageReportPath .coverage.json +``` + ## Run a fast-model evaluation 1. Prepare neutral inputs: diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 3f131a5..90fc7d1 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -3,6 +3,7 @@ "selection": "first-paired-al-article", "minimumExpectedRecall": 1.0, "minimumCleanRate": 1.0, + "coverageWaivers": [], "overrides": { "agents": { "article": "wire-all-three-agent-interfaces" @@ -75,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": { @@ -88,14 +96,23 @@ "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", "context": "The extended table existed in the previous app version and already contains rows." }, "web-services": { - "article": "expose-systemid-as-the-api-key" + "articles": [ + "expose-systemid-as-the-api-key", + "handle-httpclient-platform-failure-before-response-access", + "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-http-status-before-consuming-response-body.bad.al b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.bad.al new file mode 100644 index 0000000..847045b --- /dev/null +++ b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.bad.al @@ -0,0 +1,21 @@ +codeunit 50100 "HTTP Status Handling Bad" +{ + procedure GetCustomer(CustomerId: Guid): JsonObject + var + Client: HttpClient; + Response: HttpResponseMessage; + CustomerJson: JsonObject; + ResponseText: Text; + begin + if not Client.Get( + StrSubstNo('https://api.example.com/customers/%1', CustomerId), + Response) + then + Error('The customer service could not be reached.'); + + // A completed request can still contain a 4xx or 5xx error document. + Response.Content().ReadAs(ResponseText); + CustomerJson.ReadFrom(ResponseText); + exit(CustomerJson); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al new file mode 100644 index 0000000..e15682b --- /dev/null +++ b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al @@ -0,0 +1,23 @@ +codeunit 50100 "HTTP Status Handling Good" +{ + procedure GetCustomer(CustomerId: Guid): JsonObject + var + Client: HttpClient; + Response: HttpResponseMessage; + CustomerJson: JsonObject; + ResponseText: Text; + begin + if not Client.Get( + StrSubstNo('https://api.example.com/customers/%1', CustomerId), + Response) + then + Error('The customer service could not be reached.'); + + if not Response.IsSuccessStatusCode() then + Error('The customer service returned HTTP status %1.', Response.HttpStatusCode()); + + Response.Content().ReadAs(ResponseText); + CustomerJson.ReadFrom(ResponseText); + exit(CustomerJson); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.md b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.md new file mode 100644 index 0000000..f924870 --- /dev/null +++ b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: web-services +keywords: [httpclient, httpresponsemessage, issuccessstatuscode, httpstatuscode, response-body, json] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Check HTTP status before consuming the response body + +## Description + +A successful AL `HttpClient` call only confirms that the platform completed the HTTP exchange. The server can still return `4xx` or `5xx`, often with an error document whose shape differs from the expected success payload. Parsing that body as business data can produce misleading parse errors, incomplete records, or decisions based on an error response. + +## Best Practice + +After handling any platform or transport failure, check `HttpResponseMessage.IsSuccessStatusCode()` or the expected `HttpStatusCode()` before interpreting the response body as a success payload. Handle non-success status explicitly and include safe diagnostic context when appropriate. A bounded error body may be read for diagnostics, but it must not enter the success parsing path. + +See sample: [`check-http-status-before-consuming-response-body.good.al`](check-http-status-before-consuming-response-body.good.al). + +## Anti Pattern + +Checking only the Boolean result of `Get`, `Post`, `Put`, `Delete`, or `Send` and then parsing `Response.Content()` as the expected payload. The Boolean can be `true` for any HTTP status, including authentication failures, throttling, validation errors, and server failures. + +See sample: [`check-http-status-before-consuming-response-body.bad.al`](check-http-status-before-consuming-response-body.bad.al). + +## References + +- [HttpResponseMessage.IsSuccessStatusCode method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/httpresponsemessage/httpresponsemessage-issuccessstatuscode-method) +- [HttpClient data type](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/httpclient/httpclient-data-type) \ No newline at end of file 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/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al new file mode 100644 index 0000000..8ac6d6a --- /dev/null +++ b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al @@ -0,0 +1,18 @@ +codeunit 50100 "Http Platform Failure Bad" +{ + procedure GetCustomer(CustomerId: Guid): Text + var + Client: HttpClient; + Response: HttpResponseMessage; + ResponseText: Text; + RequestSucceeded: Boolean; + begin + RequestSucceeded := Client.Get( + StrSubstNo('https://api.example.com/customers/%1', CustomerId), + Response); + + // Response content is unavailable when the platform call failed. + Response.Content().ReadAs(ResponseText); + exit(ResponseText); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al new file mode 100644 index 0000000..019f0be --- /dev/null +++ b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al @@ -0,0 +1,18 @@ +codeunit 50100 "Http Platform Failure Good" +{ + procedure GetCustomer(CustomerId: Guid): Text + var + Client: HttpClient; + Response: HttpResponseMessage; + ResponseText: Text; + begin + if not Client.Get( + StrSubstNo('https://api.example.com/customers/%1', CustomerId), + Response) + then + Error('The customer service could not be reached.'); + + Response.Content().ReadAs(ResponseText); + exit(ResponseText); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md new file mode 100644 index 0000000..e3dd227 --- /dev/null +++ b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: web-services +keywords: [httpclient, transport-failure, boolean-return, httpresponsemessage, content, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Handle HttpClient platform failure before accessing the response + +## Description + +AL `HttpClient` methods can fail before a usable HTTP response exists because of an invalid request, DNS or network failure, certificate validation, timeout, a disabled extension setting, or the response-size limit. When code captures the optional Boolean return value, `false` reports this platform or transport failure. The accompanying `HttpResponseMessage` is not safe to consume; accessing its content after the failed call can raise another error and obscure the original failure. + +## Best Practice + +When capturing the Boolean return value from `Get`, `Post`, `Put`, `Delete`, or `Send`, stop the current response-processing path immediately when it is `false`. Report or propagate the transport failure without reading status, headers, or content. Omitting the optional Boolean is also valid when fail-fast behavior is intended: the runtime then raises an error if the operation cannot execute. + +See sample: [`handle-httpclient-platform-failure-before-response-access.good.al`](handle-httpclient-platform-failure-before-response-access.good.al). + +## Anti Pattern + +Capturing a failed call in a Boolean and then reading `Response.Content()`, parsing the body, or otherwise treating `Response` as usable. Do not report omission of the Boolean by itself; that form deliberately delegates failure propagation to the runtime. + +See sample: [`handle-httpclient-platform-failure-before-response-access.bad.al`](handle-httpclient-platform-failure-before-response-access.bad.al). + +## References + +- [HttpClient.Send method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/httpclient/httpclient-send-method) +- [Call external services with HttpClient](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-httpclient) \ No newline at end of file diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index df476f3..7a3d0c6 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -46,8 +46,12 @@ The sub-skills invoked by this skill are those listed in frontmatter `sub-skills Hosts that orchestrate leaves mechanically SHOULD run `tools/Build-SkillIndex.ps1` and resolve this skill by `id: al-code-review`. -The generated `subSkills` array preserves the frontmatter order and avoids -host-specific Markdown parsing. +The generated `subSkills` array preserves the frontmatter slot order and +avoids host-specific Markdown parsing. Before invoking leaves, run +`tools/Resolve-SkillWorklist.ps1` with this skill's path and the task's enabled +layers and disabled skill paths. Each declared path supplies a leaf `id`; the +resolver selects the highest-precedence enabled implementation with that `id` +without changing slot order. ## Relevance diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 56bc0fe..a340807 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -22,6 +22,13 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat Use READ's **Bounded retrieval for review skills** workflow with `-Domain error-handling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. +When the review scope contains outbound `HttpClient.Get` or `HttpClient.Post` calls, including resolved call paths, also retrieve every catalog page with `-Domain web-services`, using the same known task dimensions and enabled layers. Restrict supplementary candidates to these article slugs across enabled layers; the links identify their canonical owners: + +- [`handle-httpclient-platform-failure-before-response-access`](../../knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md) +- [`check-http-status-before-consuming-response-body`](../../knowledge/web-services/check-http-status-before-consuming-response-body.md) + +Retain each selected catalog row's exact `path`; do not invent paths for missing, pruned, or disabled entries. Apply this leaf's Relevance, Worklist, and READ layer precedence to the supplementary candidates before retrieving complete bodies with `Get-KnowledgeArticles.ps1`. Apply each selected article's own scope and exceptions when evaluating code and agent-finding candidates. Use READ's bounded path-discovery fallback over these same sources when needed. This supplements error-handling knowledge, not the scope of the review with unrelated web-services concerns. + ## Relevance Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: @@ -40,6 +47,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially codeunits that post or validate, tables and table extensions with `OnValidate` triggers, and any procedure that raises errors or orchestrates a batch over records. - The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]` or `[TryFunction]`. - Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `FieldError`, `TestField`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, Boolean assignment). +- For the outbound HTTP call paths identified in Source, include `HttpClient`, `Get`, `Post`, `HttpResponseMessage`, response use, and caller failure handling (including `[TryFunction]` call sites) in keyword and topic matching. - Resolve changed standalone call targets; when the target declaration has `[TryFunction]`, worklist the ignored-return rule even if the declaration itself is unchanged. Only assignment and conditional use activate try semantics. 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-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 6986977..2adbc13 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`, `Library - Utility`, `LibraryUtility`, `GenerateGUID`, `GenerateRandomCode`, `TestPage`, `.Visible(`, `.Enabled(`, `.Editable(`, `OpenNew`, `OpenView`, `OpenEdit`, `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`, `Library - Utility`, `LibraryUtility`, `GenerateGUID`, `GenerateRandomCode`, `TestPage`, `.Visible(`, `.Enabled(`, `.Editable(`, `OpenNew`, `OpenView`, `OpenEdit`, `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`. - Test fixture code assigns a hardcoded literal to a primary-key field or a field the test relies on as a unique lookup identifier, hand-builds a "unique" value for such a field (string concatenation, a counter, `Format(CurrentDateTime)`), or truncates `LibraryUtility.GenerateGUID()`'s result with `CopyStr` for such a field shorter than 10 characters — `use-generateguid-for-unique-test-fixture-values`. Calling `GenerateGUID()` untruncated into a full-length field, `GenerateRandomCodeWithLength` for a shorter field needing real verified uniqueness, or `GenerateRandomCode20` specifically for a `Code[20]` field, is the compliant shape, not the signal to flag. `GenerateRandomCode20` is not a substitute for `GenerateRandomCodeWithLength` on a shorter field — it truncates `GenerateGUID()`'s sequential value down to the field's length by keeping the *leftmost* characters, which change the slowest, so retries against a short field can churn through the same truncated prefix far longer than `GenerateRandomCodeWithLength`'s equivalent. A hardcoded or deterministic value in an ordinary descriptive field is not this anti-pattern — that field carries no uniqueness constraint. Do not claim `GenerateRandomCode` (without `WithLength`/`20`) or `GenerateRandomXMLText` verify uniqueness against the real table, or that `GenerateRandomCode` is collision-free even within one test run for a short field — none of that is true. - A test asserts against a `TestPage` field's `.Visible()` or `.Enabled()` — `use-testpage-visible-enabled-to-verify-field-ui-state`. When the assertion is against `.Editable()`, or the page is opened with `OpenEdit()` specifically to check editability — `use-testpage-editable-to-verify-field-editability`. diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index c486c88..b86c79b 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -3,7 +3,7 @@ kind: action-skill id: al-web-services-review version: 1 title: AL web services review -description: Reviews AL API surfaces and webhook integration handlers against web-services guidance from BCQuality. +description: Reviews AL API surfaces, outbound HTTP integrations, and webhook handlers against web-services guidance from BCQuality. inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] @@ -14,7 +14,7 @@ application-area: [all] # AL web services review -Reviews AL source changes against the `web-services` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. +Reviews AL source changes against the `web-services` knowledge domain in BCQuality and emits a findings report. This includes inbound API surfaces, outbound HTTP integrations, and webhook lifecycle code. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. @@ -39,10 +39,12 @@ 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. - An API page (`PageType = API`) with `InsertAllowed = true` lists a field in `ODataKeyFields` and that same field control sets `Editable = false` — `api-page-key-fields-must-be-editable-on-insert.md`. A system-generated key such as `SystemId` marked read-only is not this anti-pattern. - An API page exposes a field via `Rec` directly, and that field is a stored value computed from other fields inside an `OnValidate` trigger rather than a FlowField recalculated in `OnAfterGetRecord` — `stored-derived-fields-must-not-be-exposed-directly.md`. Exposing a genuine FlowField, or a value already recalculated in `OnAfterGetRecord`, is not this anti-pattern. -- 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`, `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. @@ -58,7 +60,14 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. - Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. -For API parts whose parent declares `ODataKeyFields = SystemId`, detect a child foreign key linked to a parent business field instead of `Field(SystemId)`. Do not apply the SystemId-link rule to APIs intentionally keyed by another field. Omitted `Multiplicity` is valid and means the documented default 1:N collection; never report omission alone. Report an explicit `ZeroOrOne` only when the visible contract clearly intends a collection or deep insert, and report an explicit `Many` only when it clearly intends a singleton. Singleton metadata requires an explicit `ZeroOrOne`; do not infer singleton intent from naming alone. For webhook eligibility, detect `QueryType = API`, `SourceTableTemporary = true`, composite `ODataKeyFields` (including an omitted property when a visible source primary key is composite), Job Queue Entry, and visible system-table sources; do not infer an unknown table number. For lifecycle code, require both create and renew paths to use a handler that returns the query-string `validationToken` verbatim with `200 OK`, and flag renewal scheduling that assumes subscriptions are permanent instead of using `expirationDateTime`. Do not emit generic HTTP or REST advice. +For API parts whose parent declares `ODataKeyFields = SystemId`, detect a child foreign key linked to a parent business field instead of `Field(SystemId)`. Do not apply the SystemId-link rule to APIs intentionally keyed by another field. Omitted `Multiplicity` is valid and means the documented default 1:N collection; never report omission alone. Report an explicit `ZeroOrOne` only when the visible contract clearly intends a collection or deep insert, and report an explicit `Many` only when it clearly intends a singleton. Singleton metadata requires an explicit `ZeroOrOne`; do not infer singleton intent from naming alone. For webhook eligibility, detect `QueryType = API`, `SourceTableTemporary = true`, composite `ODataKeyFields` (including an omitted property when a visible source primary key is composite), Job Queue Entry, and visible system-table sources; do not infer an unknown table number. For lifecycle code, require both create and renew paths to use a handler that returns the query-string `validationToken` verbatim with `200 OK`, and flag renewal scheduling that assumes subscriptions are permanent instead of using `expirationDateTime`. + +For outbound HTTP calls, apply these targeted checks: + +- When code captures the optional Boolean result of `HttpClient.Get`, `Post`, `Put`, `Delete`, or `Send`, require the failed branch to stop before status, headers, or content are accessed. Do not report a call that omits the Boolean result: that form intentionally lets the runtime raise an error when the request cannot execute. +- After platform success, require `IsSuccessStatusCode()` or an explicit acceptable `HttpStatusCode()` check before response content is interpreted as a success payload. Do not report code that reads a bounded non-success body solely for diagnostics and keeps it out of the success parsing path. + +Do not emit generic HTTP or REST advice beyond applicable knowledge articles. Set `confidence` to: @@ -66,7 +75,7 @@ Set `confidence` to: - `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. - `low` when the finding is an advisory derived only from applicability. -After evaluating each worklist entry, also consider whether the diff exhibits a web-services defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material web-services defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly API pages and web-service surfaces; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. +After evaluating each worklist entry, also consider whether the diff exhibits a web-services defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material web-services defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly API pages, outbound HTTP integrations, and other web-service surfaces; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: set `ODataKeyFields = SystemId`; add the three `*Allowed = false` guards to a read-only page; add the missing `OnOpenPage` isolation assignment). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. @@ -76,7 +85,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item; default when the skill finishes normally, including when the resulting `findings` array is empty. - `no-knowledge` — no applicable web-services knowledge survived Source, Relevance, configuration filtering, and conflict resolution. `findings` is empty. -- `not-applicable` — the task context contains no AL API surface, JavaScript webhook subscription lifecycle code, or JavaScript notification handler, or the `technologies` filter rejected the task. +- `not-applicable` — the task context contains no AL API surface, outbound AL HTTP integration, JavaScript webhook subscription lifecycle code, or JavaScript notification handler, or the `technologies` filter rejected the task. - `partial` — a time or token budget was hit before the worklist was exhausted. `summary.coverage` reflects the evaluated subset; `outcome-reason` explains the cause. - `failed` — an unrecoverable error occurred. `outcome-reason` is required. diff --git a/skills/do.md b/skills/do.md index e67faf6..db0d634 100644 --- a/skills/do.md +++ b/skills/do.md @@ -222,6 +222,12 @@ as a findings-report, a coordinator or host MUST validate it deterministically: inclusive range identify existing lines with `start-line == line` and `end-line >= start-line`. +Hosts SHOULD execute `tools/Validate-FindingsReport.ps1` with the exact source +scope and the leaf's recorded set of fully retrieved article paths. Pass +`-SkillKind super` when validating a super-skill's rolled-up report. Pass +`-AllowBoundedNormalization` only when the host preserves the immutable raw +payload and records `removedRanges` in private telemetry as required above. + Validation failure invalidates the complete return; consumers MUST NOT salvage individual findings, infer missing fields, reconstruct JSON, clamp ranges, rewrite paths, or otherwise silently repair model output. Preserve the invalid @@ -356,16 +362,27 @@ performing any super-skill self-review or final rollup. Scheduling MUST NOT change relevance, coverage, failure, reference-integrity, or output semantics. Orchestrators SHOULD generate `skill-index.json` with -`tools/Build-SkillIndex.ps1` and consume the super-skill's ordered `subSkills` -from that index instead of parsing Markdown. Action-skill frontmatter remains -the source of truth; the generated index conforms to +`tools/Build-SkillIndex.ps1` instead of parsing Markdown. Each declared +`subSkills` path defines an ordered leaf slot: its indexed `id` identifies the +slot, while its path fixes the declaration order. Before scheduling leaves, +the orchestrator MUST resolve each slot to the highest-precedence enabled, +non-disabled leaf with that `id` (`custom` over `community` over `microsoft`). +If no implementation remains, the slot is skipped with `reason: +"configuration"`. The orchestrator SHOULD use +`tools/Resolve-SkillWorklist.ps1` for this resolution. Action-skill +frontmatter remains the source of truth; the generated index conforms to `schemas/skill-index.schema.json`. +Layer resolution MUST NOT reorder slots. Multiple implementations with the +same `id` are valid only when they belong to different layers; duplicate IDs +within one layer are invalid. Disabling a winning implementation falls back +to the next enabled implementation for that slot when one exists. + ### Section interpretation for super-skills The five required sections still apply. Their meaning shifts from knowledge files to sub-skills: -- `## Source` — names the sub-skills invoked (mirrors `sub-skills` in frontmatter). +- `## Source` — names the declared sub-skill slots (mirrors `sub-skills` in frontmatter) and resolves their effective leaf implementations by the layered rule above. - `## Relevance` — rules for deciding which sub-skills apply to the current task. A sub-skill is relevant when its declared `inputs` are satisfied by the orchestrator's provided inputs and the orchestrator has not disabled it via configuration. The super-skill MUST NOT filter sub-skills by task content (for example, by inspecting the diff or the file). Task-level applicability is the sub-skill's own responsibility; sub-skills signal non-applicability by returning `outcome: "not-applicable"` or `outcome: "no-knowledge"`. - `## Worklist` — the final list of sub-skills to invoke; the rest go to `skipped-sub-skills`. - `## Action` — invoke each worklisted sub-skill with the appropriate subset of inputs, collect its findings-report verbatim into `sub-results`, and copy its `findings[]` into the super-skill's top-level `findings[]` with `from-sub-skill` set. All finding fields, including the optional `domain`, are preserved verbatim unless this contract explicitly requires a transformation. Findings from a sub-skill with `outcome: "failed"` MUST NOT be copied into the super-skill's top-level `findings[]` and MUST NOT contribute to the super-skill's `summary.counts` (their report is still preserved in `sub-results` for traceability, consistent with DO's rule that consumers ignore a failed skill's findings). diff --git a/tools/Build-SkillIndex.ps1 b/tools/Build-SkillIndex.ps1 index 022898e..d4028c2 100644 --- a/tools/Build-SkillIndex.ps1 +++ b/tools/Build-SkillIndex.ps1 @@ -203,9 +203,19 @@ foreach ($layer in 'microsoft', 'community', 'custom') { } } -$ids = @($records | Group-Object id | Where-Object Count -gt 1) -if ($ids.Count) { - throw "Duplicate action-skill IDs: $($ids.Name -join ', ')" +$idsWithinLayer = @( + $records | + Group-Object { "$($_.layer)`0$($_.id)" } | + Where-Object Count -gt 1 +) +if ($idsWithinLayer.Count) { + $duplicates = @( + $idsWithinLayer | ForEach-Object { + $parts = $_.Name -split "`0", 2 + "$($parts[0]):$($parts[1])" + } + ) + throw "Duplicate action-skill IDs within a layer: $($duplicates -join ', ')" } foreach ($record in $records) { diff --git a/tools/Resolve-SkillWorklist.ps1 b/tools/Resolve-SkillWorklist.ps1 new file mode 100644 index 0000000..1e5e9f3 --- /dev/null +++ b/tools/Resolve-SkillWorklist.ps1 @@ -0,0 +1,92 @@ +<# +.SYNOPSIS + Resolves a super-skill's ordered leaf worklist across enabled layers. +#> +[CmdletBinding()] +param( + [string] $BCQualityRoot, + [string] $IndexPath, + [Parameter(Mandatory)] + [string] $SuperSkillPath, + [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'), + [string[]] $DisabledSkills = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $BCQualityRoot) { + $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +} +$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path +if (-not $IndexPath) { + $IndexPath = Join-Path $BCQualityRoot 'skill-index.json' +} +if (-not (Test-Path -LiteralPath $IndexPath -PathType Leaf)) { + & (Join-Path $PSScriptRoot 'Build-SkillIndex.ps1') -BCQualityRoot $BCQualityRoot -IndexPath $IndexPath | Out-Null +} + +$knownLayers = @('microsoft', 'community', 'custom') +$unknownLayers = @($EnabledLayers | Where-Object { $_ -cnotin $knownLayers }) +if ($unknownLayers.Count) { + throw "Unknown enabled layers: $($unknownLayers -join ', ')" +} + +$index = Get-Content -LiteralPath $IndexPath -Raw | ConvertFrom-Json +$skills = @($index.skills) +$superSkills = @($skills | Where-Object path -CEQ $SuperSkillPath) +if ($superSkills.Count -ne 1) { + throw "Expected one indexed super-skill at '$SuperSkillPath', found $($superSkills.Count)." +} +$superSkill = $superSkills[0] +if (-not @($superSkill.subSkills).Count) { + throw "Action skill '$SuperSkillPath' is not a super-skill." +} + +$precedence = @{ microsoft = 0; community = 1; custom = 2 } +$resolved = [Collections.Generic.List[object]]::new() +$skipped = [Collections.Generic.List[object]]::new() +foreach ($declaredPath in @($superSkill.subSkills)) { + $declared = @($skills | Where-Object path -CEQ $declaredPath) + if ($declared.Count -ne 1) { + throw "Declared sub-skill '$declaredPath' is not uniquely indexed." + } + + $candidates = @( + $skills | + Where-Object { + $_.id -CEQ $declared[0].id -and + $_.layer -cin $EnabledLayers -and + $_.path -cnotin $DisabledSkills -and + -not @($_.subSkills).Count + } | + Sort-Object @{ Expression = { $precedence[$_.layer] }; Descending = $true }, path + ) + if (-not $candidates.Count) { + $skipped.Add([pscustomobject][ordered]@{ + id = $declared[0].id + declaredPath = $declaredPath + reason = 'configuration' + }) | Out-Null + continue + } + + $winner = $candidates[0] + $resolved.Add([pscustomobject][ordered]@{ + id = $winner.id + path = $winner.path + version = $winner.version + layer = $winner.layer + declaredPath = $declaredPath + }) | Out-Null +} + +return [pscustomobject][ordered]@{ + superSkill = [pscustomobject][ordered]@{ + id = $superSkill.id + path = $superSkill.path + version = $superSkill.version + } + subSkills = @($resolved) + skipped = @($skipped) +} \ No newline at end of file diff --git a/tools/Test-KnowledgeRetrieval.ps1 b/tools/Test-KnowledgeRetrieval.ps1 index ef65812..568a4bd 100644 --- a/tools/Test-KnowledgeRetrieval.ps1 +++ b/tools/Test-KnowledgeRetrieval.ps1 @@ -348,6 +348,60 @@ try { $indexPath = Join-Path $tmp 'knowledge-index.json' & $generator -BCQualityRoot $Root -IndexPath $indexPath | Out-Null $index = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + + # Check the declared finder route and real tool reachability, not model compliance. + $errorSkill = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-error-handling-review.md') -Raw + $errorSource = [regex]::Match($errorSkill, '(?ms)^## Source\r?\n(.*?)(?=^## )').Groups[1].Value + $sourceDomains = @([regex]::Matches($errorSource, '-Domain ([a-z-]+)') | ForEach-Object { $_.Groups[1].Value }) + Assert-Sequence $sourceDomains @('error-handling', 'web-services') 'error-handling declares its own and supplementary HTTP catalogs' + foreach ($cue in @('HttpClient.Get', 'HttpClient.Post', 'resolved call paths', 'same known task dimensions and enabled layers')) { + Assert-True ($errorSource.Contains($cue)) "supplementary source retains '$cue'" + } + $httpArticleNames = @( + [regex]::Matches($errorSource, '\]\(\.\./\.\./knowledge/web-services/([a-z-]+\.md)\)') | + ForEach-Object { $_.Groups[1].Value } + ) + Assert-Sequence $httpArticleNames @( + 'handle-httpclient-platform-failure-before-response-access.md' + 'check-http-status-before-consuming-response-body.md' + ) 'supplementary source names only the two canonical HTTP articles' + $httpArguments = @{ + BCQualityRoot = $Root + IndexPath = $indexPath + EnabledLayers = @('microsoft', 'community', 'custom') + Technologies = @('al') + BCVersion = 28 + Countries = @('w1') + } + $ownCatalog = Invoke-CatalogPages -Arguments ($httpArguments + @{ Domain = $sourceDomains[0] }) + Assert-True (-not @($ownCatalog.candidates | Where-Object { $_.path -like '*/knowledge/web-services/*' }).Count) 'the primary catalog does not silently expand domains' + $httpCatalog = Invoke-CatalogPages -Arguments ($httpArguments + @{ Domain = $sourceDomains[1] }) + $httpRows = @($httpCatalog.candidates | Where-Object { $httpArticleNames -ccontains ($_.path -split '/')[-1] }) + $expectedHttpPaths = @( + $index.articles | + Where-Object { $_.domain -ceq 'web-services' -and $httpArticleNames -ccontains ($_.path -split '/')[-1] } | + ForEach-Object path | + Sort-Object + ) + foreach ($name in $httpArticleNames) { + Assert-True ($expectedHttpPaths -ccontains "microsoft/knowledge/web-services/$name") "canonical owner exists for $name" + } + Assert-Sequence @($httpRows.path | Sort-Object) $expectedHttpPaths 'supplementary selection preserves exact paths across layers' + Test-BodyRoundTrip -Paths $httpRows.path -IndexPath $indexPath + foreach ($excludedContext in @( + @{ EnabledLayers = @() } + @{ Technologies = @('javascript') } + )) { + $arguments = $httpArguments + @{ Domain = $sourceDomains[1] } + foreach ($key in $excludedContext.Keys) { + $arguments[$key] = $excludedContext[$key] + } + $catalog = Invoke-CatalogPages -Arguments $arguments + $selected = @($catalog.candidates | Where-Object { $httpArticleNames -ccontains ($_.path -split '/')[-1] }) + Assert-Equal $selected.Count 0 'supplementary selection respects disabled layers and nonmatching technology' + } + Write-Host 'HTTP source contract and exact-body reachability passed; model routing was not evaluated.' + $diskArticlePaths = @( foreach ($layer in 'microsoft', 'community', 'custom') { $knowledge = Join-Path $Root "$layer\knowledge" diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1 index 1d40058..adacb36 100644 --- a/tools/Test-ReviewContract.ps1 +++ b/tools/Test-ReviewContract.ps1 @@ -1,12 +1,11 @@ <# .SYNOPSIS - Validates the bounded leaf-range normalization contract. + Validates executable findings-report acceptance and bounded normalization. .DESCRIPTION - BCQuality has no executable findings-report consumer. These assertions keep - the normative DO contract, AL coordinator, and standalone runner aligned - while exercising the exact normalization predicate against representative - safe and ambiguous inputs. + These assertions keep the normative DO contract, executable validator, AL + coordinator, and standalone runner aligned while exercising semantic report + validation and the exact normalization predicate. #> [CmdletBinding()] param( @@ -39,6 +38,24 @@ function Assert-Contains { Assert-True $Text.Contains($Expected) $Message } +function Assert-ThrowsLike { + param( + [scriptblock] $Action, + [string] $Pattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -like $Pattern) { + return + } + throw "Expected error like '$Pattern', received: $($_.Exception.Message)" + } + throw "Expected error like '$Pattern', but no error was thrown." +} + function Test-PositiveInteger { param([object] $Value) @@ -168,4 +185,334 @@ Assert-True (-not ($candidateFinding.location.PSObject.Properties.Name -contains Assert-True ($candidateFinding.location.line -eq $rawFinding.location.line) 'candidate preserves the primary line' Assert-True ($candidateFinding.message -ceq $rawFinding.message) 'candidate preserves all other finding content' -Write-Output "Review contract validation passed ($($cases.Count) normalization cases)." +$validator = Join-Path $Root 'tools/Validate-FindingsReport.ps1' +Assert-True (Test-Path -LiteralPath $validator -PathType Leaf) 'executable report validator exists' +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("reviewcontract_" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + $sourcePath = 'src/codeunit.al' + $sourceFile = Join-Path $tmp 'src/codeunit.al' + New-Item -ItemType Directory -Path (Split-Path -Parent $sourceFile) -Force | Out-Null + Set-Content -LiteralPath $sourceFile -Value @('line one', 'line two', 'line three') -Encoding utf8NoBOM + $articlePath = 'microsoft/knowledge/style/caption-required-on-page-fields.md' + $supportingArticlePath = 'microsoft/knowledge/style/tooltip-required-on-page-fields.md' + $reportPath = Join-Path $tmp 'report.json' + + $validReport = [ordered]@{ + skill = [ordered]@{ id = 'al-style-review'; version = 1 } + outcome = 'completed' + summary = [ordered]@{ + counts = [ordered]@{ blocker = 0; major = 0; minor = 1; info = 0 } + coverage = [ordered]@{ 'worklist-size' = 1; 'items-evaluated' = 1 } + } + findings = @( + [ordered]@{ + id = $articlePath + severity = 'minor' + message = 'A concrete style defect.' + location = [ordered]@{ + file = $sourcePath + line = 2 + range = [ordered]@{ 'start-line' = 2; 'end-line' = 3 } + } + references = @([ordered]@{ path = $articlePath }) + confidence = 'high' + domain = 'Style' + } + ) + suppressed = @() + } + Set-Content -LiteralPath $reportPath -Value ($validReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $accepted = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + Assert-True (-not $accepted.normalized) 'valid report is accepted without normalization' + + $invalidCounts = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $invalidCounts.summary.counts.minor = 0 + Set-Content -LiteralPath $reportPath -Value ($invalidCounts | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*COUNT_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $completedUndercoverage = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $completedUndercoverage.summary.coverage.'items-evaluated' = 0 + Set-Content -LiteralPath $reportPath -Value ($completedUndercoverage | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*COMPLETED_COVERAGE_INCOMPLETE*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $partialFullCoverage = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $partialFullCoverage.outcome = 'partial' + $partialFullCoverage | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'Stopped early.' + Set-Content -LiteralPath $reportPath -Value ($partialFullCoverage | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*PARTIAL_COVERAGE_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $completedLeaf = [ordered]@{ + skill = [ordered]@{ id = 'al-style-review'; version = 1 } + outcome = 'completed' + summary = [ordered]@{ + counts = [ordered]@{ blocker = 0; major = 0; minor = 0; info = 0 } + coverage = [ordered]@{ 'worklist-size' = 1; 'items-evaluated' = 1 } + } + findings = @() + suppressed = @() + } + $leafWithSubResults = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $leafWithSubResults | Add-Member -NotePropertyName 'sub-results' -NotePropertyValue @($completedLeaf) + Set-Content -LiteralPath $reportPath -Value ($leafWithSubResults | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*LEAF_COMPOSITION_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root + } + + $validSuperReport = [ordered]@{ + skill = [ordered]@{ id = 'al-code-review'; version = 1 } + outcome = 'completed' + summary = [ordered]@{ + counts = [ordered]@{ blocker = 0; major = 0; minor = 0; info = 0 } + coverage = [ordered]@{ 'worklist-size' = 2; 'items-evaluated' = 2 } + } + findings = @() + suppressed = @() + 'sub-results' = @($completedLeaf, $completedLeaf) + } + Set-Content -LiteralPath $reportPath -Value ($validSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super + Assert-True (-not $acceptedSuper.normalized) 'valid super-skill report is accepted' + + $styleFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $securityFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $securityFindingLeaf.skill.id = 'al-security-review' + $rolledFinding = $validReport.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $rolledFinding | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-style-review' + $deduplicatedSuperReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $deduplicatedSuperReport.summary.counts.minor = 1 + $deduplicatedSuperReport.findings = @($rolledFinding) + $deduplicatedSuperReport.'sub-results' = @($styleFindingLeaf, $securityFindingLeaf) + Set-Content -LiteralPath $reportPath -Value ($deduplicatedSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedDeduplicatedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + Assert-True (-not $acceptedDeduplicatedSuper.normalized) 'one top-level finding may deduplicate the same citation from two leaves' + + $twoOccurrenceLeaf = $styleFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $secondOccurrence = $twoOccurrenceLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $secondOccurrence.location.line = 1 + $secondOccurrence.location.range.'start-line' = 1 + $secondOccurrence.location.range.'end-line' = 1 + $twoOccurrenceLeaf.findings = @($twoOccurrenceLeaf.findings[0], $secondOccurrence) + $twoOccurrenceLeaf.summary.counts.minor = 2 + $emptySecurityLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $emptySecurityLeaf.skill.id = 'al-security-review' + $sameIdOccurrenceOmitted = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $sameIdOccurrenceOmitted.'sub-results' = @($twoOccurrenceLeaf, $emptySecurityLeaf) + Set-Content -LiteralPath $reportPath -Value ($sameIdOccurrenceOmitted | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $mergeOwnerLeaf = $styleFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $mergeOwnerLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'Caption = ''Customer name'';' + $supportingFindingLeaf = $securityFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $supportingFindingLeaf.findings[0].id = $supportingArticlePath + $supportingFindingLeaf.findings[0].references[0].path = $supportingArticlePath + $supportingFindingLeaf.findings[0].confidence = 'medium' + $supportingFindingLeaf.findings[0].message = 'The field needs the same mechanical correction for a supporting rule.' + $supportingFindingLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'Caption = ''Customer name'';' + $mergedFinding = $rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $mergedFinding | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'Caption = ''Customer name'';' + $mergedFinding.references = @( + [pscustomobject]@{ path = $articlePath } + [pscustomobject]@{ path = $supportingArticlePath } + ) + $mergedSuperReport = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $mergedSuperReport.findings = @($mergedFinding) + $mergedSuperReport.'sub-results' = @($mergeOwnerLeaf, $supportingFindingLeaf) + Set-Content -LiteralPath $reportPath -Value ($mergedSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedMergedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + Assert-True (-not $acceptedMergedSuper.normalized) 'overlapping A and B findings may merge into A with B as a supporting reference' + + $textOnlyOwnerLeaf = $mergeOwnerLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $textOnlyOwnerLeaf.findings[0].PSObject.Properties.Remove('suggested-code') + $textOnlySupportingLeaf = $supportingFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $textOnlySupportingLeaf.findings[0].PSObject.Properties.Remove('suggested-code') + $textOnlyMergedFinding = $mergedFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $textOnlyMergedFinding.PSObject.Properties.Remove('suggested-code') + $textOnlyMergedSuper = $mergedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $textOnlyMergedSuper.findings = @($textOnlyMergedFinding) + $textOnlyMergedSuper.'sub-results' = @($textOnlyOwnerLeaf, $textOnlySupportingLeaf) + Set-Content -LiteralPath $reportPath -Value ($textOnlyMergedSuper | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedTextOnlyMerge = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + Assert-True (-not $acceptedTextOnlyMerge.normalized) 'supporting references permit an overlapping A and B merge with different messages and no suggested code' + + $conflictingSupportingLeaf = $supportingFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $conflictingSupportingLeaf.findings[0].'suggested-code' = 'ToolTip = ''Customer name'';' + $conflictingCorrectionMerge = $mergedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $conflictingCorrectionMerge.'sub-results' = @($mergeOwnerLeaf, $conflictingSupportingLeaf) + Set-Content -LiteralPath $reportPath -Value ($conflictingCorrectionMerge | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + } + + $omittedConflictingCorrectionMerge = $conflictingCorrectionMerge | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $omittedConflictingCorrectionMerge.findings[0].PSObject.Properties.Remove('suggested-code') + Set-Content -LiteralPath $reportPath -Value ($omittedConflictingCorrectionMerge | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + } + + $unmergedSupportingFinding = $supportingFindingLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $unmergedSupportingFinding | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-security-review' + $unmergedDuplicates = $mergedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $unmergedDuplicates.summary.counts.minor = 2 + $unmergedDuplicates.findings = @($mergedFinding, $unmergedSupportingFinding) + Set-Content -LiteralPath $reportPath -Value ($unmergedDuplicates | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_DUPLICATE_FINDINGS*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + } + + $omittedLeafFinding = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $omittedLeafFinding.summary.counts.minor = 0 + $omittedLeafFinding.findings = @() + Set-Content -LiteralPath $reportPath -Value ($omittedLeafFinding | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $locationlessLeaf = $styleFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $locationlessLeaf.findings[0].PSObject.Properties.Remove('location') + $secondLocationlessFinding = $locationlessLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $locationlessLeaf.findings = @($locationlessLeaf.findings[0], $secondLocationlessFinding) + $locationlessLeaf.summary.counts.minor = 2 + $locationlessRolledFinding = $rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $locationlessRolledFinding.PSObject.Properties.Remove('location') + $locationlessOmission = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $locationlessOmission.findings = @($locationlessRolledFinding) + $locationlessOmission.'sub-results' = @($locationlessLeaf, $emptySecurityLeaf) + Set-Content -LiteralPath $reportPath -Value ($locationlessOmission | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $completeLocationlessRollup = $locationlessOmission | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $completeLocationlessRollup.summary.counts.minor = 2 + $completeLocationlessRollup.findings = @( + $locationlessRolledFinding + ($locationlessRolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + ) + Set-Content -LiteralPath $reportPath -Value ($completeLocationlessRollup | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedLocationlessRollup = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + Assert-True (-not $acceptedLocationlessRollup.normalized) 'two locationless leaf occurrences require and accept two distinct rolled findings' + + $nonexistentProducer = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $nonexistentProducer.findings[0].'from-sub-skill' = 'al-missing-review' + Set-Content -LiteralPath $reportPath -Value ($nonexistentProducer | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_PRODUCER_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $rewrittenLeafFinding = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $rewrittenLeafFinding.findings[0].message = 'A rewritten rollup message.' + Set-Content -LiteralPath $reportPath -Value ($rewrittenLeafFinding | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $failedLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $failedLeaf.skill.id = 'al-security-review' + $failedLeaf.outcome = 'failed' + $failedLeaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'Validation failed.' + $failedLeaf.summary.coverage.'items-evaluated' = 0 + $partialSuperReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $partialSuperReport.outcome = 'partial' + $partialSuperReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'One sub-skill failed.' + $partialSuperReport.summary.coverage.'worklist-size' = 1 + $partialSuperReport.summary.coverage.'items-evaluated' = 1 + $partialSuperReport.'sub-results' = @($completedLeaf, $failedLeaf) + Set-Content -LiteralPath $reportPath -Value ($partialSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedPartialSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super + Assert-True (-not $acceptedPartialSuper.normalized) 'partial super-skill excludes failed coverage from its rollup' + + $failedLeafLeakage = $partialSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $failedLeafLeakage.summary.counts.minor = 1 + $failedLeafLeakage.findings = @($rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + $failedLeafLeakage.findings[0].'from-sub-skill' = 'al-security-review' + Set-Content -LiteralPath $reportPath -Value ($failedLeafLeakage | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FAILED_FINDING_LEAKAGE*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $failedLeafRelabeledAsAgent = $partialSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $failedLeafRelabeledAsAgent.summary.counts.minor = 1 + $failedLeafRelabeledAsAgent.findings = @($rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + $failedLeafRelabeledAsAgent.findings[0].'from-sub-skill' = 'agent' + $failedLeafRelabeledAsAgent.findings[0].domain = 'Agent' + Set-Content -LiteralPath $reportPath -Value ($failedLeafRelabeledAsAgent | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_AGENT_FINDING_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $incorrectOutcome = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $incorrectOutcome.outcome = 'not-applicable' + Set-Content -LiteralPath $reportPath -Value ($incorrectOutcome | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_OUTCOME_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super + } + + $incorrectRollup = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $incorrectRollup.summary.coverage.'worklist-size' = 1 + $incorrectRollup.summary.coverage.'items-evaluated' = 1 + Set-Content -LiteralPath $reportPath -Value ($incorrectRollup | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_COVERAGE_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super + } + + Set-Content -LiteralPath $reportPath -Value ($validReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*REFERENCE_NOT_RETRIEVED*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp -SourcePaths $sourcePath + } + + $invalidAgent = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $invalidAgent.findings[0].id = 'agent:uncited-defect' + $invalidAgent.findings[0].references = @() + $invalidAgent.findings[0].confidence = 'high' + Set-Content -LiteralPath $reportPath -Value ($invalidAgent | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*AGENT_CONFIDENCE_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp -SourcePaths $sourcePath + } + + $normalizable = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $normalizable.findings[0].location.range.'start-line' = 1 + Set-Content -LiteralPath $reportPath -Value ($normalizable | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*RANGE_START_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + $normalized = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath -AllowBoundedNormalization + Assert-True $normalized.normalized 'eligible range mismatch is normalized' + Assert-True ($normalized.removedRanges.Count -eq 1) 'normalization records one removed range' + Assert-True (-not ($normalized.report.findings[0].location.PSObject.Properties.Name -contains 'range')) ` + 'accepted normalized report removes only the optional range' +} +finally { + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Output "Review contract validation passed ($($cases.Count) predicate cases plus executable acceptance cases)." diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index 8cf019f..2941c02 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -18,7 +18,9 @@ param( [string] $ManifestPath, [string] $PrepareDirectory, [string] $ResultsPath, - [string] $ResultsDirectory + [string] $ResultsDirectory, + [string] $ChangedPathsFile, + [string] $CoverageReportPath ) Set-StrictMode -Version Latest @@ -160,7 +162,23 @@ foreach ($overrideDomain in $overrides.Keys) { } } +$coverageWaivers = @{} +if ($manifest.PSObject.Properties.Name -contains 'coverageWaivers') { + foreach ($waiver in @($manifest.coverageWaivers)) { + if (-not $waiver.path -or -not $waiver.reason) { + $problems.Add('Each coverage waiver requires non-empty path and reason values.') | Out-Null + continue + } + if ($coverageWaivers.ContainsKey([string]$waiver.path)) { + $problems.Add("Duplicate coverage waiver: $($waiver.path)") | Out-Null + continue + } + $coverageWaivers[[string]$waiver.path] = [string]$waiver.reason + } +} + $caseList = [System.Collections.Generic.List[object]]::new() +$pairedArticlesByDomain = @{} foreach ($domain in $leafDomains) { $articleCandidates = @( foreach ($layer in $layers) { @@ -189,6 +207,7 @@ foreach ($domain in $leafDomains) { ForEach-Object { $_.Group | Sort-Object Rank -Descending | Select-Object -First 1 } | Sort-Object BaseName ) + $pairedArticlesByDomain[$domain] = @($articles) if (-not $articles.Count) { $problems.Add("${domain}: no enabled knowledge layer has an article with both .good.al and .bad.al companion samples.") | Out-Null continue @@ -339,6 +358,65 @@ foreach ($domain in $leafDomains) { } } +$selectedArticlePaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($case in $cases) { + foreach ($reference in @($case.expected)) { + $selectedArticlePaths.Add([string]$reference) | Out-Null + } +} +$effectivePairedPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$coverageDomains = @( + foreach ($domain in $leafDomains) { + $paired = @($pairedArticlesByDomain[$domain]) + foreach ($article in $paired) { + $effectivePairedPaths.Add([string]$article.ArticlePath) | Out-Null + } + $selected = @($paired | Where-Object { $selectedArticlePaths.Contains([string]$_.ArticlePath) }).Count + [pscustomobject][ordered]@{ + domain = $domain + pairedArticles = $paired.Count + selectedArticles = $selected + coverage = if ($paired.Count) { $selected / $paired.Count } else { 0 } + } + } +) +$pairedTotal = ($coverageDomains | Measure-Object pairedArticles -Sum).Sum +$selectedTotal = ($coverageDomains | Measure-Object selectedArticles -Sum).Sum +$coverageReport = [pscustomobject][ordered]@{ + pairedArticles = $pairedTotal + selectedArticles = $selectedTotal + coverage = if ($pairedTotal) { $selectedTotal / $pairedTotal } else { 0 } + domains = $coverageDomains +} +if ($CoverageReportPath) { + $coverageParent = Split-Path -Parent $CoverageReportPath + if ($coverageParent -and -not (Test-Path -LiteralPath $coverageParent)) { + New-Item -ItemType Directory -Path $coverageParent -Force | Out-Null + } + $coverageReport | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $CoverageReportPath -Encoding utf8NoBOM +} + +if ($ChangedPathsFile) { + if (-not (Test-Path -LiteralPath $ChangedPathsFile -PathType Leaf)) { + $problems.Add("Changed paths file not found: $ChangedPathsFile") | Out-Null + } + else { + foreach ($changedPathValue in Get-Content -LiteralPath $ChangedPathsFile) { + $changedPath = ([string]$changedPathValue).Trim().Replace('\', '/') + if ($changedPath -notmatch '^(microsoft|community|custom)/knowledge/[^/]+/(.+?)(?:\.(?:good|bad)\.al|\.md)$') { + continue + } + $articlePath = "$($Matches[1])/knowledge/$($changedPath.Split('/')[2])/$($Matches[2]).md" + if (-not $effectivePairedPaths.Contains($articlePath) -or $selectedArticlePaths.Contains($articlePath)) { + continue + } + if (-not $coverageWaivers.ContainsKey($articlePath)) { + $problems.Add("Changed paired article is not selected for evaluation and has no coverage waiver: $articlePath") | Out-Null + } + } + } +} + if ($problems.Count) { Write-Host "Review fixture validation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red $problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } @@ -474,7 +552,7 @@ if ($PrepareDirectory) { if (-not $ResultsPath -and -not $ResultsDirectory) { & (Join-Path $PSScriptRoot 'Test-ReviewContract.ps1') -Root $Root - Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $($leafDomains.Count) leaf domains." -ForegroundColor Green + Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $selectedTotal/$pairedTotal paired articles across $($leafDomains.Count) leaf domains." -ForegroundColor Green exit 0 } diff --git a/tools/Validate-FindingsReport.ps1 b/tools/Validate-FindingsReport.ps1 new file mode 100644 index 0000000..d3442d3 --- /dev/null +++ b/tools/Validate-FindingsReport.ps1 @@ -0,0 +1,540 @@ +<# +.SYNOPSIS + Validates a BCQuality findings-report against its structural and semantic contract. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $ReportPath, + [string] $BCQualityRoot, + [string] $SourceRoot, + [string[]] $SourcePaths = @(), + [string[]] $RetrievedArticlePaths = @(), + [ValidateSet('leaf', 'super')] + [string] $SkillKind = 'leaf', + [switch] $AllowBoundedNormalization +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $BCQualityRoot) { + $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +} +$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path +$schemaPath = Join-Path $BCQualityRoot 'schemas/findings-report.schema.json' +$raw = Get-Content -LiteralPath $ReportPath -Raw +try { + if (-not ($raw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) { + throw 'Report does not satisfy schemas/findings-report.schema.json.' + } + $report = $raw | ConvertFrom-Json -Depth 100 +} +catch { + throw "Invalid findings-report JSON or schema: $($_.Exception.Message)" +} + +$retrieved = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($path in $RetrievedArticlePaths) { + $retrieved.Add($path) | Out-Null +} +$sources = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($path in $SourcePaths) { + $sources.Add($path) | Out-Null +} +$lineCounts = @{} + +function Test-HasProperty { + param([object] $Object, [string] $Name) + return $null -ne $Object -and $Object.PSObject.Properties.Name -ccontains $Name +} + +function Get-SourceLineCount { + param([string] $Path) + + if ($lineCounts.ContainsKey($Path)) { + return $lineCounts[$Path] + } + if (-not $SourceRoot) { + return -1 + } + $fullPath = Join-Path $SourceRoot ($Path -replace '/', [IO.Path]::DirectorySeparatorChar) + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + return -1 + } + $lineCounts[$Path] = [IO.File]::ReadAllLines($fullPath).Count + return $lineCounts[$Path] +} + +function Get-SemanticErrors { + param( + [object] $Candidate, + [switch] $PermitRangeStartMismatch + ) + + $errors = [Collections.Generic.List[object]]::new() + function Add-Error { + param([string] $Code, [string] $Path, [string] $Message) + $errors.Add([pscustomobject]@{ Code = $Code; Path = $Path; Message = $Message }) | Out-Null + } + + function Get-DerivedSuperOutcome { + param([object[]] $SubResults) + + if (-not $SubResults.Count) { + return 'not-applicable' + } + + $outcomes = @($SubResults | ForEach-Object { $_.outcome }) + if (-not @($outcomes | Where-Object { $_ -cne 'failed' }).Count) { + return 'failed' + } + if (($outcomes -ccontains 'partial') -or + (($outcomes -ccontains 'failed') -and @($outcomes | Where-Object { $_ -cne 'failed' }).Count)) { + return 'partial' + } + if (-not @($outcomes | Where-Object { $_ -cne 'not-applicable' }).Count) { + return 'not-applicable' + } + if (($outcomes -ccontains 'no-knowledge') -and + -not @($outcomes | Where-Object { $_ -cnotin @('no-knowledge', 'not-applicable') }).Count) { + return 'no-knowledge' + } + return 'completed' + } + + function Get-SeverityRank { + param([string] $Severity) + return @{'info' = 0; 'minor' = 1; 'major' = 2; 'blocker' = 3}[$Severity] + } + + function Get-ConfidenceRank { + param([string] $Confidence) + return @{'low' = 0; 'medium' = 1; 'high' = 2}[$Confidence] + } + + function Test-LocationsOverlap { + param([object] $First, [object] $Second) + + $firstHasLocation = Test-HasProperty $First 'location' + $secondHasLocation = Test-HasProperty $Second 'location' + if ($firstHasLocation -ne $secondHasLocation) { + return $false + } + if (-not $firstHasLocation) { + return $false + } + if ($First.location.file -cne $Second.location.file) { + return $false + } + $firstEnd = if (Test-HasProperty $First.location 'range') { $First.location.range.'end-line' } else { $First.location.line } + $secondEnd = if (Test-HasProperty $Second.location 'range') { $Second.location.range.'end-line' } else { $Second.location.line } + return $First.location.line -le $secondEnd -and $Second.location.line -le $firstEnd + } + + function Test-SameCorrection { + param([object] $First, [object] $Second) + + $firstHasCode = Test-HasProperty $First 'suggested-code' + $secondHasCode = Test-HasProperty $Second 'suggested-code' + if ($firstHasCode -or $secondHasCode) { + return $firstHasCode -and $secondHasCode -and $First.'suggested-code' -ceq $Second.'suggested-code' + } + return $First.message -ceq $Second.message + } + + function Test-ReferencesInclude { + param([object[]] $RolledReferences, [object[]] $LeafReferences) + + foreach ($leafReference in $LeafReferences) { + $matched = @($RolledReferences | Where-Object { + if ($_.path -cne $leafReference.path) { + return $false + } + $rolledHasSha = Test-HasProperty $_ 'sha' + $leafHasSha = Test-HasProperty $leafReference 'sha' + return $rolledHasSha -eq $leafHasSha -and + (-not $rolledHasSha -or $_.sha -ceq $leafReference.sha) + }).Count + if (-not $matched) { + return $false + } + } + return $true + } + + function Test-RolledFindingRepresents { + param( + [object] $RolledFinding, + [object] $LeafFinding, + [string] $LeafProducerId, + [switch] $RequirePrimaryOwner + ) + + $rolledHasLocation = Test-HasProperty $RolledFinding 'location' + $leafHasLocation = Test-HasProperty $LeafFinding 'location' + $leafReferences = @($LeafFinding.references) + $rolledReferences = @($RolledFinding.references) + if (-not $rolledHasLocation -and -not $leafHasLocation) { + $expectedId = if ($leafReferences.Count) { $LeafFinding.id } else { "${LeafProducerId}:$($LeafFinding.id)" } + if ($RolledFinding.'from-sub-skill' -cne $LeafProducerId -or + $RolledFinding.id -cne $expectedId -or + $RolledFinding.severity -cne $LeafFinding.severity -or + $RolledFinding.confidence -cne $LeafFinding.confidence -or + $RolledFinding.message -cne $LeafFinding.message -or + $rolledReferences.Count -ne $leafReferences.Count -or + -not (Test-ReferencesInclude $rolledReferences $leafReferences)) { + return $false + } + foreach ($name in 'domain', 'suggested-code', 'suggested-code-omission-reason') { + $rolledHasProperty = Test-HasProperty $RolledFinding $name + $leafHasProperty = Test-HasProperty $LeafFinding $name + if ($rolledHasProperty -ne $leafHasProperty -or + ($rolledHasProperty -and $RolledFinding.$name -cne $LeafFinding.$name)) { + return $false + } + } + return $true + } + + if (-not (Test-LocationsOverlap $RolledFinding $LeafFinding) -or + (Get-SeverityRank $RolledFinding.severity) -lt (Get-SeverityRank $LeafFinding.severity) -or + (Get-ConfidenceRank $RolledFinding.confidence) -lt (Get-ConfidenceRank $LeafFinding.confidence)) { + return $false + } + + $sameCorrection = Test-SameCorrection $RolledFinding $LeafFinding + $correctionsConflict = (Test-HasProperty $RolledFinding 'suggested-code') -and + (Test-HasProperty $LeafFinding 'suggested-code') -and + $RolledFinding.'suggested-code' -cne $LeafFinding.'suggested-code' + $explicitCrossRuleMerge = $leafReferences.Count -and + $rolledReferences.Count -gt $leafReferences.Count -and + -not $correctionsConflict -and + (Test-ReferencesInclude $rolledReferences $leafReferences) + if (-not $sameCorrection -and -not $explicitCrossRuleMerge) { + return $false + } + + if (-not $leafReferences.Count) { + return $RolledFinding.'from-sub-skill' -ceq $LeafProducerId -and + $RolledFinding.id -ceq "${LeafProducerId}:$($LeafFinding.id)" -and + -not $rolledReferences.Count + } + if (-not (Test-ReferencesInclude $rolledReferences $leafReferences)) { + return $false + } + if ($RequirePrimaryOwner) { + $rolledHasDomain = Test-HasProperty $RolledFinding 'domain' + $leafHasDomain = Test-HasProperty $LeafFinding 'domain' + return $RolledFinding.'from-sub-skill' -ceq $LeafProducerId -and + $RolledFinding.id -ceq $LeafFinding.id -and + @($RolledFinding.references)[0].path -ceq $leafReferences[0].path -and + $rolledHasDomain -eq $leafHasDomain -and + (-not $rolledHasDomain -or $RolledFinding.domain -ceq $LeafFinding.domain) + } + return $true + } + + function Test-Report { + param( + [object] $Current, + [string] $ReportPathPrefix, + [ValidateSet('leaf', 'super')] + [string] $CurrentSkillKind + ) + + $findings = @($Current.findings) + foreach ($severity in 'blocker', 'major', 'minor', 'info') { + $actual = @($findings | Where-Object severity -CEQ $severity).Count + if ($Current.summary.counts.$severity -ne $actual) { + Add-Error 'COUNT_MISMATCH' "$ReportPathPrefix.summary.counts.$severity" "Expected $actual." + } + } + $worklistSize = $Current.summary.coverage.'worklist-size' + $itemsEvaluated = $Current.summary.coverage.'items-evaluated' + if ($itemsEvaluated -gt $worklistSize) { + Add-Error 'COVERAGE_INVALID' "$ReportPathPrefix.summary.coverage" 'items-evaluated exceeds worklist-size.' + } + elseif ($Current.outcome -ceq 'completed' -and $itemsEvaluated -ne $worklistSize) { + Add-Error 'COMPLETED_COVERAGE_INCOMPLETE' "$ReportPathPrefix.summary.coverage" 'A completed report must evaluate its full worklist.' + } + elseif ($CurrentSkillKind -ceq 'leaf' -and $Current.outcome -ceq 'partial' -and + ($itemsEvaluated -le 0 -or $itemsEvaluated -ge $worklistSize)) { + Add-Error 'PARTIAL_COVERAGE_INVALID' "$ReportPathPrefix.summary.coverage" 'A partial report must evaluate a non-zero proper subset of its worklist.' + } + + $hasSubResults = Test-HasProperty $Current 'sub-results' + $hasSkippedSubSkills = Test-HasProperty $Current 'skipped-sub-skills' + if ($CurrentSkillKind -ceq 'leaf') { + if ($hasSubResults -or $hasSkippedSubSkills) { + Add-Error 'LEAF_COMPOSITION_INVALID' $ReportPathPrefix 'A leaf report must not contain sub-results or skipped-sub-skills.' + } + } + elseif (-not $hasSubResults) { + Add-Error 'SUPER_SUB_RESULTS_REQUIRED' $ReportPathPrefix 'A super-skill report must contain sub-results.' + } + + for ($index = 0; $index -lt $findings.Count; $index++) { + $finding = $findings[$index] + $findingPath = "$ReportPathPrefix.findings[$index]" + $references = @($finding.references) + $hasProducer = Test-HasProperty $finding 'from-sub-skill' + if ($CurrentSkillKind -ceq 'leaf' -and $hasProducer) { + Add-Error 'LEAF_PRODUCER_INVALID' "$findingPath.from-sub-skill" 'A leaf finding must not contain from-sub-skill.' + } + elseif ($CurrentSkillKind -ceq 'super' -and -not $hasProducer) { + Add-Error 'SUPER_PRODUCER_REQUIRED' $findingPath 'A super-skill finding must identify its producer in from-sub-skill.' + } + if (-not $references.Count) { + if ($finding.id -cnotmatch '(^|:)agent:[a-z0-9]+(?:-[a-z0-9]+)*$') { + Add-Error 'AGENT_ID_INVALID' "$findingPath.id" 'An agent finding id must contain an agent: slug marker.' + } + if ($finding.confidence -ceq 'high') { + Add-Error 'AGENT_CONFIDENCE_INVALID' "$findingPath.confidence" 'Agent confidence cannot be high.' + } + if ($finding.severity -cin @('blocker', 'major')) { + Add-Error 'AGENT_SEVERITY_INVALID' "$findingPath.severity" 'Agent severity cannot exceed minor.' + } + } + else { + if ($finding.id -cne $references[0].path) { + Add-Error 'PRIMARY_REFERENCE_MISMATCH' "$findingPath.id" 'Finding id must equal the primary reference path.' + } + foreach ($reference in $references) { + if ($reference.path -cnotmatch '^(microsoft|community|custom)/knowledge/.+\.md$') { + Add-Error 'REFERENCE_PATH_INVALID' "$findingPath.references" "Invalid knowledge path '$($reference.path)'." + continue + } + if (-not (Test-Path -LiteralPath (Join-Path $BCQualityRoot $reference.path) -PathType Leaf)) { + Add-Error 'REFERENCE_MISSING' "$findingPath.references" "Knowledge path '$($reference.path)' does not exist." + } + if (-not $retrieved.Contains($reference.path)) { + Add-Error 'REFERENCE_NOT_RETRIEVED' "$findingPath.references" "Knowledge path '$($reference.path)' was not retrieved in full." + } + } + } + + if (Test-HasProperty $finding 'location') { + $location = $finding.location + if (-not $sources.Contains($location.file)) { + Add-Error 'SOURCE_OUT_OF_SCOPE' "$findingPath.location.file" "Source path '$($location.file)' is outside the supplied scope." + } + $lineCount = Get-SourceLineCount $location.file + if ($lineCount -lt 0) { + Add-Error 'SOURCE_MISSING' "$findingPath.location.file" "Source path '$($location.file)' does not exist." + } + elseif ($location.line -gt $lineCount) { + Add-Error 'SOURCE_LINE_INVALID' "$findingPath.location.line" "Line exceeds the file's $lineCount lines." + } + + if (Test-HasProperty $location 'range') { + $range = $location.range + if ($range.'start-line' -ne $location.line) { + Add-Error 'RANGE_START_MISMATCH' "$findingPath.location.range.start-line" 'start-line must equal line.' + } + if ($range.'end-line' -lt $range.'start-line' -or + ($lineCount -ge 0 -and $range.'end-line' -gt $lineCount)) { + Add-Error 'SOURCE_RANGE_INVALID' "$findingPath.location.range" 'Range is reversed or exceeds the source file.' + } + } + } + } + + if ($CurrentSkillKind -ceq 'super' -and $hasSubResults) { + $subResults = @($Current.'sub-results') + for ($index = 0; $index -lt $subResults.Count; $index++) { + Test-Report $subResults[$index] "$ReportPathPrefix.sub-results[$index]" 'leaf' + } + + $expectedOutcome = Get-DerivedSuperOutcome $subResults + if ($Current.outcome -cne $expectedOutcome) { + Add-Error 'SUPER_OUTCOME_MISMATCH' "$ReportPathPrefix.outcome" "Expected '$expectedOutcome' from sub-results." + } + + $includedSubResults = @($subResults | Where-Object outcome -CNE 'failed') + $expectedWorklistSize = ($includedSubResults | Measure-Object -Property { $_.summary.coverage.'worklist-size' } -Sum).Sum + $expectedItemsEvaluated = ($includedSubResults | Measure-Object -Property { $_.summary.coverage.'items-evaluated' } -Sum).Sum + if ($null -eq $expectedWorklistSize) { $expectedWorklistSize = 0 } + if ($null -eq $expectedItemsEvaluated) { $expectedItemsEvaluated = 0 } + if ($worklistSize -ne $expectedWorklistSize -or $itemsEvaluated -ne $expectedItemsEvaluated) { + Add-Error 'SUPER_COVERAGE_MISMATCH' "$ReportPathPrefix.summary.coverage" ` + "Expected worklist-size $expectedWorklistSize and items-evaluated $expectedItemsEvaluated from non-failed sub-results." + } + + $failedProducerIds = @($subResults | Where-Object outcome -CEQ 'failed' | ForEach-Object { $_.skill.id }) + $includedProducerIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $eligibleFindings = [Collections.Generic.List[object]]::new() + foreach ($subResult in $includedSubResults) { + $includedProducerIds.Add([string]$subResult.skill.id) | Out-Null + foreach ($finding in @($subResult.findings)) { + $eligibleFindings.Add([pscustomobject]@{ + ProducerId = [string]$subResult.skill.id + Finding = $finding + }) | Out-Null + } + } + + for ($index = 0; $index -lt $findings.Count; $index++) { + $finding = $findings[$index] + $producerId = [string]$finding.'from-sub-skill' + if ($producerId -ceq 'agent') { + if (@($finding.references).Count -or + $finding.id -cnotmatch '^agent:[a-z0-9]+(?:-[a-z0-9]+)*$' -or + -not (Test-HasProperty $finding 'domain') -or + $finding.domain -cne 'Agent') { + Add-Error 'SUPER_AGENT_FINDING_INVALID' "$ReportPathPrefix.findings[$index]" ` + 'A super-skill agent finding must use an agent: id, Agent domain, and no references.' + } + continue + } + if ($producerId -cin $failedProducerIds) { + Add-Error 'SUPER_FAILED_FINDING_LEAKAGE' "$ReportPathPrefix.findings[$index].from-sub-skill" ` + "Finding is attributed to failed sub-skill '$producerId'." + continue + } + if (-not $includedProducerIds.Contains($producerId)) { + Add-Error 'SUPER_PRODUCER_INVALID' "$ReportPathPrefix.findings[$index].from-sub-skill" ` + "Sub-skill '$producerId' has no non-failed result." + continue + } + $ownedLeafFindings = @($eligibleFindings | Where-Object { + $_.ProducerId -ceq $producerId -and + (Test-RolledFindingRepresents $finding $_.Finding $_.ProducerId -RequirePrimaryOwner) + }) + if (-not $ownedLeafFindings.Count) { + Add-Error 'SUPER_FINDING_MISMATCH' "$ReportPathPrefix.findings[$index]" ` + "Rolled-up finding '$($finding.id)' is not owned by a matching finding from '$producerId'." + continue + } + $representedLeafFindings = @($eligibleFindings | Where-Object { + Test-RolledFindingRepresents $finding $_.Finding $_.ProducerId + }) + $representedCorrections = @( + $representedLeafFindings | + Where-Object { Test-HasProperty $_.Finding 'suggested-code' } | + ForEach-Object { $_.Finding.'suggested-code' } | + Sort-Object -CaseSensitive -Unique + ) + if ($representedCorrections.Count -gt 1) { + Add-Error 'SUPER_FINDING_MISMATCH' "$ReportPathPrefix.findings[$index]" ` + "Rolled-up finding '$($finding.id)' represents leaf findings with conflicting suggested-code replacements." + continue + } + $expectedSeverityRank = ($representedLeafFindings | ForEach-Object { Get-SeverityRank $_.Finding.severity } | Measure-Object -Maximum).Maximum + $expectedConfidenceRank = ($representedLeafFindings | ForEach-Object { Get-ConfidenceRank $_.Finding.confidence } | Measure-Object -Maximum).Maximum + if ((Get-SeverityRank $finding.severity) -ne $expectedSeverityRank -or + (Get-ConfidenceRank $finding.confidence) -ne $expectedConfidenceRank) { + Add-Error 'SUPER_FINDING_MISMATCH' "$ReportPathPrefix.findings[$index]" ` + "Rolled-up finding '$($finding.id)' must retain the highest severity and confidence justified by its represented leaf findings." + } + } + + for ($firstIndex = 0; $firstIndex -lt $findings.Count; $firstIndex++) { + for ($secondIndex = $firstIndex + 1; $secondIndex -lt $findings.Count; $secondIndex++) { + if ((Test-HasProperty $findings[$firstIndex] 'location') -and + (Test-HasProperty $findings[$secondIndex] 'location') -and + (Test-LocationsOverlap $findings[$firstIndex] $findings[$secondIndex]) -and + (Test-SameCorrection $findings[$firstIndex] $findings[$secondIndex])) { + Add-Error 'SUPER_DUPLICATE_FINDINGS' "$ReportPathPrefix.findings[$secondIndex]" ` + "Top-level findings $firstIndex and $secondIndex overlap and prescribe the same correction; they must be merged." + } + } + } + + $usedLocationlessFindings = [Collections.Generic.HashSet[int]]::new() + foreach ($eligibleFinding in @($eligibleFindings | Where-Object { -not (Test-HasProperty $_.Finding 'location') })) { + $matchingIndex = -1 + for ($index = 0; $index -lt $findings.Count; $index++) { + if (-not $usedLocationlessFindings.Contains($index) -and + -not (Test-HasProperty $findings[$index] 'location') -and + (Test-RolledFindingRepresents $findings[$index] $eligibleFinding.Finding $eligibleFinding.ProducerId)) { + $matchingIndex = $index + break + } + } + if ($matchingIndex -lt 0) { + Add-Error 'SUPER_FINDING_MISSING' "$ReportPathPrefix.findings" ` + "No distinct rolled-up finding represents a locationless finding from '$($eligibleFinding.ProducerId)'." + } + else { + $usedLocationlessFindings.Add($matchingIndex) | Out-Null + } + } + for ($index = 0; $index -lt $findings.Count; $index++) { + if ($findings[$index].'from-sub-skill' -cne 'agent' -and + -not (Test-HasProperty $findings[$index] 'location') -and + -not $usedLocationlessFindings.Contains($index)) { + Add-Error 'SUPER_FINDING_MISMATCH' "$ReportPathPrefix.findings[$index]" ` + 'No distinct locationless leaf finding corresponds to this rolled-up finding.' + } + } + + foreach ($eligibleFinding in @($eligibleFindings | Where-Object { Test-HasProperty $_.Finding 'location' })) { + $represented = @($findings | Where-Object { + $_.'from-sub-skill' -cne 'agent' -and + (Test-RolledFindingRepresents $_ $eligibleFinding.Finding $eligibleFinding.ProducerId) + }).Count + if (-not $represented) { + Add-Error 'SUPER_FINDING_MISSING' "$ReportPathPrefix.findings" ` + "No valid rolled-up finding represents a finding from '$($eligibleFinding.ProducerId)' at its source location." + } + } + } + } + + Test-Report $Candidate '$' $SkillKind + if ($PermitRangeStartMismatch) { + return @($errors | Where-Object Code -CNE 'RANGE_START_MISMATCH') + } + return @($errors) +} + +$errors = @(Get-SemanticErrors $report) +$normalized = $false +$removedRanges = [Collections.Generic.List[object]]::new() +if ($errors.Count -and $AllowBoundedNormalization) { + $otherErrors = @($errors | Where-Object Code -CNE 'RANGE_START_MISMATCH') + $rangeErrors = @($errors | Where-Object Code -CEQ 'RANGE_START_MISMATCH') + if (-not $otherErrors.Count -and $rangeErrors.Count) { + $candidate = $report | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 + $eligible = $true + foreach ($finding in @($candidate.findings)) { + if (-not (Test-HasProperty $finding 'location') -or + -not (Test-HasProperty $finding.location 'range') -or + $finding.location.range.'start-line' -eq $finding.location.line) { + continue + } + $range = $finding.location.range + if ($range.'start-line' -gt $finding.location.line -or + $finding.location.line -gt $range.'end-line' -or + (Test-HasProperty $finding 'suggested-code')) { + $eligible = $false + break + } + $removedRanges.Add([pscustomobject]@{ + findingId = $finding.id + file = $finding.location.file + line = $finding.location.line + startLine = $range.'start-line' + endLine = $range.'end-line' + }) | Out-Null + $finding.location.PSObject.Properties.Remove('range') + } + if ($eligible -and -not @(Get-SemanticErrors $candidate).Count) { + $report = $candidate + $normalized = $true + $errors = @() + } + } +} + +if ($errors.Count) { + $details = @($errors | ForEach-Object { "$($_.Code) at $($_.Path): $($_.Message)" }) -join '; ' + throw "Findings-report acceptance failed: $details" +} + +return [pscustomobject][ordered]@{ + normalized = $normalized + report = $report + removedRanges = @($removedRanges) +} \ No newline at end of file From f63943dcfdfd1bb776441b2f9f08966f97295a01 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:12:51 +0200 Subject: [PATCH 34/51] 18 more AL/BC patterns: data-modeling, testing, style, security, error-handling, ui, upgrade, web-services, appsource (#157) * Add 18 more community AL/BC patterns across appsource, data-modeling, error-handling, security, style, testing, ui, upgrade, and web-services Second contribution from CURABIS ApS, generalized from patterns observed across real AppSource/PTE development. Cross-checked against the current microsoft/knowledge corpus before opening; several originally-drafted candidates were dropped as duplicates of existing files. * Address Jesper Schulz-Wedde's review on PR #157 - release-must-update-app-version.md: reframe around AppSource's actual strict full-version-ordering requirement; scope branching-policy claims as team convention, not platform rule. - pictures-must-use-media-not-blob.md: MediaSet is a collection of independent media objects, not automatic image variants/thumbnails. - log-writes-must-survive-rollback.{md,good.al}: StartSession's only data channel into the new session is its Record parameter to a TableNo-scoped codeunit; a setter called on a local instance before starting the session populates nothing in the new session. - exposed-objects-must-be-in-a-permission-set.md: correct the three exposure mechanisms (Web Services config, PageType/QueryType=API, ServiceEnabled as a method-only attribute). - pages-must-not-contain-business-logic.md: scope to persisted mutations and cross-entry-point rules; presentation-only calculations and table-owned invariants are not violations. - given-blocks-must-cover-full-precondition-chain.good.al: replace invented LibrarySales calls with the real API (CreateCustomer/CreateSalesOrderForCustomerNo/PostSalesDocument). - test-feature-scenario-tags.{md,good.al}: move [SCENARIO] inside the test procedure body to match the current BCApps corpus; keep [FEATURE] at codeunit level per Microsoft's own documented option. - ui-test-codeunit-naming.md: scope the _UT suffix and adjacent-ID pairing as an explicit team convention, not a BCApps-wide standard. - page-design-must-match-bc-page-type-conventions.md / table-design-must-match-bc-table-type-conventions.md: Card's single-key primary-key claim is a contextual heuristic, not a mandatory constraint (Ship-to Address, Customer/Vendor Bank Account are real composite-key Card pages); a Subsidiary table with its own identity commonly gets List+Card, not Worksheet/Tabular. - api-page-least-privilege-write-access.{md,good.al}: only page-placed fields are ever exposed; set InsertAllowed/DeleteAllowed=false in the good sample so a narrow field set can't still create/delete records. - source-organized-by-feature-not-object-type.md, test-one-when-per-test.md: scope as team/testing-design conventions, not Microsoft platform requirements. - upgrade-tag-logic-must-not-nest-deeply.md: add the Microsoft Learn citation that already backs the two-level nesting limit. - Wire the new articles into the testing/data-modeling/error-handling/ security/ui review skills' candidate-selection signals. Co-Authored-By: Claude Sonnet 5 * 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 * Fix ten focused correctness items plus sample links from Jesper's 2026-09-15 re-review Six carried-over threads: - api-page-least-privilege-write-access fixtures: added the mandatory EntityName/EntitySetName properties (AL0485). - pages-must-not-contain-business-logic fixtures: Sales Line has no "Total Amount" field; replaced with the real "Line Amount" (field 103). - test-feature-scenario-tags.good.al and test-one-when-per-test.good.al: CreatePriceHeader leaves a price list in Draft status, which price calculation ignores. Added Validate(Status, Active) + Modify before the sales line that depends on it. Verified Status field/enum against PriceListHeader.Table.al and PriceStatus.Enum.al in the BCApps clone. - exposed-objects-must-be-in-a-permission-set.md: a published codeunit is a SOAP endpoint (SOAP is deprecated), not OData - Page/Query are the OData object types. Corrected and pointed new integrations at API pages/queries instead. - al-error-handling-review.md: the log-writes-must-survive-rollback cue selected on Session.StartSession, which only appears in the compliant fix, never in the anti-pattern - the bad fixture could never be worklisted. Recued on the actual risk shape (log insert around a failed TryFunction/GetLastError* path, then raise/propagate), with StartSession as an explicit compliant discriminator instead. - page-design-must-match-bc-page-type-conventions.md: the enum value is NavigatePage, not Navigate; noted the type list is a selected subset, not an exhaustive PageType catalogue (PromptDialog, ConfigurationDialog, UserControlHost, XmlPort also exist, out of this article's scope). Four new correctness gaps: - release-must-update-app-version.md: "the version is the only identity" was backwards - id is the app's stable identity, version identifies a release/code-state of it. - defensive-vs-offensive-code-must-match-blast-radius.good.al: the "low blast radius" example had no else branch, so a failed Customer.Get() left the field at its prior/default value instead of the explicit chosen fallback the article claims to demonstrate. Added the else. - bcpt-scenarios-must-be-app-specific.good.al: InitTest and both measured StartScenario/EndScenario sections were empty/comment-only, so the "app-specific" fixture measured no actual work. Filled in a real, self-contained header+line creation path. - upgrade-tag-logic-must-not-nest-deeply.good.al: the flattened version dropped both safety conditions the bad fixture had (Discount % = 0, nonblank posting group), silently changing behavior instead of just removing nesting. Extracted the guarded update into a helper with both conditions preserved as early exits. Also converted this PR's remaining plain-backtick "See sample:" sample references (16 articles) to the READ-convention markdown-link form, matching the fix already made on #156/#158. Rebased onto upstream/main (conflicts in al-ui-review.md, al-style-review.md, al-upgrade-review.md against merged upstream PRs - all additive, both sides' worklist cues retained). * Fix four merge-critical issues from Jesper's 2026-09-22 review - pages-must-not-contain-business-logic.good.al/.bad.al: the "good" codeunit still directly assigned real Sales Line."Line Amount" and called Modify(), bypassing the field's normal Validate cascade (discount, VAT, related-amount maintenance) - persisting inconsistent document lines regardless of which object the code lived in. Replaced the real Sales Line example with a self-contained "Sample Order Line" table and switched the codeunit to Validate()/Modify(true), so the fixture demonstrates the page-vs-codeunit separation without teaching unsafe direct field writes to a real BC document table. - bcpt-scenarios-must-be-app-specific.good.al: Customer.FindFirst() assumed a pre-existing customer (fails against an empty environment), and a session-local NextNo counter for the header key collides across concurrent BCPT sessions and repeated runs. Creates its own customer when none exists, and generates keys from CreateGuid() instead of an in-memory counter. - upgrade-tag-logic-must-not-nest-deeply: the rule conflated two different things - nesting one tag's existence check inside another (the real anti-pattern Microsoft's guidance warns against) with having business-data safety conditions inside a single tagged migration's own loop body (which Microsoft's own worked example does, and its own design guidance explicitly requires: "Implement extra safety checks to avoid data corruption, even though you're using upgrade tags"). Rewrote the Description/Best Practice/Anti Pattern to scope the rule to actual tag nesting and migrations blended under one tag, and rewrote both fixtures: good.al now shows two safety conditions correctly nested inside one migration's own loop plus a second, genuinely separate migration as its own flat tagged procedure; bad.al now shows the real anti-pattern, one tag's check nested inside another's guarded body. - table-design-must-match-bc-table-type-conventions: the rule and its worklist cue fired on any new table with a keys block, forcing buffers, queues, logs, mapping tables, and staging tables into the nearest-looking one of nine business-record archetypes. Added an explicit scope note that these nine types aren't an exhaustive table catalogue, and narrowed the al-data-modeling-review.md cue to require positive evidence (a type-specific naming suffix, key shape, or usage) before worklisting, instead of a bare keys/primary-key declaration. * Narrow upgrade-tag nesting cue to match revised article Cue now flags only nested upgrade-tag checks or functionally unrelated migrations under one tag, and explicitly excludes record loops and business-data safety guards belonging to a single migration. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../release-must-update-app-version.md | 37 +++++++ .../code-must-not-change-workdate.bad.al | 9 ++ .../code-must-not-change-workdate.good.al | 11 +++ .../code-must-not-change-workdate.md | 58 +++++++++++ .../pictures-must-use-media-not-blob.bad.al | 11 +++ .../pictures-must-use-media-not-blob.good.al | 11 +++ .../pictures-must-use-media-not-blob.md | 49 +++++++++ ...ust-match-bc-table-type-conventions.bad.al | 17 ++++ ...st-match-bc-table-type-conventions.good.al | 16 +++ ...gn-must-match-bc-table-type-conventions.md | 99 +++++++++++++++++++ ...ensive-code-must-match-blast-radius.bad.al | 11 +++ ...nsive-code-must-match-blast-radius.good.al | 14 +++ ...-offensive-code-must-match-blast-radius.md | 26 +++++ .../log-writes-must-survive-rollback.bad.al | 24 +++++ .../log-writes-must-survive-rollback.good.al | 45 +++++++++ .../log-writes-must-survive-rollback.md | 26 +++++ ...objects-must-be-in-a-permission-set.bad.al | 12 +++ ...bjects-must-be-in-a-permission-set.good.al | 10 ++ ...sed-objects-must-be-in-a-permission-set.md | 32 ++++++ ...not-restate-what-code-already-shows.bad.al | 18 ++++ ...ot-restate-what-code-already-shows.good.al | 20 ++++ ...ust-not-restate-what-code-already-shows.md | 30 ++++++ ...ges-must-not-contain-business-logic.bad.al | 49 +++++++++ ...es-must-not-contain-business-logic.good.al | 60 +++++++++++ .../pages-must-not-contain-business-logic.md | 31 ++++++ ...ce-organized-by-feature-not-object-type.md | 48 +++++++++ ...bcpt-scenarios-must-be-app-specific.bad.al | 29 ++++++ ...cpt-scenarios-must-be-app-specific.good.al | 73 ++++++++++++++ .../bcpt-scenarios-must-be-app-specific.md | 26 +++++ ...-must-cover-full-precondition-chain.bad.al | 15 +++ ...must-cover-full-precondition-chain.good.al | 20 ++++ ...ocks-must-cover-full-precondition-chain.md | 26 +++++ .../testing/test-feature-scenario-tags.bad.al | 33 +++++++ .../test-feature-scenario-tags.good.al | 40 ++++++++ .../testing/test-feature-scenario-tags.md | 26 +++++ .../testing/test-one-when-per-test.bad.al | 32 ++++++ .../testing/test-one-when-per-test.good.al | 56 +++++++++++ .../testing/test-one-when-per-test.md | 34 +++++++ .../testing/ui-test-codeunit-naming.bad.al | 27 +++++ .../testing/ui-test-codeunit-naming.good.al | 42 ++++++++ .../testing/ui-test-codeunit-naming.md | 26 +++++ ...must-match-bc-page-type-conventions.bad.al | 21 ++++ ...ust-match-bc-page-type-conventions.good.al | 20 ++++ ...ign-must-match-bc-page-type-conventions.md | 90 +++++++++++++++++ ...rade-tag-logic-must-not-nest-deeply.bad.al | 32 ++++++ ...ade-tag-logic-must-not-nest-deeply.good.al | 40 ++++++++ .../upgrade-tag-logic-must-not-nest-deeply.md | 34 +++++++ ...i-page-least-privilege-write-access.bad.al | 25 +++++ ...-page-least-privilege-write-access.good.al | 25 +++++ .../api-page-least-privilege-write-access.md | 26 +++++ .../skills/review/al-appsource-review.md | 1 + .../skills/review/al-data-modeling-review.md | 3 + .../skills/review/al-error-handling-review.md | 2 + microsoft/skills/review/al-security-review.md | 3 +- microsoft/skills/review/al-style-review.md | 3 + microsoft/skills/review/al-testing-review.md | 5 + microsoft/skills/review/al-ui-review.md | 3 +- microsoft/skills/review/al-upgrade-review.md | 1 + .../skills/review/al-web-services-review.md | 1 + 59 files changed, 1612 insertions(+), 2 deletions(-) create mode 100644 microsoft/knowledge/appsource/release-must-update-app-version.md create mode 100644 microsoft/knowledge/data-modeling/code-must-not-change-workdate.bad.al create mode 100644 microsoft/knowledge/data-modeling/code-must-not-change-workdate.good.al create mode 100644 microsoft/knowledge/data-modeling/code-must-not-change-workdate.md create mode 100644 microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.bad.al create mode 100644 microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.good.al create mode 100644 microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.md create mode 100644 microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.bad.al create mode 100644 microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.good.al create mode 100644 microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.md create mode 100644 microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.bad.al create mode 100644 microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.good.al create mode 100644 microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.md create mode 100644 microsoft/knowledge/error-handling/log-writes-must-survive-rollback.bad.al create mode 100644 microsoft/knowledge/error-handling/log-writes-must-survive-rollback.good.al create mode 100644 microsoft/knowledge/error-handling/log-writes-must-survive-rollback.md create mode 100644 microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.bad.al create mode 100644 microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.good.al create mode 100644 microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.md create mode 100644 microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.bad.al create mode 100644 microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.good.al create mode 100644 microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md create mode 100644 microsoft/knowledge/style/pages-must-not-contain-business-logic.bad.al create mode 100644 microsoft/knowledge/style/pages-must-not-contain-business-logic.good.al create mode 100644 microsoft/knowledge/style/pages-must-not-contain-business-logic.md create mode 100644 microsoft/knowledge/style/source-organized-by-feature-not-object-type.md create mode 100644 microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.bad.al create mode 100644 microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.good.al create mode 100644 microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.md create mode 100644 microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.bad.al create mode 100644 microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.good.al create mode 100644 microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.md create mode 100644 microsoft/knowledge/testing/test-feature-scenario-tags.bad.al create mode 100644 microsoft/knowledge/testing/test-feature-scenario-tags.good.al create mode 100644 microsoft/knowledge/testing/test-feature-scenario-tags.md create mode 100644 microsoft/knowledge/testing/test-one-when-per-test.bad.al create mode 100644 microsoft/knowledge/testing/test-one-when-per-test.good.al create mode 100644 microsoft/knowledge/testing/test-one-when-per-test.md create mode 100644 microsoft/knowledge/testing/ui-test-codeunit-naming.bad.al create mode 100644 microsoft/knowledge/testing/ui-test-codeunit-naming.good.al create mode 100644 microsoft/knowledge/testing/ui-test-codeunit-naming.md create mode 100644 microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.bad.al create mode 100644 microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.good.al create mode 100644 microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.md create mode 100644 microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.bad.al create mode 100644 microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.good.al create mode 100644 microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.md create mode 100644 microsoft/knowledge/web-services/api-page-least-privilege-write-access.bad.al create mode 100644 microsoft/knowledge/web-services/api-page-least-privilege-write-access.good.al create mode 100644 microsoft/knowledge/web-services/api-page-least-privilege-write-access.md diff --git a/microsoft/knowledge/appsource/release-must-update-app-version.md b/microsoft/knowledge/appsource/release-must-update-app-version.md new file mode 100644 index 0000000..fb8f483 --- /dev/null +++ b/microsoft/knowledge/appsource/release-must-update-app-version.md @@ -0,0 +1,37 @@ +--- +bc-version: [all] +domain: appsource +keywords: [version, release, app-json, semver, al-go, appsource] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Update the app version at every release + +## Description + +At every release — a branch merged to `main`, a tagged release build, or an AppSource submission — the app's version is consciously updated, not left to the pipeline alone. + +| Version part | Owner | When | +|---|---|---| +| Major | Developer decision | Breaking change (schema, API, removed objects) | +| Minor | Developer decision | Every release with new functionality | +| Build / Revision | AL-Go pipeline | Automatic — never hand-edited | + +The app's stable identity is its `id` in `app.json`; the version identifies which release — which code state — of that app is deployed. Two customer environments running "the same" version with different code is an undiagnosable support case. AppSource's actual requirement is strict full-version ordering — the complete version must be greater than the previously submitted version — which an AL-Go-generated build/revision increment can satisfy on its own; AppSource does not require major.minor itself to change. Treating major.minor as a deliberate, human-decided compatibility signal is still valuable practice — it is a statement about what changed that no pipeline can make on its own — just not a platform-enforced requirement. + +## Best Practice + + Before the release merge: + app.json: "version": "1.3.0.0" (new functionality -> minor bump, by team convention) + AL-Go settings: "repoVersion": "1.3" (where used) + Then: feature branch -> main via PR, tag, release. + +"Feature branches never touch the version" and "every merge to main is a release" are workflow choices your team can adopt for compatibility clarity — not something AppSource itself requires. + +## Anti Pattern + + Branch merged to main and released. + app.json still says "version": "1.2.0.0" -- same as the previous release. + Two different code states now share one version identity. diff --git a/microsoft/knowledge/data-modeling/code-must-not-change-workdate.bad.al b/microsoft/knowledge/data-modeling/code-must-not-change-workdate.bad.al new file mode 100644 index 0000000..0c265e9 --- /dev/null +++ b/microsoft/knowledge/data-modeling/code-must-not-change-workdate.bad.al @@ -0,0 +1,9 @@ +codeunit 50101 "Batch Job Runner" +{ + procedure AdvanceToNextBusinessDay() + begin + // Anti-pattern: repurposes the user's session WorkDate as a + // scratch variable for unrelated business logic. + WorkDate(CalcDate('<1D>', WorkDate())); + end; +} diff --git a/microsoft/knowledge/data-modeling/code-must-not-change-workdate.good.al b/microsoft/knowledge/data-modeling/code-must-not-change-workdate.good.al new file mode 100644 index 0000000..f1dcf5e --- /dev/null +++ b/microsoft/knowledge/data-modeling/code-must-not-change-workdate.good.al @@ -0,0 +1,11 @@ +codeunit 50100 "Posting Date Helper" +{ + procedure GetDefaultPostingDate(): Date + var + PostingDate: Date; + begin + // Read the work date to default a value; never write to it. + PostingDate := WorkDate(); + exit(PostingDate); + end; +} diff --git a/microsoft/knowledge/data-modeling/code-must-not-change-workdate.md b/microsoft/knowledge/data-modeling/code-must-not-change-workdate.md new file mode 100644 index 0000000..3e2ea0e --- /dev/null +++ b/microsoft/knowledge/data-modeling/code-must-not-change-workdate.md @@ -0,0 +1,58 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [workdate, session-setting, user-control, side-effect] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Application code must not change the WorkDate + +## Description + +The work date is a per-user session setting the user controls from the +client (the date shown in the top-right corner, used to default posting +dates and date filters). Business logic unrelated to that setting must not +call `WorkDate(NewDate)` as a side effect of doing something else — that +silently changes what the user sees and defaults to for the rest of their +session, a surprising, hard-to-trace behavior change the user never asked +for and has no visibility into. This is not a blanket ban on the setter +itself: BCApps' own demo-data generators legitimately save the current +work date, set a specific one to backdate the data they create, and +restore it afterward (see `CreateDemoEDocsBE.Codeunit.al`'s +`WorkDate(SampleInvoiceDate)` / `WorkDate(SavedWorkDate)` pair), and test +codeunits routinely set `WorkDate` deliberately to control the date context +a test runs under (hundreds of calls across BCApps' test suite, for +example `SustainabilityPostingTest.Codeunit.al`). Both are the code's +*actual purpose*, not a side effect of something unrelated. + +This is a call-direction distinction for the read side: reading the +current work date via `WorkDate` (or `WorkDate()` with no argument) is +always fine. + +## Best Practice + +Read the work date to default a value. 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`](code-must-not-change-workdate.good.al). + +## Anti Pattern + +Setting the work date from within a codeunit, report, or page action whose +purpose is unrelated to the user's date preference — for example, a +posting or calculation routine that calls `WorkDate(SomeDate)` to make its +own logic simpler. This changes session state the user owns for the +duration of a call that was never about the work date, and never restores +it. This is a different case from a test or demo-data routine explicitly +declaring a date context: the anti-pattern is unrelated logic silently +mutating state it does not own, not the setter form itself. + +See sample: [`code-must-not-change-workdate.bad.al`](code-must-not-change-workdate.bad.al). diff --git a/microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.bad.al b/microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.bad.al new file mode 100644 index 0000000..e20775a --- /dev/null +++ b/microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.bad.al @@ -0,0 +1,11 @@ +table 50111 "Sample Item Card" +{ + fields + { + field(1; "No."; Code[20]) { } + field(50; Picture; BLOB) + { + Caption = 'Picture'; + } + } +} diff --git a/microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.good.al b/microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.good.al new file mode 100644 index 0000000..4fec633 --- /dev/null +++ b/microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.good.al @@ -0,0 +1,11 @@ +table 50110 "Sample Item Card" +{ + fields + { + field(1; "No."; Code[20]) { } + field(50; Picture; Media) + { + Caption = 'Picture'; + } + } +} diff --git a/microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.md b/microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.md new file mode 100644 index 0000000..1767a0d --- /dev/null +++ b/microsoft/knowledge/data-modeling/pictures-must-use-media-not-blob.md @@ -0,0 +1,49 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [blob, media, mediaset, picture-field, image-field, table-design] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pictures must be stored in a Media/MediaSet field, not BLOB + +## Description + +`BLOB` is still a valid AL field type for arbitrary binary data, but it is +not the right choice for storing pictures or images. The current +recommendation is the `Media` field type for a single image, or +`MediaSet` when a record needs several independent images (e.g. multiple +product photos) — `MediaSet` is a collection of separately-imported media +objects, each with its own identity; it does not generate resized variants +or thumbnails on its own, and displaying more than one item still requires +custom page handling. Media/MediaSet integrate with the platform's +picture control and media repository, which a plain `BLOB` field does not +— but any derived preview or thumbnail image still has to be generated +explicitly and stored in its own field, regardless of which type holds the +source image. + +`BLOB` remains the correct choice for genuinely arbitrary binary payloads +that are not images and don't benefit from the media pipeline (e.g. a raw +file attachment blob unrelated to picture rendering). + +## Best Practice + +Use `Media` for a single image, or `MediaSet` for multiple independent +images, for any field that holds a picture. + +See sample: [`pictures-must-use-media-not-blob.good.al`](pictures-must-use-media-not-blob.good.al). + +## Anti Pattern + +A `BLOB` field named "Picture" compiles and stores the image bytes, but +it misses the picture control integration and media repository that a +`Media`/`MediaSet` field provides for free — the anti pattern is choosing +`BLOB` for image storage out of habit rather than recognizing that the +field is holding a picture, not generic binary data. A related anti +pattern: assuming `MediaSet` gives automatic image variants or thumbnails +because it sounds like a collection with derived versions — it is only a +collection of independently-imported media objects. + +See sample: [`pictures-must-use-media-not-blob.bad.al`](pictures-must-use-media-not-blob.bad.al). diff --git a/microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.bad.al b/microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.bad.al new file mode 100644 index 0000000..c241640 --- /dev/null +++ b/microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.bad.al @@ -0,0 +1,17 @@ +table 50121 "Sample Ledger Entry" +{ + fields + { + // Anti-pattern: a Ledger table's key must never be user-editable. + field(1; "Entry No."; Integer) { } + field(2; "Posting Date"; Date) { } + field(3; Amount; Decimal) { } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + } + // No AutoIncrement, no guard against manual insert/delete — a user or + // integration can renumber or remove entries, breaking the Ledger + // type's audit-trail guarantee. +} diff --git a/microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.good.al b/microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.good.al new file mode 100644 index 0000000..825ca45 --- /dev/null +++ b/microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.good.al @@ -0,0 +1,16 @@ +table 50120 "Sample Ledger Entry" +{ + fields + { + // Ledger primary key: Integer "Entry No.", set only by posting. + field(1; "Entry No."; Integer) { AutoIncrement = true; } + field(2; "Posting Date"; Date) { } + field(3; Amount; Decimal) { } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + } + // No user-facing Insert/Delete/Modify path is exposed; rows are + // created exclusively by the posting routine. +} diff --git a/microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.md b/microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.md new file mode 100644 index 0000000..ce82f9b --- /dev/null +++ b/microsoft/knowledge/data-modeling/table-design-must-match-bc-table-type-conventions.md @@ -0,0 +1,99 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [tables, table-design, naming-conventions, primary-key, master-table, ledger-table, journal-table, register-table, document-table, setup-table, subsidiary-table, supplemental-table] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Tables must match one of Business Central's nine table-type conventions + +## Description + +Business Central's Base Application follows nine recurring table types — +Master, Supplemental, Subsidiary, Ledger, Register, Journal, Document, +Document History, and Setup. Each type fixes a naming pattern, a +primary-key shape, and a set of associated pages. A new or extended table +whose design doesn't match the conventions of its own type is either +misclassified or built inconsistently with the rest of the application, +and should be flagged in review even if it compiles. Before assigning a +primary key or naming a new table, first identify which of the nine types +it is — that answer fixes almost every other design decision. + +## Best Practice + +Match the table's design to its type: + +1. **Master** (Customer, Item) — one record is the subject; primary key + `Code[20]` named `No.`; description field in `DataCaptionFields`; Card + + List (+ Statistics) pages. +2. **Supplemental** (Currency, Language) — used across functional areas; + primary key `Code[10]` named `Code`; one List page, plural name, set as + `LookupPageID`. +3. **Subsidiary** (Item Vendor) — subsidiary to a Master/Supplemental + table; primary key is the parent key field(s), optionally + `Line No.`; + page shape depends on whether the table carries its own identity: a + pure parent-join table (Item Vendor) typically gets a plain List page + filtered by the calling page, while a subsidiary table that supplements + a master record with its own identity — parent key + own code, e.g. + Ship-to Address, Customer/Vendor Bank Account — commonly gets a + List+Card pair instead, for direct editing of that record. +4. **Ledger** (Cust. Ledger Entry) — transactional record of a functional + area; primary key `Integer` `Entry No.`, always auto-generated by + posting, never user-editable, no free add/delete; List page as + `LookupPageID`/`DrillDownPageID`. +5. **Register** (G/L Register) — table of contents for its Ledger, one row + per posting run; primary key `Integer` `No.`, auto-generated; carries + `From Entry No.`/`To Entry No.`; List page with a link to the Ledger. +6. **Journal** (Resource Journal Line) — where users enter data before + posting to a Ledger; primary key Template + Batch + `Integer` `Line No.`; + Worksheet page with `AutoSplitKey`, a Posting action, and a link to the + Ledger. +7. **Document** (Sales Header/Line) — posts to Ledgers via Journals, not + directly; Header primary key `Code[20]` `No.` (or + `Option Document + Type`); Line primary key = Header key renamed ` No.` + + `Integer Line No.`; Document/Card page with a Posting action and a lines + subpage. +8. **Document History** (Posted Sales Invoice Header/Line) — posted copy of + a Document table, created during posting; mirrors the source table's + fields; never user-editable; same page shape but the Line-equivalent is + a List page, not a Worksheet. +9. **Setup** (General Ledger Setup) — exactly one record for a functional + area; primary key `Code[10]` named `Primary Key`, always blank; one page + with the key field hidden, whose `OnOpenPage` creates the singleton the + first time it's opened (`Reset()` → `Get()` → if not found, `Init()` → + `Insert()`) rather than assuming the record pre-exists. + +A table named "Setup" that holds more than one record follows Subsidiary +rules instead — the name alone is not proof of type. When a table's +identity can't be resolved from its definition alone (e.g. a "Setup"-named +table with a real business-field key and no page), say so explicitly +rather than forcing a classification; settling it requires checking actual +row cardinality or call sites, not just the object definition. + +These nine types cover Business Central's *business-record* tables — they +are not an exhaustive catalogue of every legitimate table shape. A +temporary/buffer table, a work queue, a log or telemetry table, a +cross-reference/mapping table with no business meaning of its own, or a +staging/working table used only inside one process is not required to fit +any of the nine, and forcing one into the nearest-looking type (usually +Ledger, because it has an `Integer` key, or Subsidiary, because it has a +composite key) produces a harmful redesign recommendation for a table that +was never meant to carry that type's guarantees. Apply this rule only when +the table's name, fields, or usage genuinely establish it as one of the +nine business-record types; when nothing points that way, this rule simply +does not apply — that is not the same as an unresolved classification. + +See sample: [`table-design-must-match-bc-table-type-conventions.good.al`](table-design-must-match-bc-table-type-conventions.good.al). + +## Anti Pattern + +A table that mixes conventions from two types — for example, a "Ledger" +table with a user-editable primary key that lets users freely insert or +delete rows — is not "flexible", it is either misclassified or has skipped +a design step. A Ledger table's `Entry No.` must come only from the +posting routine; exposing it as an editable field breaks the type's core +guarantee that entries are an immutable, sequential audit trail. + +See sample: [`table-design-must-match-bc-table-type-conventions.bad.al`](table-design-must-match-bc-table-type-conventions.bad.al). diff --git a/microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.bad.al b/microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.bad.al new file mode 100644 index 0000000..6f2835f --- /dev/null +++ b/microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.bad.al @@ -0,0 +1,11 @@ +// Both fields guarded the same way, out of habit rather than analysis. +if Customer.Get(SalesHeader."Sell-to Customer No.") then + CustomerHomePage := Customer."Home Page"; // low blast radius - fine + +// but the same pattern, unexamined, was also applied here: +if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then + VATBusPostingGroup := SalesHeader."VAT Bus. Posting Group" +else + VATBusPostingGroup := ''; + // High blast radius: silently wrong VAT posting group reaches posting + // with no error, no TestField, and no reviewer in the loop. diff --git a/microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.good.al b/microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.good.al new file mode 100644 index 0000000..35f88ee --- /dev/null +++ b/microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.good.al @@ -0,0 +1,14 @@ +// Low blast radius: guard, with an explicit chosen fallback. +if Customer.Get(SalesHeader."Sell-to Customer No.") then + CustomerHomePage := Customer."Home Page" +else + CustomerHomePage := ''; +// Blank is an acceptable, deliberately-considered default here - the field +// is purely a display convenience and a reviewer sees it before the document ships. +// It is assigned explicitly, though, not left to whatever the variable +// happened to hold before this lookup ran. + +// High blast radius: let it fail loud, because this feeds posted VAT. +SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo); +SalesHeader.TestField("VAT Bus. Posting Group"); +VATBusPostingGroup := SalesHeader."VAT Bus. Posting Group"; diff --git a/microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.md b/microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.md new file mode 100644 index 0000000..91a8aff --- /dev/null +++ b/microsoft/knowledge/error-handling/defensive-vs-offensive-code-must-match-blast-radius.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: error-handling +keywords: [defensive-programming, offensive-programming, fail-fast, blast-radius, guarded-lookup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Match defensive vs. offensive error handling to the blast radius of being wrong + +## Description + +Whether code should guard gracefully (defensive) or fail loudly (offensive/fail-fast) is not a matter of habit or a blanket house style — it depends on what happens downstream if the guarded condition is silently defaulted or skipped. Treating every missing value the same way, defensively or offensively, is itself the anti-pattern: uniform defensiveness hides the failures that matter most, while uniform fail-fast turns ordinary, expected absence into unnecessary crashes. Two fields can look structurally identical — both read from a related record, both potentially missing — and still deserve opposite treatment depending on what they feed. + +## Best Practice + +Trace what a silently-defaulted or skipped value actually reaches before deciding how to guard it. If it reaches a posted ledger amount, a tax/VAT calculation, a quantity or price actually used in a transaction, or a legally/compliance-facing output, code offensively: let the lookup fail loud (`TestField`, an unguarded `Get()` expected to always succeed, or an explicit `Error`) so a human sees the problem before anything posts. If it is cosmetic, informational, or easily corrected after the fact (a display field, an optional UI enhancement, a report not yet run), code defensively — but the fallback must be an explicit, deliberately-chosen, named business value, never a blank or zero that is merely the datatype default. When genuinely unsure which category a field falls into, that is a question to resolve explicitly with whoever owns the requirement, not a coin flip. + +See sample: [`defensive-vs-offensive-code-must-match-blast-radius.good.al`](defensive-vs-offensive-code-must-match-blast-radius.good.al). + +## Anti Pattern + +Guarding two fields the same way purely out of habit, without analyzing what each one feeds. A low-blast-radius field, such as a 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`](defensive-vs-offensive-code-must-match-blast-radius.bad.al). diff --git a/microsoft/knowledge/error-handling/log-writes-must-survive-rollback.bad.al b/microsoft/knowledge/error-handling/log-writes-must-survive-rollback.bad.al new file mode 100644 index 0000000..6cc2876 --- /dev/null +++ b/microsoft/knowledge/error-handling/log-writes-must-survive-rollback.bad.al @@ -0,0 +1,24 @@ +codeunit 50101 "Sample Web Service Caller" +{ + procedure CallExternalService() + var + ErrorLogEntry: Record "Sample Error Log"; + begin + // BUG: the log write happens inside the same transaction as the + // risky call, using the same Record instance as the caller. + if not TryCallService() then begin + ErrorLogEntry.Init(); + ErrorLogEntry."Error Message" := CopyStr(GetLastErrorText(), 1, 250); + ErrorLogEntry.Insert(); + Error(GetLastErrorText()); + // Error() above rolls back this transaction - including the + // Insert() just made. The failure is never actually logged. + end; + end; + + [TryFunction] + local procedure TryCallService() + begin + // ... external call that may fail ... + end; +} diff --git a/microsoft/knowledge/error-handling/log-writes-must-survive-rollback.good.al b/microsoft/knowledge/error-handling/log-writes-must-survive-rollback.good.al new file mode 100644 index 0000000..c0f7d65 --- /dev/null +++ b/microsoft/knowledge/error-handling/log-writes-must-survive-rollback.good.al @@ -0,0 +1,45 @@ +table 50100 "Sample Error Log Buffer" +{ + TableType = Temporary; + fields + { + field(1; "Call Duration (ms)"; Integer) { } + field(2; "Error Message"; Text[250]) { } + } +} + +codeunit 50100 "Sample Error Log Writer" +{ + // TableNo makes OnRun receive the Record that Session.StartSession + // passes to the new session. This is the only data channel into that + // session — there is no shared memory with the caller's instance. + TableNo = "Sample Error Log Buffer"; + + trigger OnRun() + var + ErrorLogEntry: Record "Sample Error Log"; + begin + ErrorLogEntry.Init(); + ErrorLogEntry."Call Duration (ms)" := Rec."Call Duration (ms)"; + ErrorLogEntry."Error Message" := + CopyStr(Rec."Error Message", 1, MaxStrLen(ErrorLogEntry."Error Message")); + ErrorLogEntry.Insert(true); + Commit(); + end; +} + +// Caller side: populate the buffer record, then hand it to StartSession. +// The insert-and-commit above happens inside the started session, so it +// survives even if the caller's own transaction rolls back afterward. +codeunit 50101 "Sample Error Log Caller Excerpt" +{ + procedure LogFailure(Duration: Integer; ErrorText: Text) + var + LogBuffer: Record "Sample Error Log Buffer" temporary; + SessionId: Integer; + begin + LogBuffer."Call Duration (ms)" := Duration; + LogBuffer."Error Message" := CopyStr(ErrorText, 1, MaxStrLen(LogBuffer."Error Message")); + Session.StartSession(SessionId, Codeunit::"Sample Error Log Writer", CompanyName, LogBuffer); + end; +} diff --git a/microsoft/knowledge/error-handling/log-writes-must-survive-rollback.md b/microsoft/knowledge/error-handling/log-writes-must-survive-rollback.md new file mode 100644 index 0000000..eae1700 --- /dev/null +++ b/microsoft/knowledge/error-handling/log-writes-must-survive-rollback.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: error-handling +keywords: [logging, rollback, session, transaction, isolated-session, telemetry] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Log writes that must capture failures must survive transaction rollback + +## Description + +Inserting a log record inside the same transaction as the operation it logs looks correct until the operation errors: the transaction rolls back and takes the log entry with it. The result is a log that faithfully records every success and silently loses exactly the failures it exists to capture. This is a common blind spot in error/duration logging around web-service calls, background jobs, and other operations expected to fail sometimes. + +## Best Practice + +Write any log whose purpose includes capturing failures from a transaction that is independent of the operation being logged: start an isolated session (`Session.StartSession` on a `TableNo`-scoped codeunit that only inserts the log record and commits) so the entry persists regardless of what happens to the caller's transaction. `StartSession`'s only channel for getting data into that new session is its optional `Record` parameter, delivered to the target codeunit's `OnRun` trigger — a separate session gets a fresh instantiation of the codeunit, so calling a setter procedure on a local object variable before starting the session does not populate anything in the new session's instance. Capture duration and other telemetry values in the caller, place them into the `Record` passed to `StartSession`, and do the insert-and-commit entirely inside that session's own `OnRun`. Logs that only record successful, committed work can safely stay in the main transaction; this pattern targets error and diagnostic logs specifically. + +See sample: [`log-writes-must-survive-rollback.good.al`](log-writes-must-survive-rollback.good.al). + +## Anti Pattern + +Inserting the error-log record in the same transaction as the risky operation, so a rollback deletes the very entry meant to explain the failure. Adding a stray `Commit` before the risky call is not a fix either — it breaks the caller's atomicity and can violate posting-routine rules. Swallowing the error to keep the log alive (running a codeunit without checking or re-raising its result) is equally wrong: the log must observe the failure, not suppress it. + +See sample: [`log-writes-must-survive-rollback.bad.al`](log-writes-must-survive-rollback.bad.al). diff --git a/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.bad.al b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.bad.al new file mode 100644 index 0000000..1a93861 --- /dev/null +++ b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.bad.al @@ -0,0 +1,12 @@ +permissionset 50100 "Sample - Integration" +{ + Access = Public; + Assignable = false; + Caption = 'Sample Integration'; + Permissions = + tabledata "Sample Order" = RIMD; + // BUG: "Sample Order API" (PageType = API) and "Sample Order Query" + // (a published API query) have no "= X" entry anywhere in this app. + // Both endpoints are unreachable even though the table looks fully + // granted - nobody decided who may call them. +} diff --git a/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.good.al b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.good.al new file mode 100644 index 0000000..de8d2ab --- /dev/null +++ b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.good.al @@ -0,0 +1,10 @@ +permissionset 50100 "Sample - Integration" +{ + Access = Public; + Assignable = false; + Caption = 'Sample Integration'; + Permissions = + tabledata "Sample Order" = RIMD, + page "Sample Order API" = X, // exposed API page: execute granted + query "Sample Order Query" = X; // exposed API query: execute granted +} diff --git a/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.md b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.md new file mode 100644 index 0000000..fd933d7 --- /dev/null +++ b/microsoft/knowledge/security/exposed-objects-must-be-in-a-permission-set.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: security +keywords: [permission-set, api-page, web-service, exposure, access-control] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every exposed object must belong to a permission set + +## 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, 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 a SOAP operation automatically — there is no per-method attribute to add. SOAP web service support is deprecated and scheduled for removal; prefer publishing an API page/query for a new integration rather than a new codeunit web service. The permission target for an existing published codeunit is the codeunit itself: `codeunit "..." = X`. +- `[ServiceEnabled]` is a method-level attribute used on a *page* procedure to expose it as an OData v4 bound action (for example a `Post` action on an invoice page) — it does not apply to pages, queries, or codeunits as an object-level property, and it does not create its own permission target. The action is still a call into that page object, so the page's own `page "..." = X` entry is what governs it. + +When such an object is left out of every permission set, it becomes both unusable (no caller, human or service, can reach it) and invisible in review: nobody deliberately decided who may call it. Exposure without a matching grant is not a safe default; it is an endpoint nobody is governing. + +## Best Practice + +Give every exposed object an explicit execute entry 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`](exposed-objects-must-be-in-a-permission-set.good.al). + +## Anti Pattern + +Granting access to the underlying table data while forgetting to grant execute access to the exposed page or query itself. The table looks fully covered by a permission set, but the API/service layer in front of it has no `= X` entry anywhere, so the endpoint silently fails for every caller even though the data permissions look complete. + +See sample: [`exposed-objects-must-be-in-a-permission-set.bad.al`](exposed-objects-must-be-in-a-permission-set.bad.al). diff --git a/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.bad.al b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.bad.al new file mode 100644 index 0000000..869e398 --- /dev/null +++ b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.bad.al @@ -0,0 +1,18 @@ +codeunit 50101 "Credit Memo Routing" +{ + procedure PostSalesLine(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line"; CustomerNo: Code[20]; Amount: Decimal) + begin + // Set the customer number + SalesHeader.Validate("Sell-to Customer No.", CustomerNo); + // Insert the line + SalesLine.Insert(true); + // Check if the amount is positive + if Amount > 0 then + // Post the entry + PostEntry(Amount); + end; + + local procedure PostEntry(Amount: Decimal) + begin + end; +} diff --git a/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.good.al b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.good.al new file mode 100644 index 0000000..6bd39b1 --- /dev/null +++ b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.good.al @@ -0,0 +1,20 @@ +codeunit 50101 "Credit Memo Routing" +{ + procedure PostSalesLine(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line"; CustomerNo: Code[20]; Amount: Decimal) + begin + SalesHeader.Validate("Sell-to Customer No.", CustomerNo); + SalesLine.Insert(true); + + // Negative amounts arrive from credit memos routed through this + // codeunit; PostEntry() rejects them, so they're filtered here. + if Amount < 0 then + exit; + + if Amount > 0 then + PostEntry(Amount); + end; + + local procedure PostEntry(Amount: Decimal) + begin + end; +} diff --git a/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md new file mode 100644 index 0000000..09a2ea4 --- /dev/null +++ b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: style +keywords: [comments, verbosity, self-documenting, restate, tutorial-style] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Comments must not restate what the code already shows + +## Description + +A comment above nearly every statement that just narrates what the statement already says (`// Validate the customer number` above `SalesHeader.Validate("Sell-to Customer No.", CustomerNo)`) adds noise without adding information. Production AL — the Base Application, mature partner codebases — is comment-sparse by comparison: identifiers do the explaining, and a comment appears only when the code alone can't carry the reason. + +A comment earns its place only when it captures something the code cannot: a non-obvious business rule, a workaround for a specific platform limitation, or a constraint that would surprise the next reader. If removing the comment would leave the reader no worse off, the comment should not have been written. + +This does not override required structural documentation — feature/scenario test tags and XML-doc summaries on public library procedures remain required where they apply; those are structural markers, not narrative comments. + +## Best Practice + +Let the code speak for itself; reserve comments for the reason a reader could not otherwise infer. + +See sample: [`al-comments-must-not-restate-what-code-already-shows.good.al`](al-comments-must-not-restate-what-code-already-shows.good.al). + +## Anti Pattern + +A comment line before every statement, repeating in English what the statement's own identifiers already say. + +See sample: [`al-comments-must-not-restate-what-code-already-shows.bad.al`](al-comments-must-not-restate-what-code-already-shows.bad.al). diff --git a/microsoft/knowledge/style/pages-must-not-contain-business-logic.bad.al b/microsoft/knowledge/style/pages-must-not-contain-business-logic.bad.al new file mode 100644 index 0000000..352c515 --- /dev/null +++ b/microsoft/knowledge/style/pages-must-not-contain-business-logic.bad.al @@ -0,0 +1,49 @@ +table 50101 "Sample Order Line" +{ + fields + { + field(1; "Document No."; Code[20]) { } + field(2; "Line No."; Integer) { } + field(10; Quantity; Decimal) { } + field(11; "Unit Price"; Decimal) { } + field(12; "Line Amount"; Decimal) { } + } + keys + { + key(PK; "Document No.", "Line No.") { Clustered = true; } + } +} + +page 50100 "Sample Order Line Card" +{ + PageType = Card; + SourceTable = "Sample Order Line"; + + layout + { + area(content) + { + repeater(General) + { + field(quantity; Rec.Quantity) { } + field(unitPrice; Rec."Unit Price") { } + field(lineAmount; Rec."Line Amount") { } + } + } + } + + actions + { + area(Processing) + { + action(Recalculate) + { + trigger OnAction() + begin + Rec."Line Amount" := Rec.Quantity * Rec."Unit Price"; + Rec.Modify(); + end; + } + } + } +} diff --git a/microsoft/knowledge/style/pages-must-not-contain-business-logic.good.al b/microsoft/knowledge/style/pages-must-not-contain-business-logic.good.al new file mode 100644 index 0000000..a22f307 --- /dev/null +++ b/microsoft/knowledge/style/pages-must-not-contain-business-logic.good.al @@ -0,0 +1,60 @@ +table 50101 "Sample Order Line" +{ + fields + { + field(1; "Document No."; Code[20]) { } + field(2; "Line No."; Integer) { } + field(10; Quantity; Decimal) { } + field(11; "Unit Price"; Decimal) { } + field(12; "Line Amount"; Decimal) { } + } + keys + { + key(PK; "Document No.", "Line No.") { Clustered = true; } + } +} + +codeunit 50100 "Sample Order Line Management" +{ + procedure RecalculateLine(var OrderLine: Record "Sample Order Line") + begin + OrderLine.Validate("Line Amount", OrderLine.Quantity * OrderLine."Unit Price"); + OrderLine.Modify(true); + end; +} + +page 50100 "Sample Order Line Card" +{ + PageType = Card; + SourceTable = "Sample Order Line"; + + layout + { + area(content) + { + repeater(General) + { + field(quantity; Rec.Quantity) { } + field(unitPrice; Rec."Unit Price") { } + field(lineAmount; Rec."Line Amount") { } + } + } + } + + actions + { + area(Processing) + { + action(Recalculate) + { + trigger OnAction() + begin + OrderLineMgt.RecalculateLine(Rec); + end; + } + } + } + + var + OrderLineMgt: Codeunit "Sample Order Line Management"; +} diff --git a/microsoft/knowledge/style/pages-must-not-contain-business-logic.md b/microsoft/knowledge/style/pages-must-not-contain-business-logic.md new file mode 100644 index 0000000..3326a25 --- /dev/null +++ b/microsoft/knowledge/style/pages-must-not-contain-business-logic.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: style +keywords: [pages, business-logic, codeunit, separation-of-concerns, presentation-layer] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep business logic out of page objects + +## Description + +A page procedure that persists a business mutation directly (`Rec.Modify()` outside the standard record-bound save, or a cross-entry-point business rule implemented only in a page trigger) is an architecture violation even when it compiles: the rule only applies when a user opens that specific page, and silently doesn't run through any other entry point (API, batch job, another page). This is narrower than "no calculation may live on a page" — a presentation-specific calculation (formatting, a derived display value) is fine on the page that shows it, and a reusable data invariant commonly belongs on the table itself (a field's own validation/trigger), not forced into a codeunit merely to keep it off the page. The actual line is entry-point independence: a business operation or invariant that must hold regardless of which entry point touches the record belongs in a codeunit or the table, not solely in one page's trigger. + +A narrow set of patterns are conventional rather than violations: +- A setup page reading and writing its own singleton setup record. +- A dedicated "Run Conversion" page invoking a conversion codeunit directly. +- The standard singleton-initialization idiom on `OnOpenPage` (`if not Rec.Get() then begin Rec.Init(); Rec.Insert(); end`) used by cue/activities pages to bootstrap their own presentation-state record — this is not business logic, it is the same pattern used throughout base-app cue pages. + +## Best Practice + +Delegate all business operations to a codeunit: the page owns presentation, the codeunit owns logic. A calculation or validation triggered from a page action should call a codeunit procedure rather than compute the result inline. + +See sample: [`pages-must-not-contain-business-logic.good.al`](pages-must-not-contain-business-logic.good.al). + +## Anti Pattern + +A cross-entry-point business rule or persisted mutation implemented only in a page trigger — calling `Rec.Modify()` to save a computed business value from `OnValidate`/`OnAction`, or a validation that must hold regardless of caller, instead of routed through a codeunit or the table's own field validation. A presentation-only calculation or a table-owned field invariant is not an instance of this anti-pattern. + +See sample: [`pages-must-not-contain-business-logic.bad.al`](pages-must-not-contain-business-logic.bad.al). diff --git a/microsoft/knowledge/style/source-organized-by-feature-not-object-type.md b/microsoft/knowledge/style/source-organized-by-feature-not-object-type.md new file mode 100644 index 0000000..c698093 --- /dev/null +++ b/microsoft/knowledge/style/source-organized-by-feature-not-object-type.md @@ -0,0 +1,48 @@ +--- +bc-version: [all] +domain: style +keywords: [folder-structure, feature-organization, source-layout, maintainability] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Organize AL source by business feature, not object type + +## Description + +Folder structure inside an AL app has no effect on compilation or runtime behavior — this is a repository-organization convention, not a platform requirement, and different projects reasonably choose differently. Grouping files by business feature or module (`src/Sales/Invoice/`, `src/NoSeries/`) rather than by AL object type (`src/Tables/`, `src/Pages/`, `src/Codeunits/`) keeps everything belonging to one feature physically together, which many teams find easier to navigate than jumping between object-type folders that share nothing but their AL object kind. Adopt this consistently on a project rather than mixing both schemes, but treat it as a team convention to apply deliberately, not a Microsoft-mandated structure. + +Code genuinely shared across multiple features (utility codeunits, common interfaces, shared enums) belongs in a `Common` or `Shared` folder, not duplicated per feature and not left in a catch-all root. + +## Best Practice + + src/ + ├── NoSeries/ + ├── Sales/ + │ ├── Invoice/ + │ └── Order/ + └── Common/ + +Each feature folder holds every object type it needs; shared code has one dedicated home. + +## 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/ + ├── Sales/ + │ └── Invoice/ + ├── Tables/ <- new objects land here instead of a feature folder + └── Codeunits/ + +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. diff --git a/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.bad.al b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.bad.al new file mode 100644 index 0000000..d6a29d6 --- /dev/null +++ b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.bad.al @@ -0,0 +1,29 @@ +// Only wraps Microsoft's own generic scenario — measures BC, not this extension +codeunit 50101 "BCPT Create Sales Order" implements "BCPT Test Param. Provider" +{ + SingleInstance = true; + + trigger OnRun() + begin + CreateStandardSalesOrder(GlobalBCPTTestContext); + end; + + var + GlobalBCPTTestContext: Codeunit "BCPT Test Context"; + + local procedure CreateStandardSalesOrder(var BCPTTestContext: Codeunit "BCPT Test Context") + begin + BCPTTestContext.StartScenario('Create Sales Order With N Lines'); + // ... standard sales order creation, no reference to the extension's own logic + BCPTTestContext.EndScenario('Create Sales Order With N Lines'); + end; + + procedure GetDefaultParameters(): Text[1000] + begin + exit(''); + end; + + procedure ValidateParameters(Parameters: Text[1000]) + begin + end; +} diff --git a/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.good.al b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.good.al new file mode 100644 index 0000000..68239d7 --- /dev/null +++ b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.good.al @@ -0,0 +1,73 @@ +codeunit 50100 "BCPT Create Service Request" implements "BCPT Test Param. Provider" +{ + SingleInstance = true; + + trigger OnRun() + begin + if not IsInitialized then begin + InitTest(); + IsInitialized := true; + end; + CreateServiceRequest(GlobalBCPTTestContext); + end; + + var + GlobalBCPTTestContext: Codeunit "BCPT Test Context"; + CustomerNo: Code[20]; + IsInitialized: Boolean; + + local procedure InitTest() + var + Customer: Record Customer; + begin + // Do not assume a customer already exists: a BCPT run may target an + // otherwise-empty environment. Create one if none is found instead + // of failing on FindFirst(). + if not Customer.FindFirst() then begin + Customer.Init(); + Customer."No." := GenerateUniqueCode(MaxStrLen(Customer."No.")); + Customer.Insert(true); + end; + CustomerNo := Customer."No."; + end; + + local procedure GenerateUniqueCode(Length: Integer): Code[20] + begin + // A GUID-derived code, not a session-local counter: it stays unique + // across concurrent BCPT sessions and repeated runs against the + // same environment, which an in-memory counter reset per session + // cannot guarantee. + exit(CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, Length)); + end; + + local procedure CreateServiceRequest(var BCPTTestContext: Codeunit "BCPT Test Context") + var + ServiceRequestHeader: Record "Service Request Header"; + ServiceRequestLine: Record "Service Request Line"; + begin + BCPTTestContext.StartScenario('Create Service Request Header'); + ServiceRequestHeader.Init(); + ServiceRequestHeader."No." := GenerateUniqueCode(MaxStrLen(ServiceRequestHeader."No.")); + ServiceRequestHeader.Validate("Customer No.", CustomerNo); + ServiceRequestHeader.Insert(true); + BCPTTestContext.EndScenario('Create Service Request Header'); + BCPTTestContext.UserWait(); + + BCPTTestContext.StartScenario('Add Service Request Line'); + ServiceRequestLine.Init(); + ServiceRequestLine."Document No." := ServiceRequestHeader."No."; + ServiceRequestLine."Line No." := 10000; + ServiceRequestLine.Description := 'Performance test line'; + ServiceRequestLine.Insert(true); + BCPTTestContext.EndScenario('Add Service Request Line'); + end; + + procedure GetDefaultParameters(): Text[1000] + begin + exit(''); + end; + + procedure ValidateParameters(Parameters: Text[1000]) + begin + end; +} diff --git a/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.md b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.md new file mode 100644 index 0000000..51ebddd --- /dev/null +++ b/microsoft/knowledge/testing/bcpt-scenarios-must-be-app-specific.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [bcpt, performance-test, scenarios, app-specific, regression] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Include app-specific scenarios in a PerformanceTest app's BCPT suite + +## Description + +A PerformanceTest app that ships with only the generic Microsoft BCPT samples (creating sales orders, purchase orders, posting item journals) measures Business Central's own baseline performance, not the extension it was built to test. Those samples are starting points, not coverage. Without a scenario that exercises the extension's own business flow — its own codeunits, its own FlowFields, its own page rendering — a performance regression introduced by the extension has no test that would ever detect it. + +## Best Practice + +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`](bcpt-scenarios-must-be-app-specific.good.al). + +## Anti Pattern + +A PerformanceTest app whose only scenario codeunits are copies of Microsoft's shipped samples (creating a standard sales order, opening the standard customer list) tests the platform, not the extension. Any regression in the extension's own posting logic, calculations, or pages goes unmeasured and unnoticed. + +See sample: [`bcpt-scenarios-must-be-app-specific.bad.al`](bcpt-scenarios-must-be-app-specific.bad.al). diff --git a/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.bad.al b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.bad.al new file mode 100644 index 0000000..0181fdb --- /dev/null +++ b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.bad.al @@ -0,0 +1,15 @@ +[Test] +procedure PostSalesOrder_CreatesInvoice() +var + SalesHeader: Record "Sales Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + InvoiceNo: Code[20]; +begin + // [GIVEN] a sales order — posting groups left to whatever exists in the test company + LibrarySales.CreateSalesOrder(SalesHeader); + // [WHEN] + InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true); + // [THEN] + SalesInvoiceHeader.Get(InvoiceNo); + Assert.RecordIsNotEmpty(SalesInvoiceHeader); +end; diff --git a/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.good.al b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.good.al new file mode 100644 index 0000000..224f0dc --- /dev/null +++ b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.good.al @@ -0,0 +1,20 @@ +[Test] +procedure PostSalesOrder_CreatesInvoice() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + InvoiceNo: Code[20]; +begin + // [GIVEN] a customer + LibrarySales.CreateCustomer(Customer); + // [GIVEN] a sales order for that customer + LibrarySales.CreateSalesOrderForCustomerNo(SalesHeader, Customer."No."); + SalesHeader.Validate("Posting Date", WorkDate()); + SalesHeader.Modify(true); + // [WHEN] + InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true); + // [THEN] + SalesInvoiceHeader.Get(InvoiceNo); + Assert.RecordIsNotEmpty(SalesInvoiceHeader); +end; diff --git a/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.md b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.md new file mode 100644 index 0000000..b34adfe --- /dev/null +++ b/microsoft/knowledge/testing/given-blocks-must-cover-full-precondition-chain.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [given, test-setup, posting, report, request-page, precondition, completeness] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Cover the full precondition chain in GIVEN, not just the primary record + +## Description + +A `[GIVEN]` block is only correct if it sets up every precondition the code under test actually reads, not just the record the scenario is "about." For most master-data tests, creating the primary record is enough. For posting routines and reports it usually is not: an incomplete `[GIVEN]` produces a test that either fails with a setup error unrelated to the scenario, or worse, passes without ever reaching the logic it claims to verify. + +## Best Practice + +For a posting test, set up the full posting-group chain the document requires (e.g. customer/vendor posting group, gen. business/product posting group, VAT posting setup), the setup records the specific posting path reads, and an explicit date when the path is date-sensitive — a missing link surfaces as an unrelated G/L error, not a meaningful test failure. For a report test that claims to verify filtering or dataset logic, include both a record that should be included and one that should be excluded, plus any request-page parameter or FlowField the report's logic branches on. A report test that only claims to run without error is exempt from the include/exclude pairing, but it must say so in its scenario name or comment — an unlabelled single-record `[GIVEN]` is ambiguous about which claim it is making, and that ambiguity is itself the defect. + +See sample: [`given-blocks-must-cover-full-precondition-chain.good.al`](given-blocks-must-cover-full-precondition-chain.good.al). + +## Anti Pattern + +A posting test whose `[GIVEN]` creates only the sales header, relying on whatever posting groups happen to exist in the test company. A report test whose `[GIVEN]` creates only matching records, so the report "passes" whether or not its filter logic does anything at all. + +See sample: [`given-blocks-must-cover-full-precondition-chain.bad.al`](given-blocks-must-cover-full-precondition-chain.bad.al). diff --git a/microsoft/knowledge/testing/test-feature-scenario-tags.bad.al b/microsoft/knowledge/testing/test-feature-scenario-tags.bad.al new file mode 100644 index 0000000..06e5d54 --- /dev/null +++ b/microsoft/knowledge/testing/test-feature-scenario-tags.bad.al @@ -0,0 +1,33 @@ +codeunit 50102 "Item Price Testing" +{ + Subtype = Test; + + var + LibrarySales: Codeunit "Library - Sales"; + LibraryInventory: Codeunit "Library - Inventory"; + LibraryPriceCalculation: Codeunit "Library - Price Calculation"; + Assert: Codeunit "Library Assert"; + + [Test] + procedure Test1() + var + Customer: Record Customer; + Item: Record Item; + PriceListHeader: Record "Price List Header"; + PriceListLine: Record "Price List Line"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + begin + // setup mixed with assertions, no clear layers, no FEATURE/SCENARIO/GIVEN/WHEN/THEN tags + LibrarySales.CreateCustomer(Customer); + LibraryInventory.CreateItem(Item); + LibraryPriceCalculation.CreatePriceHeader( + PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No."); + LibraryPriceCalculation.CreateSalesPriceLine( + PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.", + "Price Asset Type"::Item, Item."No."); + LibrarySales.CreateSalesDocumentWithItem( + SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D); + Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", ''); + end; +} diff --git a/microsoft/knowledge/testing/test-feature-scenario-tags.good.al b/microsoft/knowledge/testing/test-feature-scenario-tags.good.al new file mode 100644 index 0000000..b2cbf06 --- /dev/null +++ b/microsoft/knowledge/testing/test-feature-scenario-tags.good.al @@ -0,0 +1,40 @@ +// [FEATURE] Item Price — price cascade (Customer -> Price Group -> All Customers) +codeunit 50103 "Item Price Testing" +{ + Subtype = Test; + + var + LibrarySales: Codeunit "Library - Sales"; + LibraryInventory: Codeunit "Library - Inventory"; + LibraryPriceCalculation: Codeunit "Library - Price Calculation"; + Assert: Codeunit "Library Assert"; + + [Test] + procedure GetPrice_CustomerPrice_ReturnsUnitPrice() + var + Customer: Record Customer; + Item: Record Item; + PriceListHeader: Record "Price List Header"; + PriceListLine: Record "Price List Line"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + begin + // [SCENARIO] Customer with a specific price list line gets that unit price + // [GIVEN] a customer and an item with a customer-specific sales price list line + LibrarySales.CreateCustomer(Customer); + LibraryInventory.CreateItem(Item); + LibraryPriceCalculation.CreatePriceHeader( + PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No."); + LibraryPriceCalculation.CreateSalesPriceLine( + PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.", + "Price Asset Type"::Item, Item."No."); + // CreatePriceHeader leaves the list in Draft status, which price calculation ignores. + PriceListHeader.Validate(Status, PriceListHeader.Status::Active); + PriceListHeader.Modify(true); + // [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; +} diff --git a/microsoft/knowledge/testing/test-feature-scenario-tags.md b/microsoft/knowledge/testing/test-feature-scenario-tags.md new file mode 100644 index 0000000..2247c7e --- /dev/null +++ b/microsoft/knowledge/testing/test-feature-scenario-tags.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [feature, scenario, given, when, then, tags, bdd, atdd, comments] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Tag test codeunits with FEATURE, SCENARIO, GIVEN, WHEN, and THEN comments + +## Description + +Test codeunits are easier to trust and to review when they carry a four-level comment structure taken from Behaviour-/Acceptance-Test-Driven Development: `[FEATURE]` once at the top of the codeunit naming the functional area under test, `[SCENARIO]` above each test procedure stating one falsifiable business claim in plain language, and `[GIVEN]`/`[WHEN]`/`[THEN]` marking the precondition, action, and assertion inside the test body. Without these tags a test procedure is an opaque block of AL that only reveals its intent by being read line by line; a reviewer or product owner cannot scan a codeunit and know what business behaviour it covers. + +## Best Practice + +Put `[FEATURE]` as a comment before the codeunit's opening brace, naming the domain rather than the object — Microsoft's own guidance allows setting it once for the whole codeunit, inherited by every test in it. Put `[SCENARIO]`, matching the current BCApps corpus, as the first comment inside each test procedure's body (after `begin`), describing the scenario in business language that complements — not duplicates — the procedure name, followed by `[GIVEN]` marking the precondition setup, `[WHEN]` marking the single action under test, and `[THEN]` marking the assertions. The procedure name stays the machine-readable identity shown in test-runner output; the `[SCENARIO]` comment stays the human-readable one. Neither replaces the other. + +See sample: [`test-feature-scenario-tags.good.al`](test-feature-scenario-tags.good.al). + +## Anti Pattern + +A test procedure with no `[FEATURE]`/`[SCENARIO]`/`[GIVEN]`/`[WHEN]`/`[THEN]` structure, setup mixed freely with assertions, and a procedure name like `Test1` that says nothing about what is being verified. Nothing in the codeunit tells a reader what business rule it exists to protect. + +See sample: [`test-feature-scenario-tags.bad.al`](test-feature-scenario-tags.bad.al). diff --git a/microsoft/knowledge/testing/test-one-when-per-test.bad.al b/microsoft/knowledge/testing/test-one-when-per-test.bad.al new file mode 100644 index 0000000..c49696c --- /dev/null +++ b/microsoft/knowledge/testing/test-one-when-per-test.bad.al @@ -0,0 +1,32 @@ +[Test] +procedure GetPrice_ThenGetPriceLines_ReturnsCorrectValues() +var + Customer: Record Customer; + Item: Record Item; + PriceListHeader: Record "Price List Header"; + PriceListLine: Record "Price List Line"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; +begin + // [GIVEN] ... + LibrarySales.CreateCustomer(Customer); + LibraryInventory.CreateItem(Item); + LibraryPriceCalculation.CreatePriceHeader( + PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No."); + LibraryPriceCalculation.CreateSalesPriceLine( + PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.", + "Price Asset Type"::Item, Item."No."); + // [WHEN] first action + LibrarySales.CreateSalesDocumentWithItem( + SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D); + // [WHEN] second action — this is a second test in disguise + PriceListLine.Validate("Minimum Quantity", 10); + PriceListLine.Modify(true); + LibraryPriceCalculation.CreateSalesPriceLine( + PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.", + "Price Asset Type"::Item, Item."No."); + // [THEN] asserting two unrelated things + Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", ''); + PriceListLine.SetRange("Price List Code", PriceListHeader.Code); + Assert.AreEqual(2, PriceListLine.Count(), ''); +end; diff --git a/microsoft/knowledge/testing/test-one-when-per-test.good.al b/microsoft/knowledge/testing/test-one-when-per-test.good.al new file mode 100644 index 0000000..6b58c51 --- /dev/null +++ b/microsoft/knowledge/testing/test-one-when-per-test.good.al @@ -0,0 +1,56 @@ +[Test] +procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice() +var + Customer: Record Customer; + Item: Record Item; + PriceListHeader: Record "Price List Header"; + PriceListLine: Record "Price List Line"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; +begin + // [GIVEN] a customer with a price list line for the item + LibrarySales.CreateCustomer(Customer); + LibraryInventory.CreateItem(Item); + LibraryPriceCalculation.CreatePriceHeader( + PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No."); + LibraryPriceCalculation.CreateSalesPriceLine( + PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.", + "Price Asset Type"::Item, Item."No."); + // CreatePriceHeader leaves the list in Draft status, which price calculation ignores. + PriceListHeader.Validate(Status, PriceListHeader.Status::Active); + PriceListHeader.Modify(true); + // [WHEN] + LibrarySales.CreateSalesDocumentWithItem( + SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D); + // [THEN] + Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match price list'); +end; + +[Test] +procedure GetPriceLines_TwoMinimumQuantityLines_ReturnsBoth() +var + Customer: Record Customer; + Item: Record Item; + PriceListHeader: Record "Price List Header"; + PriceListLine: Record "Price List Line"; +begin + // [GIVEN] a customer price list with two minimum-quantity price lines for the same item + LibrarySales.CreateCustomer(Customer); + LibraryInventory.CreateItem(Item); + LibraryPriceCalculation.CreatePriceHeader( + PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No."); + LibraryPriceCalculation.CreateSalesPriceLine( + PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.", + "Price Asset Type"::Item, Item."No."); + PriceListLine.Validate("Minimum Quantity", 10); + PriceListLine.Modify(true); + LibraryPriceCalculation.CreateSalesPriceLine( + PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.", + "Price Asset Type"::Item, Item."No."); + PriceListLine.Validate("Minimum Quantity", 50); + PriceListLine.Modify(true); + // [WHEN] + PriceListLine.SetRange("Price List Code", PriceListHeader.Code); + // [THEN] + Assert.AreEqual(2, PriceListLine.Count(), 'Exactly two price lines expected'); +end; diff --git a/microsoft/knowledge/testing/test-one-when-per-test.md b/microsoft/knowledge/testing/test-one-when-per-test.md new file mode 100644 index 0000000..69c3b37 --- /dev/null +++ b/microsoft/knowledge/testing/test-one-when-per-test.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: testing +keywords: [when, single-action, bdd, atdd, given-when-then, flow-test, regression-test] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep exactly one WHEN per test, with narrow exceptions for flow and defect-then-fix tests + +## Description + +This is a testing-design practice, not a BC platform requirement — no AL API enforces it, and it should not gate a change the way a platform-contradicted claim would. Each test procedure should contain exactly one `[WHEN]` block: one action that triggers the behaviour under test. A test with multiple WHENs — "do A, then do B, then check C" — is two or more tests in disguise. Splitting them gives failure isolation (a failing test points at one action, not an ambiguous sequence) and keeps each test readable as a single, falsifiable claim. A precondition action, such as posting a document so a ledger entry exists to assert against, belongs in `[GIVEN]`; only the action actually being asserted belongs in `[WHEN]`. + +## Best Practice + +Give each test one `[WHEN]` and one focused claim. A procedure name containing "And" or "Then" in the middle (`GetPrice_AndDiscount_ReturnsValues`) is a strong signal the test should be split. + +See sample: [`test-one-when-per-test.good.al`](test-one-when-per-test.good.al). + +## Anti Pattern + +A test that performs a first action, then a second unrelated action, then asserts on both — mixing two falsifiable claims into one procedure so a failure can't tell you which action broke. + +See sample: [`test-one-when-per-test.bad.al`](test-one-when-per-test.bad.al). + +## Flow tests — a deliberate exception + +A flow test verifies the accumulated outcome of a genuinely multi-round business process (partial receipt then invoicing, several posting rounds against one document), where the sequence itself is the scenario — splitting it would lose the interaction under test. Multiple `[WHEN]` blocks are allowed only when the procedure name declares the flow, each `[WHEN]` is labelled as one round of a single scenario rather than an unrelated action, and the `[THEN]` asserts the accumulated end-state rather than assertions that decompose cleanly per action (if they do decompose cleanly, it is still two tests in disguise). Outside this shape, unit-level tests keep the strict one-WHEN rule. + +## Defect-then-fix tests — a second, narrower exception + +A test that reproduces a specific broken state and then verifies a subsequent action corrects it is not the same shape as an unrelated-action test, even though its `[THEN]` assertions decompose cleanly per step — clean decomposition is expected here, not a sign of two unrelated tests. This shape is permitted only when the second `[WHEN]` cannot be meaningfully tested without the first (the fix only affects the exact stale state the first action produced, so splitting would just re-run the first action inside a second test's `[GIVEN]`), and the procedure name communicates the before/after relationship. diff --git a/microsoft/knowledge/testing/ui-test-codeunit-naming.bad.al b/microsoft/knowledge/testing/ui-test-codeunit-naming.bad.al new file mode 100644 index 0000000..2acba18 --- /dev/null +++ b/microsoft/knowledge/testing/ui-test-codeunit-naming.bad.al @@ -0,0 +1,27 @@ +codeunit 50104 "Item Price Testing" +{ + Subtype = Test; + + [Test] + procedure ApplyDiscount_LogicTest() + var + Assert: Codeunit "Library Assert"; + begin + // logic test — fine on its own, but not paired with a UI test below + Assert.AreEqual(90, ApplyDiscount(100, 10), 'A 10% discount on 100 must yield 90'); + end; + + local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal + begin + exit(UnitPrice - (UnitPrice * DiscountPct / 100)); + end; + + [Test] + procedure CustomerCard_Opens_UT() + var + CustomerCard: TestPage "Customer Card"; + begin + // UI test mixed into a logic-test codeunit, and the codeunit lacks the _UT suffix + CustomerCard.OpenNew(); + end; +} diff --git a/microsoft/knowledge/testing/ui-test-codeunit-naming.good.al b/microsoft/knowledge/testing/ui-test-codeunit-naming.good.al new file mode 100644 index 0000000..6c343d8 --- /dev/null +++ b/microsoft/knowledge/testing/ui-test-codeunit-naming.good.al @@ -0,0 +1,42 @@ +codeunit 50105 "Item Price Testing" +{ + Subtype = Test; + + [Test] + procedure ApplyDiscount_ReducesUnitPrice() + var + Assert: Codeunit "Library Assert"; + DiscountedPrice: Decimal; + begin + 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; +} + +codeunit 50106 "Item Price Testing_UT" +{ + Subtype = Test; + + [Test] + procedure CustomerCard_SetName_UpdatesField() + var + Customer: Record Customer; + CustomerCard: TestPage "Customer Card"; + Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; + begin + LibrarySales.CreateCustomer(Customer); + CustomerCard.OpenEdit(); + CustomerCard.GoToRecord(Customer); + CustomerCard.Name.SetValue('Updated Name'); + CustomerCard.Close(); + + Customer.Get(Customer."No."); + Assert.AreEqual('Updated Name', Customer.Name, 'Name must be updated through the page'); + end; +} diff --git a/microsoft/knowledge/testing/ui-test-codeunit-naming.md b/microsoft/knowledge/testing/ui-test-codeunit-naming.md new file mode 100644 index 0000000..0790f37 --- /dev/null +++ b/microsoft/knowledge/testing/ui-test-codeunit-naming.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: testing +keywords: [ui-test, testpage, naming, suffix, codeunit, page-testing, team-convention] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Separate UI-layer and logic-layer tests into different codeunits + +## Description + +A test codeunit that drives pages through `TestPage` — opening pages, reading FactBox parts, triggering field `OnValidate` through the page — is testing a different layer than a codeunit that calls business-logic procedures directly. Readers need to know which layer a given test exercises without opening it, and a single codeunit that mixes both kinds of test hides that distinction: a failure could mean the logic broke, the page broke, or both. The `_UT` suffix and adjacent-object-ID pairing below are one team's naming convention for making that split visible, not a BCApps-wide naming standard — BCApps itself uses `UT` for unit tests generally, not specifically to mean "UI layer," and does not treat adjacent object IDs as a semantic pairing mechanism. Apply the suffix only on a project that has explicitly adopted this convention. + +## Best Practice + +Keep UI-layer (`TestPage`-driven) and logic-layer tests in separate codeunits regardless of naming. Projects that adopt a `_UT`-style suffix convention should apply it consistently to every UI-layer test codeunit, keep the corresponding logic-only codeunit unsuffixed, and document the convention where the team's other naming rules live. + +See sample: [`ui-test-codeunit-naming.good.al`](ui-test-codeunit-naming.good.al). + +## Anti Pattern + +One codeunit that mixes a direct logic-call test and a `TestPage`-driven test side by side — a failing test no longer tells a reader which layer actually broke. On a project that has adopted the `_UT` convention, a UI-layer codeunit missing the suffix is also an instance of this anti-pattern; on a project that has not adopted it, the suffix itself is not required. + +See sample: [`ui-test-codeunit-naming.bad.al`](ui-test-codeunit-naming.bad.al). diff --git a/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.bad.al b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.bad.al new file mode 100644 index 0000000..fd371f5 --- /dev/null +++ b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.bad.al @@ -0,0 +1,21 @@ +page 50131 "Sample Item List" +{ + PageType = List; + SourceTable = "Sample Item"; + // Anti-pattern: no CardPageID even though a Card page exists for + // this table, and no UsageCategory, so the page is invisible to + // Tell Me search. + ApplicationArea = All; + + layout + { + area(content) + { + repeater(Group) + { + field(Description; Rec.Description) { } + field("No."; Rec."No.") { } // primary key buried, not left-most + } + } + } +} diff --git a/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.good.al b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.good.al new file mode 100644 index 0000000..7b53ac0 --- /dev/null +++ b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.good.al @@ -0,0 +1,20 @@ +page 50130 "Sample Item List" +{ + PageType = List; + SourceTable = "Sample Item"; + CardPageID = "Sample Item Card"; // links back to its Card page + UsageCategory = Lists; + ApplicationArea = All; + + layout + { + area(content) + { + repeater(Group) + { + field("No."; Rec."No.") { } // primary key, left-most + field(Description; Rec.Description) { } + } + } + } +} diff --git a/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.md b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.md new file mode 100644 index 0000000..18f7178 --- /dev/null +++ b/microsoft/knowledge/ui/page-design-must-match-bc-page-type-conventions.md @@ -0,0 +1,90 @@ +--- +bc-version: [all] +domain: ui +keywords: [pages, page-design, naming-conventions, page-type, card-page, list-page, factbox, worksheet-page, document-page, rolecenter, cardpageid, autosplitkey] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pages must match one of Business Central's page-type conventions + +## Description + +Business Central's page types — RoleCenter, Card, List, CardPart, +ListPart, Worksheet, Document, ListPlus, plus system dialog/special +types such as `NavigatePage`, `ConfirmationDialog`, `StandardDialog`, +`HeadlinePart`, and `API` (a selected list of conventional types this +article covers design conventions for — not an exhaustive catalogue of +every current `PageType` value; `PromptDialog`, `ConfigurationDialog`, +`UserControlHost`, and `XmlPort` also exist but follow their own +design rules, out of scope here) — each +fix a naming pattern and a structural constraint, not just a visual +layout. A page whose name, primary-key handling, or linkage +(`CardPageID`, `SubPageLink`, `AutoSplitKey`) doesn't match its own type's +conventions is either the wrong page type for the job or built +inconsistently with the rest of the application, and should be flagged in +review even if it compiles and renders. Before naming a new page or +wiring its links, first ask which page type it is, and whether the source +table actually fits that type's structural requirement — the type fixes +the naming suffix, which fields are visible, and which other page it must +link back to. + +## Best Practice + +Match the page's design to its type: + +- **RoleCenter** — tailored home page for a role; named role + `Role + Center`; links to List pages, shows Cues/Activities. +- **Card** — view/edit one record; named table + `Card`; FastTabs only, + first FastTab named `General`. A single-field primary key is typical, + but not a hard requirement: a subsidiary table that supplements a + master record with its own identity (parent key + own code — Ship-to + Address, Customer/Vendor Bank Account) commonly gets its own Card page + over a composite key too. Treat the key shape as a contextual signal, + not a mandatory constraint — a composite-key table with no such + supplementing relationship to a master record is the actual signal a + List/Worksheet/Tabular page fits better. +- **List** — view multiple records, also the lookup/drilldown surface; + named table + `List` if read-only, or the plural table name if + editable; primary-key fields shown left-most; `CardPageID` must point + at the associated Card page when one exists. +- **CardPart** — single-column FactBox; named for its content + + `FactBox`. +- **ListPart** — multi-column FactBox or subpage (e.g. document lines); + named for its content + `FactBox`/`SubPage`; `SubPageLink` must + actually filter to the host record. +- **Worksheet** — multi-record entry for a Journal-like table, insertion + order preserved; primary-key fields never shown; uses `AutoSplitKey` + with a trailing `Integer` key field. +- **Document** — FastTabs plus a lines subpage, lines filtered to the + header; named for the document (`Sales Invoice`). +- **ListPlus** — like Document but with multiple lists instead of one; + named like the record/report it summarizes. +- System dialog types (`NavigatePage`, `ConfirmationDialog`, + `StandardDialog`, `HeadlinePart`) are fixed shapes with no page-name + suffix convention. `API` pages follow their own property rules and are + extended by adding a new API page, never a page extension. + +Before wiring controls, the design step should fix: which users and +tasks the page serves, the concrete fields/commands/links those tasks +need, the page type that matches the content (chosen before the source +table), and the source table that actually holds the page's primary data. + +See sample: [`page-design-must-match-bc-page-type-conventions.good.al`](page-design-must-match-bc-page-type-conventions.good.al). + +## Anti Pattern + +A page that mixes conventions from two types — for example, a "List" +page with no `CardPageID` even though a Card page exists for the same +table — signals a design step was skipped, not a stylistic choice. A +Card page over a composite-key table is not automatically this anti +pattern; check whether the table supplements a master record first. Also watch +for a Worksheet or List page showing primary-key fields it shouldn't (or +hiding them when it should show them). A page with no `UsageCategory` set +is not automatically a defect either: supporting pages, subpages, dialogs, +and pages intended only to be reached through another workflow correctly +have no `UsageCategory` — flag its absence only on a page intended as a +searchable entry point in its own right. + +See sample: [`page-design-must-match-bc-page-type-conventions.bad.al`](page-design-must-match-bc-page-type-conventions.bad.al). diff --git a/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.bad.al b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.bad.al new file mode 100644 index 0000000..82065f0 --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.bad.al @@ -0,0 +1,32 @@ +local procedure UpgradeCustomerFields() +begin + if not UpgradeTag.HasUpgradeTag(GetCustomerDiscountFieldTag()) then begin + Customer.SetLoadFields("Discount %", "Customer Posting Group"); + if Customer.FindSet() then + repeat + if (Customer."Discount %" = 0) and (Customer."Customer Posting Group" <> '') then begin + Customer."Discount %" := 5; + Customer.Modify(); + end; + until Customer.Next() = 0; + UpgradeTag.SetUpgradeTag(GetCustomerDiscountFieldTag()); + + // BUG: a second, unrelated migration's tag check nested inside the + // first migration's guarded body. Neither tag can be checked, + // skipped, or fixed independently of the other - a failure or a + // deliberate skip of the discount migration silently takes the + // shipping-agent migration down with it, and nothing in the + // Upgrade Tags table records that the second step ran on its own. + if not UpgradeTag.HasUpgradeTag(GetCustomerShippingAgentFieldTag()) then begin + Customer.SetLoadFields("Shipping Agent Code"); + if Customer.FindSet() then + repeat + if Customer."Shipping Agent Code" = '' then begin + Customer."Shipping Agent Code" := DefaultShippingAgentCode(); + Customer.Modify(); + end; + until Customer.Next() = 0; + UpgradeTag.SetUpgradeTag(GetCustomerShippingAgentFieldTag()); + end; + end; +end; diff --git a/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.good.al b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.good.al new file mode 100644 index 0000000..e121570 --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.good.al @@ -0,0 +1,40 @@ +local procedure UpgradeCustomerDiscountField() +begin + if UpgradeTag.HasUpgradeTag(GetCustomerDiscountFieldTag()) then + exit; + + Customer.SetLoadFields("Discount %", "Customer Posting Group"); + if Customer.FindSet() then + repeat + // A business-data safety condition inside this one migration's + // loop is not a second migration hiding inside the first - + // Microsoft's own upgrade-tag example nests exactly this shape + // (a corruption guard, then a redundant-write guard) inside a + // single tagged procedure. + if (Customer."Discount %" = 0) and (Customer."Customer Posting Group" <> '') then begin + Customer."Discount %" := 5; + Customer.Modify(); + end; + until Customer.Next() = 0; + + UpgradeTag.SetUpgradeTag(GetCustomerDiscountFieldTag()); +end; + +// A second, genuinely unrelated migration gets its own tag and its own +// top-level procedure - not nested inside the first one's guarded body. +local procedure UpgradeCustomerShippingAgentField() +begin + if UpgradeTag.HasUpgradeTag(GetCustomerShippingAgentFieldTag()) then + exit; + + Customer.SetLoadFields("Shipping Agent Code"); + if Customer.FindSet() then + repeat + if Customer."Shipping Agent Code" = '' then begin + Customer."Shipping Agent Code" := DefaultShippingAgentCode(); + Customer.Modify(); + end; + until Customer.Next() = 0; + + UpgradeTag.SetUpgradeTag(GetCustomerShippingAgentFieldTag()); +end; diff --git a/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.md b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.md new file mode 100644 index 0000000..d2384f8 --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-tag-logic-must-not-nest-deeply.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-tag, nesting, complexity, upgrade-per-company, upgrade-per-database] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Never nest upgrade tag checks or blend two migrations under one tag + +## Description + +Upgrade tag *checks* should stay flat: never nest one tag's existence check inside another tag's guarded body, and never let one tagged procedure quietly perform a second, functionally distinct migration — that turns two upgrade steps into one that can't be tracked, skipped, or fixed independently, which is exactly what separate tags exist to prevent. That is the specific nesting Microsoft's own guidance warns against ("Keep tags simple by limiting nesting tags to two levels"). + +That is not a limit on how much conditional logic a single migration's own loop body may contain. Microsoft's own worked example for upgrade tags nests a record loop with two business-data safety conditions — a corruption guard, then a redundant-write guard — inside one `if UpgradeTagMgt.HasUpgradeTag(...) then exit;`-guarded procedure, and its own design guidance separately *requires* this: "Implement extra safety checks to avoid data corruption, even though you're using upgrade tags." A business-data guard that protects the single migration a tag represents is not a second migration hiding inside the first, however many `if` levels it takes. + +Upgrade code runs unattended, once, against production data with no chance to interactively debug a wrong branch — which is why mixing two migrations under one tag, or losing track of which tag guards which step, is a genuinely higher-cost mistake here than the equivalent would be in ordinary application code. + +## Best Practice + +One tag, one migration: exit early if the tag is already set, then run the one upgrade step that tag represents — including as many business-data safety conditions as that single step's own correctness requires, nested however deep the logic actually needs. Reach for a second, separately tagged migration only when the nested logic is doing genuinely unrelated work (a different table, a different field, a different concern) that could legitimately be skipped, retried, or fixed on its own. + +See sample: [`upgrade-tag-logic-must-not-nest-deeply.good.al`](upgrade-tag-logic-must-not-nest-deeply.good.al). + +## Anti Pattern + +Checking one upgrade tag inside the guarded body of another, or writing two functionally unrelated migrations — different tables, different concerns — under a single tag so neither can be tracked, skipped, or fixed independently of the other. A record loop with business-data safety conditions inside one tagged migration's own body is not this anti-pattern, even several `if` levels deep, as long as every condition serves that one migration. + +See sample: [`upgrade-tag-logic-must-not-nest-deeply.bad.al`](upgrade-tag-logic-must-not-nest-deeply.bad.al). + +## Source + +Microsoft's own "Upgrading Extensions" guidance, Design considerations: "Keep tags simple by limiting nesting tags to two levels. Complicated if statements can lead to problems." — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-upgrading-extensions#using-upgrade-tags-to-control-upgrade-code diff --git a/microsoft/knowledge/web-services/api-page-least-privilege-write-access.bad.al b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.bad.al new file mode 100644 index 0000000..13c33b4 --- /dev/null +++ b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.bad.al @@ -0,0 +1,25 @@ +page 50100 "Vendor Document API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'documents'; + APIVersion = 'v1.0'; + EntityName = 'vendorDocument'; + EntitySetName = 'vendorDocuments'; + SourceTable = Vendor; + // no InsertAllowed/ModifyAllowed override, no Editable = false anywhere + + layout + { + area(content) + { + repeater(GroupName) + { + field(no; Rec."No.") { } + field(vatRegNo; Rec."VAT Registration No.") { } + field(contactEmail; Rec."E-Mail") { } + // ...dozens more fields, none marked Editable = false + } + } + } +} diff --git a/microsoft/knowledge/web-services/api-page-least-privilege-write-access.good.al b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.good.al new file mode 100644 index 0000000..b524b4f --- /dev/null +++ b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.good.al @@ -0,0 +1,25 @@ +page 50102 "Vendor Contact Info API" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'integration'; + APIVersion = 'v1.0'; + EntityName = 'vendorContact'; + EntitySetName = 'vendorContacts'; + SourceTable = Vendor; + DelayedInsert = true; + InsertAllowed = false; + DeleteAllowed = false; + + layout + { + area(content) + { + repeater(GroupName) + { + field(no; Rec."No.") { Editable = false; } + field(contactEmail; Rec."E-Mail") { } + } + } + } +} diff --git a/microsoft/knowledge/web-services/api-page-least-privilege-write-access.md b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.md new file mode 100644 index 0000000..6f40c87 --- /dev/null +++ b/microsoft/knowledge/web-services/api-page-least-privilege-write-access.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: web-services +keywords: [api-page, least-privilege, write-access, odata, security, external-api, identity-fields] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Give API pages least-privilege write access + +## Description + +A general-purpose API page that exposes many fields should not be widened to allow writes on one additional field. A `PageType = API` page consumed by an external integration, an automation agent, or a partner system carries the same risk regardless of caller: a write-enabled page with no per-field restriction is a wide-open surface. Least privilege has to cover both dimensions of exposure: which fields are on the page, and which operations the page allows. Only a field actually placed on the page is reachable at all — but a page that includes many fields, with `InsertAllowed`/`ModifyAllowed`/`DeleteAllowed` left at their defaults and no `Editable = false` on most of them, leaves every one of those included fields — identity fields and financially significant ones among them — fully writable, with nothing marking that as deliberate. Restricting fields alone is not enough either: a page with only two fields on it can still let a caller insert brand-new records or delete existing ones if `InsertAllowed`/`DeleteAllowed` are left at their true defaults (both `true`). + +## Best Practice + +Create a separate, minimal API page that exposes only the key and the specific field the consumer needs to write, with everything else `Editable = false` or simply absent from the page — and set `InsertAllowed`/`DeleteAllowed` to `false` unless the consumer's use case genuinely needs to create or delete records through that page. + +See sample: [`api-page-least-privilege-write-access.good.al`](api-page-least-privilege-write-access.good.al). + +## Anti Pattern + +Widening an existing general-purpose API page with write access to one field, leaving every other field on the page (including identity and posting fields) writable by default because no one added `Editable = false`. + +See sample: [`api-page-least-privilege-write-access.bad.al`](api-page-least-privilege-write-access.bad.al). diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md index 2efc049..c12815f 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -52,6 +52,7 @@ The following targeted checks cover every current `appsource` article across the - A page or report that repository context identifies as a direct user entry point omits `UsageCategory` or sets it to `None` — `set-usagecategory-on-searchable-entry-points`. Do not select this article based only on object type; exclude supporting parts, dialogs, API pages, and objects intentionally reached through another page. - A `DateTime` assignment adds or subtracts a fixed duration to represent an assumed regional offset — `do-not-hard-code-time-zone-offsets`. Require contextual evidence such as an hour-sized constant, offset-oriented name, or time-zone comment; do not flag deadlines, schedules, or elapsed-time calculations. - For BC v27 or later, `app.json` adds or changes the `help` URL to a path deeper than two levels, or a changed Copilot/context-sensitive help arrangement would ground the app under an overly broad truncated parent — `keep-copilot-help-url-to-two-path-levels`. +- A release/submission pipeline change (`AL-Go-Settings.json`, a publish/release workflow) or an `app.json` version bump is present without the new complete version being strictly greater than the previously submitted one, or the change asserts a hand-edited build/revision or every-merge-is-a-release policy as a universal AppSource rule rather than a project-specific workflow choice — `release-must-update-app-version`. Require repository/pipeline context to know the previously submitted version; a single `app.json` diff cannot prove ordering on its own. - Changed code declares or calls `File.Open`/`File.Create`/`File.Read`/`File.Write` in an app targeting Business Central Online — `file-datatype-saas`. 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`. diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index e58b6b0..a73f929 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -46,6 +46,9 @@ A file enters the candidate worklist when its `keywords` intersect the extracted The following targeted checks cover every current `data-modeling` 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 `* Setup` table or its page changes singleton structure, uses a nonblank or generated key, permits insert/delete, uses a List page, or does not ensure the blank-keyed row exists — `setup-table-is-a-singleton`. +- A new field is typed `Media`, `MediaSet`, or `BLOB` and the field's caption/name suggests a picture or image — `pictures-must-use-media-not-blob`. +- Code outside a test codeunit or a demo-data generator calls `WorkDate(NewDate)` (the assignment form, not a bare `WorkDate()` read) as part of logic whose purpose is unrelated to the work date itself — `code-must-not-change-workdate`. A test deliberately setting a date context, or a demo-data routine that saves, sets, and restores the work date to backdate the data it creates, is not this anti-pattern. +- A new or extended table's name, fields, or usage positively establish it as one of Business Central's nine business-record types — a name ending `Ledger Entry`/`Register`/`Journal Line`/`Header`/`Line`/`Setup`, an auto-generated `Entry No.`/`No.` key posted from elsewhere, a `Template Name`+`Batch Name`+`Line No.` key, or a singleton `Primary Key` field — `table-design-must-match-bc-table-type-conventions`. Do not worklist it from a bare `keys` block or primary-key declaration alone: a temporary/buffer table, a work queue, a log, a cross-reference/mapping table, or a process-local staging table is not one of the nine types and is out of this rule's scope entirely, not an unresolved case. - Code reads `Item Ledger Entry."Document No."` (or `"Last Shipping No."`/`"Last Posting No."`) after a combined Ship+Invoice **sales** post — `item-ledger-entry-document-no-follows-last-shipping-no`. This is a sales-specific rule: purchase combined posting is Receive+Invoice and uses receiving fields such as `"Last Receiving No."`, not the shipment/document-number behavior this article describes. Do not worklist it from purchase posting code. - 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`. diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index a340807..3962363 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -55,6 +55,8 @@ A file enters the candidate worklist when its `keywords` intersect the extracted The following targeted checks cover every current `error-handling` article: - `[ErrorBehavior(ErrorBehavior::Collect)]`, `ErrorInfo.Collectible`, `HasCollectedErrors`, `GetCollectedErrors`, or `ClearCollectedErrors` is added or changed, especially when errors are collected without later surfacing/clearing them — `collect-validation-errors-with-errorbehavior`. +- New or changed code inserts an error/duration log record around a failed `TryFunction`/`GetLastErrorText`/`GetLastErrorCode` path and then raises, propagates, or rethrows the error — `log-writes-must-survive-rollback`. Do not worklist it when the log insert already happens inside a `Session.StartSession`-targeted codeunit's `OnRun`; that is the compliant shape, not the signal to flag. +- A guarded lookup (`if Record.Get(...) then ... else` or similar) sets a value used later, and the same guard shape (with the same blank/zero fallback style) is applied to a field that feeds a posted amount, a tax/VAT calculation, a quantity or price actually used in a transaction, or a legally/compliance-facing output — `defensive-vs-offensive-code-must-match-blast-radius`. The signal is a posting-critical or compliance-facing field guarded defensively with a silent fallback, not the mere presence of a guarded lookup. - Developer-only invariant text is raised with default client visibility, or a user-actionable validation is hidden as `ErrorType::Internal` — `errortype-internal-vs-client-for-diagnostics`. - `FieldError` receives a complete capitalized sentence, repeats the field caption/value, or ends the predicate with punctuation — `fielderror-default-message-logic`. - An unguarded `FieldError` is used as though it performed a comparison, or `TestField` is forced onto a complex rule needing a tailored predicate — `fielderror-vs-testfield`. diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index e18d3fc..3d70173 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`, `SetFilter`). +- 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`, `ServiceEnabled`, `PageType = API`, `QueryType = API`, `permissionset`, `Web Services`). 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. @@ -49,6 +49,7 @@ For secret values, select the most specific sink owner: - When a `Text`/`Code` credential is declared, passed, returned, or unwrapped without a visible HTTP URI/header/body sink, use `secrettext-for-credentials.md`. - When that value is interpolated into a URI, authorization header, or HTTP body and sent through `HttpClient`, use `secrettext-with-httpclient.md` as the primary finding. It supersedes the generic credential-type article at that location; keep the latter only as a supporting reference when useful. +- When a page/query is registered in Web Services, declares `PageType = API`/`QueryType = API`, a codeunit is registered in Web Services, or `[ServiceEnabled]` is added to a page procedure, and no permission set in the app grants a matching `page "..." = X` / `query "..." = X` / `codeunit "..." = X` entry for that specific object — use `exposed-objects-must-be-in-a-permission-set.md`. The anti-pattern is the exposed object missing its own execute entry, even when the underlying table's `tabledata` permissions look complete; require repository-level permission-set context, since one file cannot prove an entry is absent elsewhere in the app. Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 0cc747e..2aaf902 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -52,6 +52,9 @@ Apply these high-signal mappings before fuzzy topic ranking: - A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder — `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged. - A normal two-argument `Evaluate` has a resolved `DateFormula` destination and a hard-coded non-angle-bracket date-formula literal, directly or through a visible constant — `dateformula-evaluate-needs-language-independent-literals.md`. Do not use this cue for dynamic/localized external input, already invariant `<...>` input, or direct `CalcDate(Text, ...)` calls. - `function-call-parentheses-required.md` applies only to a zero-argument invocation written without `()`. Never worklist it from an invocation that already has parentheses or supplies arguments, including `Error(Label, Arg1, Arg2)`. +- A new or changed comment restates what the adjacent code already makes obvious from its own names and structure (a comment that just repeats a variable/field/method name in prose) rather than explaining a non-obvious constraint, invariant, or workaround — `al-comments-must-not-restate-what-code-already-shows.md`. A comment absent entirely is not this anti-pattern; only a present-but-redundant comment is. +- A `page`/`pageextension` adds or changes a procedure body that performs a calculation, validation, or record mutation belonging to a business operation reused across entry points, rather than presentation-specific state or a call into a codeunit — `pages-must-not-contain-business-logic.md`. A page calling a codeunit procedure, or a page's own presentation-only state and formatting, is not this anti-pattern; nor is a data invariant that belongs on the table itself. +- Changed source files are added under an object-type folder (`Tables/`, `Pages/`, `Codeunits/`, etc.) in a repository whose existing structure is predominantly feature-based, or vice versa — `source-organized-by-feature-not-object-type.md`. The anti-pattern is inconsistency with the repository's own established convention, not the choice of either scheme; a repository consistently organized by object type throughout is not a violation. Require repository-level folder context; a single new file's path cannot prove the project's convention alone. - A new `.app` build artifact appears at the project root or another unversioned/arbitrary location, or is added to source control alongside the AL source that produced it — `al-build-output-must-not-pollute-project-root.md`. A deliberate `--outfolder`/`outputPath` destination added to `.gitignore` is the compliant shape, not the signal to flag. - A new or renamed AL identifier (variable, procedure, parameter, field, object, enum value, or label identifier) contains non-English words — `al-identifiers-english.md`. A caption, tooltip, or other user-facing text value in a non-English language is not this anti-pattern; only the identifier itself is in scope. - A field or variable is typed `Boolean` and its two possible values are genuinely named domain alternatives (a status pair like Inbound/Outbound, Debit/Credit, Buy/Sell) rather than a true/false predicate, or an `Option`/`Enum`/`Integer` models a domain concept that is intrinsically a yes/no flag — `binary-choice-must-be-boolean.md`. The signal is a semantic mismatch between the type and the domain concept, not the current number of states. diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index 2adbc13..5bc30c8 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -46,6 +46,11 @@ A file enters the candidate worklist when its `keywords` intersect the extracted The following targeted checks cover every current `testing` article. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action. - A method in a `Subtype = Test` codeunit adds or changes `[TransactionModel(...)]`, exercises code that calls `Commit` under `AutoRollback`, defaults broadly to `AutoCommit`, or chooses `None` for a writing test — `transactionmodel-attribute-governs-test-transactions`. +- A new or changed `[Test]` procedure is added, whether or not it already carries `[FEATURE]`/`[SCENARIO]`/`[GIVEN]`/`[WHEN]`/`[THEN]` tags — `test-feature-scenario-tags`. A procedure with no tags at all, or a generic name like `Test1`, is the anti-pattern signal; presence of the tags is the compliant shape, not the thing to search for. +- A test codeunit calls `TestPage` methods (`OpenNew`, `OpenView`, `OpenEdit`) alongside `[Test]` procedures in the same codeunit that call business-logic procedures directly with no `TestPage` involved — `ui-test-codeunit-naming`. The anti-pattern signal is both kinds of test mixed into one codeunit (or, on a project using the `_UT` convention, a UI-layer codeunit missing the suffix); a codeunit containing only `TestPage`-driven tests is not itself a violation. +- A `[GIVEN]`-tagged setup precedes a posting call or report execution and does not visibly set up posting-group/VAT setup records, an explicit date, or (for a report test) both an included and an excluded record — `given-blocks-must-cover-full-precondition-chain`. +- A test procedure contains more than one `[WHEN]` block, or more than one distinct action not labelled `[GIVEN]`, without the procedure name declaring a flow/defect-then-fix shape — `test-one-when-per-test`. +- A `BCPT*` scenario codeunit is added and the PerformanceTest app's only other scenario codeunits are copies of Microsoft's shipped BCPT samples (`BCPT Create Customer`, `BCPT Create Item Journal`, `BCPT Post GL Entries`, etc.) with no scenario exercising the extension's own codeunits, FlowFields, or pages — `bcpt-scenarios-must-be-app-specific`. - An `AutoCommit` test runs under a `Subtype = TestRunner` codeunit that omits `TestIsolation` or sets it to `Disabled`, leaving committed data between tests — `testisolation-belongs-on-the-test-runner`. Require runner/repository context; a standalone test file cannot prove which runner executes it. - A permission-sensitive test uses `TestPermissions = Disabled`, claims to test a restricted user without `"Permissions Mock"`/`"Library - Lower Permissions"`, or declares `[TestPermissions(...)]` without applying that context — `permission-tests-must-lower-the-execution-context`. - Test fixture code manually calls `Init`/`Insert`, invents keys or prerequisite records, or bypasses available `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, or equivalent library codeunits — `use-library-codeunits-for-test-fixtures`. diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index 8c35f49..f54c0c9 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -40,8 +40,9 @@ Discard files that are not applicable. Retain conditionally applicable files onl Narrow the relevant files to the subset that applies to the changes under review. - **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files. +- A new or changed page's name/suffix, primary-key handling, `CardPageID`, `SubPageLink`, `AutoSplitKey`, or `UsageCategory` doesn't match the conventions of its own declared `PageType` — `page-design-must-match-bc-page-type-conventions.md`. A Card page over a composite-key table that supplements a master record, or a supporting/subpage/dialog page intended only to be reached through another workflow and correctly omitting `UsageCategory`, is not this anti-pattern on its own; check whether the page is actually mixing conventions or is meant as a searchable entry point before flagging. - For each relevant knowledge file, compute overlap against changed page declarations and control add-in files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, promoted action definitions, field importance, page background tasks, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL. -- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `fieldgroups`, `DropDown`, `UpdatePropagation`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions). +- 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`). 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 page element. 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-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index 9ae61f8..dce1053 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -45,6 +45,7 @@ Narrow the relevant files to the subset that applies to the changes under review - Worklist the install-versus-upgrade rule when migration helpers are reachable only from an install codeunit. - Worklist `install-and-upgrade-codeunits-have-no-order.md` when a change adds multiple install or upgrade codeunits whose same-phase triggers share state or depend on one another. - Worklist `appversion-meaning-depends-on-execution-context.md` when install or upgrade code branches on `ModuleInfo.AppVersion()` or confuses it with `DataVersion()`. +- An upgrade tag's existence check (`HasUpgradeTag`) is nested inside another tag's guarded body, or one tagged procedure performs two or more functionally unrelated migrations (different tables, fields, or concerns) under a single tag, or one procedure mixes the gated logic for more than one distinct upgrade tag — `upgrade-tag-logic-must-not-nest-deeply.md`. Do not flag record loops or business-data safety guards (corruption checks, redundant-write checks, or other conditions) that serve the single migration the tag represents, however many `if` levels they take — that is the compliant shape the article explicitly permits. 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. diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index b86c79b..6d7d71a 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -42,6 +42,7 @@ Narrow the relevant files to the subset that applies to the changes under review - 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. +- 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. - An API page (`PageType = API`) with `InsertAllowed = true` lists a field in `ODataKeyFields` and that same field control sets `Editable = false` — `api-page-key-fields-must-be-editable-on-insert.md`. A system-generated key such as `SystemId` marked read-only is not this anti-pattern. - An API page exposes a field via `Rec` directly, and that field is a stored value computed from other fields inside an `OnValidate` trigger rather than a FlowField recalculated in `OnAfterGetRecord` — `stored-derived-fields-must-not-be-exposed-directly.md`. Exposing a genuine FlowField, or a value already recalculated in `OnAfterGetRecord`, is not this anti-pattern. - 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`). From 830eaff68f92128dc764275d2b93b4b4e315ea77 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:13:06 +0200 Subject: [PATCH 35/51] 3 AL/BC testing patterns from an external BC testing expert's blog (Luc van Vugt, fluxxus.nl) (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add 4 more AL/BC testing patterns from Luc van Vugt's fluxxus.nl blog Fourth batch from CURABIS ApS, mined from an external BC/NAV testing expert's blog archive (fluxxus.nl). Confirm+StrSubstNo interaction with ConfirmHandler, Table Relation Test's OnAfterRemoveTableRelation exclusion hook (verified against BCApps source, codeunit 134926), committing shared lazy-Initialize fixture data, and Assert.IsFalse vs asserterror for boolean checks. * Address Jesper Schulz-Wedde's review on PR #159 - transactionmodel-attribute-governs-test-transactions.md: the "Commit causes an error" behavior is specific to an explicitly declared AutoRollback attribute. A test method with no TransactionModel attribute at all is a distinct, valid shape — BCApps' own codeunit 134915 "ERM Online Mapping Setup" commits inside a lazy Initialize() with no attribute declared, cleaning up via a manual asserterror at the end. Evidence for commit-shared-test-fixture- inside-lazy-initialize.md (this PR), which is correct as submitted. - confirm-needs-strsubstno-before-confirmhandler-sees-substituted-text.md: reframe as a known, unconfirmed-fix platform defect (microsoft/ALAppExtensions#23935) rather than designed behavior; add the Message/MessageHandler asymmetry as supporting evidence. - table-relation-test-exclude-known-invalid-relations-via-event.md: note the test-app-only consumer dependency; correct "walks every TableRelation field property in the app" to the actual tenant-wide Table Relations Metadata scope across installed apps. - Wire confirm-needs-strsubstno, commit-shared-test-fixture-inside- lazy-initialize, and table-relation-test-exclude-known-invalid- relations-via-event into al-testing-review.md's candidate-selection cues. Co-Authored-By: Claude Sonnet 5 * Address second round of Jesper Schulz-Wedde's review on PR #159 - commit-shared-test-fixture-inside-lazy-initialize.md: fundamentally rewritten. AutoCommit is the documented default TransactionModel, not AutoRollback. Explains the real mechanism (Commit() protects a fixture from the test method's own later deliberate rollback, per Codeunit.Run/ TransactionModel-property semantics) and the TestIsolation dependency (Disabled/Codeunit survive across methods, Function does not). Fixtures rewritten to demonstrate the actual failure/success shape. - transactionmodel-attribute-governs-test-transactions.md: now states the AutoCommit default explicitly and agrees with the article above, closing the contradiction Jesper flagged between the two testing articles. - Deleted confirm-needs-strsubstno-before-confirmhandler-sees-substituted-text (.md/.good.al/.bad.al): the underlying platform bug (microsoft/ ALAppExtensions#23935) was closed as completed in Feb 2024; cannot be reproduced or bc-version-pinned on any currently supported version. - table-relation-test-exclude-known-invalid-relations-via-event.md: added the [Scope('OnPrem')] boundary verified against BCApps' Table Relation Test codeunit. - use-assert-isfalse-not-asserterror-for-boolean-checks.md: added a Scope section resolving the overlap with asserterror-needs-expectederror-and-code. - al-testing-review.md: fixed the shared-fixture cue to catch the actual anti-pattern instead of the compliant shape, added the missing cue for use-assert-isfalse-not-asserterror-for-boolean-checks, wired precedence between it and the generic asserterror rule, and removed the cue for the deleted article. - Added in-file Source provenance (specific fluxxus.nl post per article, with what was independently verified vs. taken from the post) to the three surviving externally-inspired articles, per Jesper's request that provenance live in the knowledge file itself, not only the PR description. Co-Authored-By: Claude Sonnet 5 * Fix remaining correctness issues from Jesper's 2026-09-15 re-review - commit-shared-test-fixture-inside-lazy-initialize: three sub-issues. Recommended TestIsolation = Codeunit instead of listing Disabled as an equal option - Disabled never rolls back at all ("tests are not isolated from each other" per the property's own docs), so a fixture this pattern commits under Disabled is permanent database contamination unless something else tears it down; Disabled is now only mentioned alongside that explicit teardown requirement. Added precedence in al-testing-review.md so the deliberate end-of-test asserterror Error(...) rollback sentinel isn't also flagged by the generic asserterror-needs-expectederror-and-code rule. Rewrote both fixtures to actually demonstrate the pattern: persisted fixture data (an Item record) instead of an empty comment, a second [Test] method that depends on the fixture surviving into it, and an explicit Subtype = TestRunner / TestIsolation = Codeunit runner codeunit. - table-relation-test-exclude-known-invalid-relations-via-event: the length/type rule was stated as one global requirement. Verified ValidateFieldRelation in codeunit 134926 directly (BCApps reference clone) and split it into the two branches the source actually has: a field with any unconditional relation needs exact length and exact resolved type; a field whose relations are all conditional only fails on being shorter (longer is fine) than the largest related field, and when the required type is specifically Code, a Text source passes too - a tolerance that does not apply on the unconditional side and does not extend to a required Text. Rebased onto upstream/main (one conflict in transactionmodel-attribute-governs-test-transactions.md - upstream had already linked its sample references via the READ convention, ours added a Source section; merged both). Also converted the 3 remaining plain-backtick sample references in this PR to the READ-convention markdown-link form, same fix as #156/#157/#158. * Fix four merge-critical issues from Jesper's 2026-09-22 review - al-testing-review.md: the generic ExpectedError cue's asserterror Assert.IsTrue/IsFalse exclusion was unconditional, but the specialized rule it deferred to only claims the pure-inversion shape. A test expecting the guarded Boolean-returning call itself to raise fell through both routes. Narrowed the exclusion to the same inversion-only condition the specialized cue already uses. - asserterror-needs-expectederror-and-code.md: the rollback-sentinel exception (a trailing asserterror Error(...) used purely to force a fixture rollback, not to verify a specific failure) previously lived only in skill routing prose. Encoded it directly in the article's Anti Pattern section so every consumer of the knowledge base sees it, not just this one skill. - commit-shared-test-fixture-inside-lazy-initialize.good.al/.bad.al: replaced hand-rolled Item.Init()/Insert(true) with LibraryInventory.CreateItem, so the canonical fixture doesn't itself trigger use-library-codeunits-for-test-fixtures. - table-relation-test-exclude-known-invalid-relations-via-event.good.al/ .bad.al: declared minimal "Sample Setup"/"Sample Header" tables inline instead of referencing undefined symbols, matching this repo's own convention that every fixture is self-contained. * Give the table-relation-test fixtures a real relation to exclude and one to protect The "Category Code" field had no TableRelation at all, so the good subscriber's RemoveTableRelation call targeted metadata that never existed - a no-op. Added a real TableRelation to "Sample Setup" on that field (the one known exception to exclude) and a second, ordinary self-referencing relation ("Parent No." -> "Sample Header"."No.") with no exception. The good fixture now removes only the first; the bad fixture's table-wide removal (field/related table/field all 0) now demonstrably also strips the second, showing the actual anti-pattern instead of removing nothing meaningful. --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...sserterror-needs-expectederror-and-code.md | 2 + ...test-fixture-inside-lazy-initialize.bad.al | 73 +++++++++++++++++++ ...est-fixture-inside-lazy-initialize.good.al | 71 ++++++++++++++++++ ...red-test-fixture-inside-lazy-initialize.md | 34 +++++++++ ...e-known-invalid-relations-via-event.bad.al | 47 ++++++++++++ ...-known-invalid-relations-via-event.good.al | 51 +++++++++++++ ...clude-known-invalid-relations-via-event.md | 35 +++++++++ ...del-attribute-governs-test-transactions.md | 10 ++- ...-not-asserterror-for-boolean-checks.bad.al | 18 +++++ ...not-asserterror-for-boolean-checks.good.al | 18 +++++ ...alse-not-asserterror-for-boolean-checks.md | 34 +++++++++ microsoft/skills/review/al-testing-review.md | 5 +- 12 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.bad.al create mode 100644 microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.good.al create mode 100644 microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.md create mode 100644 microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.bad.al create mode 100644 microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.good.al create mode 100644 microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.md create mode 100644 microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al create mode 100644 microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.good.al create mode 100644 microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.md diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md index 4560a18..9ebf3f8 100644 --- a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md +++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md @@ -23,4 +23,6 @@ See sample: [`asserterror-needs-expectederror-and-code.good.al`](asserterror-nee `asserterror DoInvalid();` with nothing after it. The test asserts only that the call failed somehow; swap the validation for a different bug and the test still passes, certifying a guard that may no longer fire. A negative test that cannot tell one error from another verifies almost nothing. +Not an instance of this anti-pattern: a trailing `asserterror Error(SomeLabel)` used purely as an end-of-test rollback sentinel to undo a lazily-initialized shared fixture's scratch changes (see `commit-shared-test-fixture-inside-lazy-initialize.md`). That `Error` call exists to force a rollback, not to verify that a specific failure occurred — the sentinel's own text is not meant to be asserted against, and adding an `ExpectedError` there would just duplicate the label without checking anything the test doesn't already control. + See sample: [`asserterror-needs-expectederror-and-code.bad.al`](asserterror-needs-expectederror-and-code.bad.al). diff --git a/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.bad.al b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.bad.al new file mode 100644 index 0000000..f2af637 --- /dev/null +++ b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.bad.al @@ -0,0 +1,73 @@ +codeunit 50142 "Sample Test Library" +{ + Subtype = Test; + + var + LibraryInventory: Codeunit "Library - Inventory"; + Initialized: Boolean; + SharedItemNo: Code[20]; + RollBackMsg: Label 'Revert back the tables to their original state.'; + + local procedure Initialize() + begin + if Initialized then + exit; + + CreateSharedFixtureData(); + // BUG: no Commit() here. The fixture below is still inside this + // test method's own transaction. + Initialized := true; + end; + + local procedure CreateSharedFixtureData() + var + Item: Record Item; + begin + LibraryInventory.CreateItem(Item); + SharedItemNo := Item."No."; + end; + + [Test] + procedure FirstTestUsesSharedFixture() + var + Item: Record Item; + begin + Initialize(); + + Item.Get(SharedItemNo); + Item.Description := 'Scratch change this test makes and does not need to keep.'; + Item.Modify(); + + asserterror Error(RollBackMsg); + // The deliberate rollback above also erases the never-committed + // fixture from CreateSharedFixtureData(). Initialized still reads + // true on the next test, but the row it points at is gone. + end; + + [Test] + procedure SecondTestStillFindsSharedFixture() + var + Item: Record Item; + begin + Initialize(); + + // Fails here: Initialize() saw Initialized = true and returned + // immediately, so it never recreated the fixture - and the first + // test's rollback took the original row with it. + Item.Get(SharedItemNo); + end; +} + +codeunit 50143 "Sample Test Runner" +{ + // Codeunit isolation alone does not save this fixture: TestIsolation + // only controls whether committed changes survive between methods, and + // this fixture was never committed in the first place. + Subtype = TestRunner; + TestIsolation = Codeunit; + + trigger OnRun() + begin + Codeunit.Run(Codeunit::"Sample Test Library"); + end; +} diff --git a/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.good.al b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.good.al new file mode 100644 index 0000000..c550697 --- /dev/null +++ b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.good.al @@ -0,0 +1,71 @@ +codeunit 50142 "Sample Test Library" +{ + Subtype = Test; + + var + LibraryInventory: Codeunit "Library - Inventory"; + Initialized: Boolean; + SharedItemNo: Code[20]; + RollBackMsg: Label 'Revert back the tables to their original state.'; + + local procedure Initialize() + begin + if Initialized then + exit; + + CreateSharedFixtureData(); + Commit(); + Initialized := true; + end; + + local procedure CreateSharedFixtureData() + var + Item: Record Item; + begin + LibraryInventory.CreateItem(Item); + SharedItemNo := Item."No."; + end; + + [Test] + procedure FirstTestUsesSharedFixture() + var + Item: Record Item; + begin + Initialize(); + + Item.Get(SharedItemNo); + Item.Description := 'Scratch change this test makes and does not need to keep.'; + Item.Modify(); + + asserterror Error(RollBackMsg); + // Rolls back the Modify() above, but not the fixture: that was + // already committed inside Initialize(). + end; + + [Test] + procedure SecondTestStillFindsSharedFixture() + var + Item: Record Item; + begin + // Runs after FirstTestUsesSharedFixture's deliberate rollback. + // Initialize() sees Initialized = true and does nothing, but the + // committed fixture it created earlier is still there to Get(). + Initialize(); + + Item.Get(SharedItemNo); + end; +} + +codeunit 50143 "Sample Test Runner" +{ + // Codeunit isolation: everything this codeunit's tests commit, + // including the shared fixture, survives from one test method to the + // next, and rolls back only once every method in the codeunit has run. + Subtype = TestRunner; + TestIsolation = Codeunit; + + trigger OnRun() + begin + Codeunit.Run(Codeunit::"Sample Test Library"); + end; +} diff --git a/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.md b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.md new file mode 100644 index 0000000..7ec16df --- /dev/null +++ b/microsoft/knowledge/testing/commit-shared-test-fixture-inside-lazy-initialize.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: testing +keywords: [initialize, isinitialized, shared-fixture, commit, autocommit, asserterror, testisolation, lazy-initialization] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Commit shared fixture data created inside a lazy Initialize(), or later tests lose it + +## Description + +A test method with no `[TransactionModel(...)]` attribute defaults to `AutoCommit` (see `transactionmodel-attribute-governs-test-transactions.md`): a method that completes without error commits automatically at its own boundary, with no explicit `Commit()` needed. So a lazy/shared `Initialize()` — guarded by an `IsInitialized` flag, creating master/setup data once to avoid repeating expensive setup across many `[Test]` methods — does not need `Commit()` just to survive into the next test method; under the default model it already will. (Declaring `[TransactionModel(AutoRollback)]` instead is not compatible with this pattern at all: `AutoRollback` assumes the code under test never commits, and a `Commit()` call under it raises a runtime error.) + +What an early `Commit()` inside `Initialize()` actually guards against is the test method's *own later, deliberate* rollback — the BCApps cleanup idiom of ending a test with `asserterror Error(SomeLabel)` to undo demo-data mutations that method made, so the run doesn't permanently dirty the database. Per the documented `Codeunit.Run` transaction semantics, changes are committed at the end of an execution "unless an error occurs" — an unhandled error rolls back whatever wasn't already committed. `Commit()` closes out the fixture's own transaction immediately, so it is unaffected by whatever the rest of that method does afterward, including that end-of-test error. Without the early `Commit()`, the same deliberate rollback wipes out the fixture too, even though `IsInitialized` still reads `true` on the next test, since it's a plain variable, not persisted data. BCApps' `codeunit 134915 "ERM Online Mapping Setup"` shows exactly this shape: no `TransactionModel` attribute, `Commit()` inside a lazy `Initialize()`, and the test itself ends with `asserterror Error(RollBackMessage)`. + +Protecting the fixture from that same-method rollback is necessary but not sufficient for the fixture to reach a *later* test method — that also depends on the executing test runner's `TestIsolation`. Under `Disabled` (the property's own documented default) or `Codeunit` (used by BCApps' own `TestRunner`, `CLITestRunner`, and `SnapTestRunner` codeunits), nothing rolls back until the whole test codeunit finishes, so the already-committed fixture survives across every method run before then. These two are not interchangeable, though: `Codeunit` rolls back everything once the codeunit's last method completes, so the environment is clean afterward; `Disabled` never rolls back anything at all — "tests are not isolated from each other" is the property's own description — so a fixture this pattern commits stays in the database permanently unless something else explicitly deletes it. Under `Function`, the runner rolls back all database changes — explicitly including ones already committed via `Commit()` — after every single test method; no amount of committing inside `Initialize()` makes a fixture shared across methods survive that regime, because the whole premise of a lazy, once-per-codeunit fixture doesn't hold when every method is isolated from every other. + +## Best Practice + +When a test method's own cleanup relies on ending in a deliberate error to roll back its scratch changes, call `Commit()` once, inside the lazy `Initialize()` guard, right after the shared fixture is created — before that cleanup-triggering error can run. This pattern only delivers a fixture shared across test methods when the executing runner's `TestIsolation` is `Disabled` or `Codeunit`; do not recommend it, or pair it with, a `Function`-isolated runner — that configuration undoes the committed fixture after every method regardless. Recommend `TestIsolation = Codeunit`: it gives every method in the codeunit the same shared, committed fixture and still leaves the database clean once the codeunit finishes. Recommend `Disabled` only alongside an explicit, verified teardown step that removes the fixture data at the end of the run — without one, the committed fixture is permanent contamination, not a controlled trade-off. + +See sample: [`commit-shared-test-fixture-inside-lazy-initialize.good.al`](commit-shared-test-fixture-inside-lazy-initialize.good.al). + +## Anti Pattern + +A shared `Initialize()` guarded by `IsInitialized` that creates fixture records without committing, in a test method that ends with a deliberate `asserterror Error(...)` to undo its own scratch changes, run under a `Disabled`- or `Codeunit`-isolated test runner. That rollback also erases the never-committed fixture; the next test still finds `IsInitialized = true` but the rows it depends on are gone. (Under a `Function`-isolated runner the fixture is lost regardless of `Commit()`, for the unrelated reason above — that is a runner-configuration problem, not this anti-pattern.) + +See sample: [`commit-shared-test-fixture-inside-lazy-initialize.bad.al`](commit-shared-test-fixture-inside-lazy-initialize.bad.al). + +## Source + +The shared/lazy `Initialize()` pattern and its `Commit()` call are drawn from Luc van Vugt's "Let's talk about Shared Fixture and how to profit from this with the Dynamics NAV Test Toolkit": https://www.fluxxus.nl/index.php/bc/let39s-talk-about-shared-fixture-and-how-to-profit-from-this-with-the-dynamics-nav-test-toolkit/. That post shows the `Commit()` call in its `Initialize()` example but does not explain the transaction mechanics behind it; the `AutoCommit`-default, `Codeunit.Run`-error, and `TestIsolation`-level analysis above is this article's own, verified independently against Microsoft's TransactionModel/TestIsolation documentation and BCApps' `codeunit 134915 "ERM Online Mapping Setup"` source, not taken from the post. diff --git a/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.bad.al b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.bad.al new file mode 100644 index 0000000..d95d9f2 --- /dev/null +++ b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.bad.al @@ -0,0 +1,47 @@ +table 50144 "Sample Setup" +{ + fields + { + field(1; "Primary Key"; Code[10]) { } + field(2; "Default Category Code"; Code[20]) { } + } + keys + { + key(PK; "Primary Key") { Clustered = true; } + } +} + +table 50145 "Sample Header" +{ + fields + { + field(1; "No."; Code[20]) { } + field(10; "Category Code"; Code[10]) + { + TableRelation = "Sample Setup"."Primary Key"; + } + field(11; "Parent No."; Code[20]) + { + TableRelation = "Sample Header"."No."; + } + } + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +codeunit 50141 "Sample Table Relation Test Ext" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Table Relation Test", 'OnAfterRemoveTableRelation', '', false, false)] + local procedure ExcludeSampleFieldFromTableRelationTest(var TableRelationsMetadata: Record "Table Relations Metadata" temporary) + var + TableRelationTest: Codeunit "Table Relation Test"; + begin + // Removes every relation on the whole table (field/related table/ + // related field all 0), not just the one known exception - this + // also strips "Parent No." -> "Sample Header"."No.", which had no + // exception and should have stayed covered by the standard test. + TableRelationTest.RemoveTableRelation(TableRelationsMetadata, Database::"Sample Header", 0, 0, 0); + end; +} diff --git a/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.good.al b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.good.al new file mode 100644 index 0000000..26db161 --- /dev/null +++ b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.good.al @@ -0,0 +1,51 @@ +table 50144 "Sample Setup" +{ + fields + { + field(1; "Primary Key"; Code[10]) { } + field(2; "Default Category Code"; Code[20]) { } + } + keys + { + key(PK; "Primary Key") { Clustered = true; } + } +} + +table 50145 "Sample Header" +{ + fields + { + field(1; "No."; Code[20]) { } + // A known exception: "Category Code" predates "Sample Setup" and + // can carry a value that no longer resolves to a real row there, + // so the standard Table Relation Test would otherwise reject it - + // excluded via OnAfterRemoveTableRelation below. + field(10; "Category Code"; Code[10]) + { + TableRelation = "Sample Setup"."Primary Key"; + } + // An ordinary relation with no exception - ExcludeSampleFieldFrom + // TableRelationTest below must leave this one checked. + field(11; "Parent No."; Code[20]) + { + TableRelation = "Sample Header"."No."; + } + } + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +codeunit 50141 "Sample Table Relation Test Ext" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Table Relation Test", 'OnAfterRemoveTableRelation', '', false, false)] + local procedure ExcludeSampleFieldFromTableRelationTest(var TableRelationsMetadata: Record "Table Relations Metadata" temporary) + var + TableRelationTest: Codeunit "Table Relation Test"; + begin + // Removes only the one known exception. "Parent No." -> "Sample + // Header"."No." is untouched and stays covered by the standard test. + TableRelationTest.RemoveTableRelation(TableRelationsMetadata, Database::"Sample Header", 10, Database::"Sample Setup", 1); + end; +} diff --git a/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.md b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.md new file mode 100644 index 0000000..3f7d778 --- /dev/null +++ b/microsoft/knowledge/testing/table-relation-test-exclude-known-invalid-relations-via-event.md @@ -0,0 +1,35 @@ +--- +bc-version: [all] +domain: testing +keywords: [table-relation-test, tablerelationsmetadata, onafterremovetablerelation, field-length, field-type] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Exclude a known-valid TableRelation exception via OnAfterRemoveTableRelation + +## Description + +Codeunit 134926 "Table Relation Test" (shipped in BCApps' test app — only consumers that depend on the BC test libraries can subscribe to it) reads Table Relations Metadata tenant-wide across every installed app, not just the current one, and validates each field's type and length against what its relations require — but the exact rule depends on whether that field has an *unconditional* relation (a `Table Relations Metadata` row with `Condition Field No. = 0`) among its relations, or only *conditional* ones: + +- If any relation is unconditional, the field's length must equal *exactly* the largest related field's length, and its type must exactly match the required type — resolved to `Text` when the related fields themselves mix `Code` and `Text`. +- If every relation for that field is conditional, the requirement relaxes: the field only needs to be *at least* as long as the largest related field (longer is accepted; only shorter fails), and when the required type is specifically `Code`, both a `Code` and a `Text` source field pass. That `Code`/`Text` tolerance is conditional-only — it does not apply on the unconditional side, and it does not extend to a required type of `Text` (a `Code` source field does not satisfy a required `Text`). + +A field with a legitimate, intentional relation shape outside both of these tolerances has no per-field override in its own object definition; the check runs with no built-in escape hatch beyond them. The validation test method itself is `[Scope('OnPrem')]`: it only runs from an on-premises test surface, not from a cloud-targeted test app, so this whole exception mechanism — and the check it works around — is only reachable where that test can actually execute. + +## Best Practice + +Subscribe to `OnAfterRemoveTableRelation` and call the codeunit's own `RemoveTableRelation(TableRelationsMetadata, TableID, FieldID, RelatedTableID, RelatedFieldID)` to strike the one known-valid relation before the test evaluates it, scoped as narrowly as the exception actually is. Because the test itself is `[Scope('OnPrem')]`, do not recommend subscribing to it as a way to guard a cloud-targeted app's test suite — the subscription has no effect where the test never runs. + +See sample: [`table-relation-test-exclude-known-invalid-relations-via-event.good.al`](table-relation-test-exclude-known-invalid-relations-via-event.good.al). + +## Anti Pattern + +Excluding an entire table's relations (or disabling the whole test codeunit) to work around one known exception. This discards the check's coverage for every other relation on that table, or in the app, not just the one that needed an exception. + +See sample: [`table-relation-test-exclude-known-invalid-relations-via-event.bad.al`](table-relation-test-exclude-known-invalid-relations-via-event.bad.al). + +## Source + +The `OnAfterRemoveTableRelation` exclusion technique is drawn from Luc van Vugt's "How-to: Test your Table Relations (2)": https://www.fluxxus.nl/index.php/bc/how-to-test-your-table-relations-2/. The codeunit/event signature, the `[Scope('OnPrem')]` boundary, and the tenant-wide `Table Relations Metadata` scope described above were verified directly against BCApps' `codeunit 134926 "Table Relation Test"` source, not taken from the post. diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md index c9c159a..0f4f3bd 100644 --- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md +++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md @@ -11,16 +11,20 @@ application-area: [all] ## Description -`[TransactionModel(...)]` declares how a test method interacts with the database's write transaction. The attribute applies only to methods inside a codeunit with `SubType = Test` and takes one of three values: `AutoRollback`, `AutoCommit`, or `None`. The choice must match the code being exercised — in particular, whether that code calls `Commit()`. Per the platform reference, "if the code that you test includes calls to the COMMIT Method, then set the TransactionModel property on the test method to AutoCommit." Applying `AutoRollback` to a test that drives code which calls `Commit` produces a runtime error on the first Commit, not a meaningful assertion failure — the test does not complete, and the reviewer sees an infrastructure error instead of a business-logic verdict. +`[TransactionModel(...)]` declares how a test method interacts with the database's write transaction. The attribute applies only to methods inside a codeunit with `SubType = Test` and takes one of three values: `AutoRollback`, `AutoCommit`, or `None`. **`AutoCommit` is the documented default** — a test method with no `[TransactionModel(...)]` attribute at all runs under `AutoCommit`, not `AutoRollback` and not `None` (Microsoft's TransactionModel property reference states this explicitly: "AutoCommit is the default value"). The "a call to `Commit` produces a runtime error" behavior is specific to the *explicitly declared* `AutoRollback` attribute. BCApps' own canonical pattern for a lazily-initialized shared fixture (see `codeunit 134915 "ERM Online Mapping Setup"`) declares no `TransactionModel` attribute at all — so it runs under the `AutoCommit` default — calls `Commit()` inside its `Initialize()` helper, and cleans up manually with a deliberate `asserterror Error(...)` at the end rather than relying on automatic rollback; this is a legitimate, common pattern, not a bug. Per the same reference, under `AutoCommit` an error, even one caught by `asserterror`, still rolls back the transaction — but "only to the point at which `Commit` was called" if the code being tested committed first. When a test method *does* declare `AutoRollback` explicitly, the choice must match the code being exercised: per the platform reference, "if the code that you test includes calls to the COMMIT Method, then set the TransactionModel property on the test method to AutoCommit." Applying `AutoRollback` to a test that drives code which calls `Commit` produces a runtime error on the first Commit, not a meaningful assertion failure. ## Best Practice -Default to `AutoRollback`: it opens a write transaction at the start of the test, runs the test body, and rolls back at the end, leaving the database in its original state. Pick `AutoCommit` only when the code under test genuinely calls `Commit` — posting routines, job-queue handlers, integration flows — and make the test exercise that commit path. Pair the test codeunit with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. Pick `None` only for read-only tests or tests that drive UI code without writing from the test method itself. +Leave `[TransactionModel(...)]` undeclared to get the `AutoCommit` default when the codeunit's own tests rely on that default's behavior — for example a lazily-initialized shared fixture that commits once and cleans up its own scratch changes with a manual `asserterror`-based rollback (see `commit-shared-test-fixture-inside-lazy-initialize.md`); do not treat that absence as equivalent to declaring `AutoRollback`. When declaring `[TransactionModel(...)]` explicitly instead, pick `AutoRollback` for a test whose own logic and the code it exercises make no `Commit` call, `AutoCommit` when the code under test genuinely calls `Commit` — posting routines, job-queue handlers, integration flows — and make the test exercise that commit path, and `None` for a read-only test or one that drives UI code without writing from the test method itself. Pair an intentional, suite-wide reliance on `AutoCommit` with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. See sample: [`transactionmodel-attribute-governs-test-transactions.good.al`](transactionmodel-attribute-governs-test-transactions.good.al). ## Anti Pattern -Applying `AutoRollback` to every test method without checking whether the tested business logic calls `Commit`. The test throws at the first Commit, leaving no verdict on the behavior it intended to verify; in a CI run this looks like a flake or a setup bug, not a specification mismatch. The mirror-image anti-pattern is defaulting to `AutoCommit` across the suite "to avoid the error" — without a `TestIsolation` runner this permanently dirties the test database between runs and produces order-dependent test outcomes. +Declaring `[TransactionModel(AutoRollback)]` explicitly on a test method without checking whether the tested business logic calls `Commit`. The test throws at the first Commit, leaving no verdict on the behavior it intended to verify; in a CI run this looks like a flake or a setup bug, not a specification mismatch. The mirror-image anti-pattern is defaulting to `AutoCommit` across the suite "to avoid the error" — without a `TestIsolation` runner this permanently dirties the test database between runs and produces order-dependent test outcomes. Flagging a `Commit()` call in a test method that declares no `TransactionModel` attribute at all is not this anti-pattern — that shape does not error, and is BCApps' own documented pattern for shared lazy fixtures. See sample: [`transactionmodel-attribute-governs-test-transactions.bad.al`](transactionmodel-attribute-governs-test-transactions.bad.al). + +## Source + +The `AutoCommit`-is-default claim and the exact rollback-to-last-`Commit` mechanics are quoted from Microsoft's TransactionModel Property reference: https://learn.microsoft.com/en-us/previous-versions/dynamicsnav-2018-developer/TransactionModel-Property. The current AL [TransactionModel attribute](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/attributes/devenv-transactionmodel-attribute) page describes the same three values but never states a default; this older property reference is the citable source for that fact. diff --git a/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al new file mode 100644 index 0000000..07ad6b2 --- /dev/null +++ b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al @@ -0,0 +1,18 @@ +codeunit 50143 "Sample Doc Amount Test" +{ + Subtype = Test; + + [Test] + procedure DocAmountIsNotVerifiedWhenLinesAreMissing() + var + Assert: Codeunit Assert; + PurchHeader: Record "Purchase Header"; + begin + asserterror Assert.IsTrue(VerifyDocAmount(PurchHeader), 'Doc. amount should not verify with no lines.'); + end; + + local procedure VerifyDocAmount(var PurchHeader: Record "Purchase Header"): Boolean + begin + exit(false); + end; +} diff --git a/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.good.al b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.good.al new file mode 100644 index 0000000..eb21d6c --- /dev/null +++ b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.good.al @@ -0,0 +1,18 @@ +codeunit 50143 "Sample Doc Amount Test" +{ + Subtype = Test; + + [Test] + procedure DocAmountIsNotVerifiedWhenLinesAreMissing() + var + Assert: Codeunit Assert; + PurchHeader: Record "Purchase Header"; + begin + Assert.IsFalse(VerifyDocAmount(PurchHeader), 'Doc. amount should not verify with no lines.'); + end; + + local procedure VerifyDocAmount(var PurchHeader: Record "Purchase Header"): Boolean + begin + exit(false); + end; +} diff --git a/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.md b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.md new file mode 100644 index 0000000..182a62b --- /dev/null +++ b/microsoft/knowledge/testing/use-assert-isfalse-not-asserterror-for-boolean-checks.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: testing +keywords: [assert, isfalse, istrue, asserterror, boolean-check, negative-test] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use Assert.IsFalse to check a boolean result, not asserterror around Assert.IsTrue + +## Description + +`asserterror` exists to assert that a statement raises a runtime error; it is not a general-purpose way to invert a boolean check. Wrapping `asserterror Assert.IsTrue(SomeFunc(), Msg)` to verify that `SomeFunc()` returns `false` tests whether `Assert.IsTrue`'s own error-raising behavior fired, not the value `SomeFunc()` actually returned. + +## Best Practice + +When the code under test returns a `Boolean` rather than raising an error, assert the value directly with `Assert.IsFalse(SomeFunc(), Msg)` (or `Assert.IsTrue` for the positive case). Reserve `asserterror` for statements expected to actually raise an error. + +See sample: [`use-assert-isfalse-not-asserterror-for-boolean-checks.good.al`](use-assert-isfalse-not-asserterror-for-boolean-checks.good.al). + +## Anti Pattern + +`asserterror Assert.IsTrue(SomeFunc(), Msg);` to verify `SomeFunc()` is `false`. It passes today because `Assert.IsTrue` happens to raise an error on failure, but it verifies the assertion helper's error-raising behavior, not the value under test. + +See sample: [`use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al`](use-assert-isfalse-not-asserterror-for-boolean-checks.bad.al). + +## Source + +Drawn from Luc van Vugt's "TDD in NAV – ASSERTERROR or IsFalse": https://www.fluxxus.nl/index.php/bc/tdd-in-nav-asserterror-or-isfalse/. The post's own example and reasoning — reserve `asserterror` for the product code actually raising an error, use `Assert.IsFalse`/`Assert.IsTrue` to check a boolean the test framework itself computes — carries over directly; the overlap with `asserterror-needs-expectederror-and-code.md` below is this repository's own addition, not from the source. + +## Scope + +This rule and `asserterror-needs-expectederror-and-code.md` can both match `asserterror Assert.IsTrue(SomeFunc(), Msg);` with nothing after it — the generic rule sees a bare `asserterror`, this one sees `asserterror` wrapping an `Assert.IsTrue`/`Assert.IsFalse` call used to invert a boolean. This rule wins for that shape: the fix is to replace the construct with a direct `Assert.IsFalse`/`Assert.IsTrue` call, not to add `Assert.ExpectedError`/`Assert.ExpectedErrorCode` after it. `asserterror-needs-expectederror-and-code.md` still applies on its own to every other bare `asserterror`, including one guarding `Assert.IsTrue`/`Assert.IsFalse` where the intent genuinely is to assert that the guarded call itself raises an error (for example, asserting that a validation helper errors before it can even return a boolean). diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index 5bc30c8..c42fe85 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -55,7 +55,10 @@ The following targeted checks cover every current `testing` article. Treat each - 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`. +- `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`. Exclude `asserterror Assert.IsTrue(...)` / `asserterror Assert.IsFalse(...)` only when it is used solely to invert the guarded call's Boolean result (the same condition `use-assert-isfalse-not-asserterror-for-boolean-checks` cues on below, which wins for that shape) — not when the test expects the guarded Boolean-returning call itself to raise an error, which this rule still owns even though it happens to wrap an `Assert.IsTrue`/`IsFalse` call. Also exclude a trailing `asserterror Error(...)` used purely as an end-of-test rollback sentinel after a lazy `Initialize()` fixture already committed — that shape belongs to `commit-shared-test-fixture-inside-lazy-initialize`, which wins for it; the sentinel's own error text is not meant to be asserted against. +- `asserterror` wraps `Assert.IsTrue(BooleanExpression, ...)` (or the `IsFalse` mirror) solely to invert the boolean result of the guarded call, rather than to assert that call itself raises an error — `use-assert-isfalse-not-asserterror-for-boolean-checks`. +- A shared/lazy `Initialize()`-style fixture helper creates fixture data without a following `Commit()`, in a test method whose body later forces its own rollback (for example `asserterror Error(...)` used for end-of-test cleanup) — `commit-shared-test-fixture-inside-lazy-initialize`. The presence of `Commit()` after the fixture is the compliant shape, not the signal to look for; the missing-`Commit()` shape combined with a later deliberate rollback is the anti-pattern. Require runner/repository context for the `TestIsolation` value: a standalone test file cannot prove which runner executes it, and under `Function`-level isolation this whole pattern is moot regardless of `Commit()` — do not raise the finding when the executing runner's `TestIsolation` is known to be `Function`. +- Changed code subscribes to `OnAfterRemoveTableRelation`, calls `RemoveTableRelation`, or references `Codeunit "Table Relation Test"`/134926 — `table-relation-test-exclude-known-invalid-relations-via-event`. - Test fixture code assigns a hardcoded literal to a primary-key field or a field the test relies on as a unique lookup identifier, hand-builds a "unique" value for such a field (string concatenation, a counter, `Format(CurrentDateTime)`), or truncates `LibraryUtility.GenerateGUID()`'s result with `CopyStr` for such a field shorter than 10 characters — `use-generateguid-for-unique-test-fixture-values`. Calling `GenerateGUID()` untruncated into a full-length field, `GenerateRandomCodeWithLength` for a shorter field needing real verified uniqueness, or `GenerateRandomCode20` specifically for a `Code[20]` field, is the compliant shape, not the signal to flag. `GenerateRandomCode20` is not a substitute for `GenerateRandomCodeWithLength` on a shorter field — it truncates `GenerateGUID()`'s sequential value down to the field's length by keeping the *leftmost* characters, which change the slowest, so retries against a short field can churn through the same truncated prefix far longer than `GenerateRandomCodeWithLength`'s equivalent. A hardcoded or deterministic value in an ordinary descriptive field is not this anti-pattern — that field carries no uniqueness constraint. Do not claim `GenerateRandomCode` (without `WithLength`/`20`) or `GenerateRandomXMLText` verify uniqueness against the real table, or that `GenerateRandomCode` is collision-free even within one test run for a short field — none of that is true. - A test asserts against a `TestPage` field's `.Visible()` or `.Enabled()` — `use-testpage-visible-enabled-to-verify-field-ui-state`. When the assertion is against `.Editable()`, or the page is opened with `OpenEdit()` specifically to check editability — `use-testpage-editable-to-verify-field-editability`. - 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. From 56ce52a9c7a01046d4f2b603ddb0d9d044bb9502 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:13:35 +0200 Subject: [PATCH 36/51] AL methods limited during write transactions (RunModal, Codeunit.Run) (#161) * Add runmodal-is-not-allowed-inside-write-transactions.md; drop "modal page" from the prompts article A modal page does not behave like Confirm/StrMenu inside a write transaction: the platform refuses Page.RunModal (and Report/XmlPort .RunModal with a request page, and Codeunit.Run with its return value used) with a runtime error instead of holding the lock. The new article documents that guard - verified against Microsoft Learn (Codeunit.Run transaction semantics), microsoft/AL#5452, Microsoft's own Base Application (Commit(); Page.RunModal pattern), and a live reproduction on Business Central 26 quoted verbatim. avoid-user-prompts-inside- transactions.md keeps its scope to the prompts the platform does allow; "modal page" is removed from its list because that case is refused, not stalled. Co-Authored-By: Claude Fable 5.1 * Do not list Message among the lock-holding prompts Message runs asynchronously - it is queued and shown when the calling method ends or another method requests input - so it never pauses the transaction and holds no lock. Only Confirm and StrMenu wait for the user. Co-Authored-By: Claude Fable 5.1 * State the four write-transaction conditions as one explicit line per method Mirrors the structure of the platform's own error message so the Report.RunModal and XmlPort.RunModal request-page exceptions are visible at a glance instead of buried in prose. Co-Authored-By: Claude Fable 5.1 * Title the article after the platform error it explains "AL methods limited during write transactions: commit before RunModal and Codeunit.Run" - so a developer or agent searching for the runtime error text lands on the one article that covers all four restricted methods. Slug and sample stems renamed to match; keywords gain the error's own phrase and the legacy Form.RunModal name it still uses. Co-Authored-By: Claude Fable 5.1 * Trim keywords to 10 per validator guidance * Wire the new article into al-performance-review.md Adds Page.RunModal / Report.RunModal / XmlPort.RunModal / UseRequestPage to the extracted-token list and one deterministic worklist cue with exclusions, so the article is selected from the RunModal call itself rather than only via a co-located Commit/Modify token. Codeunit.Run in that position is routed to its existing owner article. Co-Authored-By: Claude Fable 5.1 * Correct XmlPort.RunModal to Xmlport.Run; fix READ-convention sample links XmlPort has no RunModal method (static or instance) - Microsoft Learn confirms only Xmlport.Run(Integer [, Boolean RequestWindow] [, Boolean] [, var Record]). Replaces the invented API with the real one throughout the article, worklist cue, and token list, and explains the platform error message's own "XmlPort.RunModal" wording as the same kind of legacy phrasing already noted for Form.RunModal/RequestForm. Also corrects UseRequestPage(false): that's a Report instance method only, not applicable to XMLport, which uses the UseRequestPage = false object property or Run's RequestWindow argument instead. Fixes the two sample references to use the READ-convention markdown link form (Test-KnowledgeIndex.ps1's Knowledge-Retrieval.ps1 check was failing on plain backtick text). Rebased onto upstream/main to resolve conflicts with #148's job-queue additions to al-performance-review.md - both sets of worklist tokens and cues are retained. * Add Report.Run alongside Report.RunModal to the write-transaction guard routing al-performance-review.md's tokens/cues only recognized Report.RunModal, so a failing Modify(); Report.Run(..., true, ...) path was never worklisted even though Report.Run shares the exact same RequestWindow- blocking-dialog mechanism as Report.RunModal (they differ only in whether the report instance is cleared afterward). Added Report.Run to the token list and the targeted cue, with the same request-page- suppressed exclusion, and extended the knowledge article's Description bullet to name both methods explicitly instead of only RunModal. --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...s-limited-during-write-transactions.bad.al | 18 ++++++++ ...-limited-during-write-transactions.good.al | 18 ++++++++ ...thods-limited-during-write-transactions.md | 46 +++++++++++++++++++ .../avoid-user-prompts-inside-transactions.md | 2 +- .../skills/review/al-performance-review.md | 3 +- 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 microsoft/knowledge/performance/al-methods-limited-during-write-transactions.bad.al create mode 100644 microsoft/knowledge/performance/al-methods-limited-during-write-transactions.good.al create mode 100644 microsoft/knowledge/performance/al-methods-limited-during-write-transactions.md diff --git a/microsoft/knowledge/performance/al-methods-limited-during-write-transactions.bad.al b/microsoft/knowledge/performance/al-methods-limited-during-write-transactions.bad.al new file mode 100644 index 0000000..665520b --- /dev/null +++ b/microsoft/knowledge/performance/al-methods-limited-during-write-transactions.bad.al @@ -0,0 +1,18 @@ +codeunit 50258 "Perf Sample RunModalInTxn Bad" +{ + procedure ChangeShippingAgent(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + ShippingAgent: Record "Shipping Agent"; + begin + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + SalesHeader."Shipment Date" := WorkDate(); + SalesHeader.Modify(true); // a write transaction is now open + + // Runtime error: RunModal is not allowed in write transactions. + if Page.RunModal(Page::"Shipping Agents", ShippingAgent) = Action::LookupOK then begin + SalesHeader.Validate("Shipping Agent Code", ShippingAgent.Code); + SalesHeader.Modify(true); + end; + end; +} diff --git a/microsoft/knowledge/performance/al-methods-limited-during-write-transactions.good.al b/microsoft/knowledge/performance/al-methods-limited-during-write-transactions.good.al new file mode 100644 index 0000000..0fcfcb2 --- /dev/null +++ b/microsoft/knowledge/performance/al-methods-limited-during-write-transactions.good.al @@ -0,0 +1,18 @@ +codeunit 50259 "Perf Sample RunModalInTxn Good" +{ + procedure ChangeShippingAgent(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + ShippingAgent: Record "Shipping Agent"; + begin + // User interaction first, while no write transaction is open. + if Page.RunModal(Page::"Shipping Agents", ShippingAgent) <> Action::LookupOK then + exit; + + // All writes after the choice is made; the transaction ends with the trigger. + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + SalesHeader."Shipment Date" := WorkDate(); + SalesHeader.Validate("Shipping Agent Code", ShippingAgent.Code); + SalesHeader.Modify(true); + end; +} diff --git a/microsoft/knowledge/performance/al-methods-limited-during-write-transactions.md b/microsoft/knowledge/performance/al-methods-limited-during-write-transactions.md new file mode 100644 index 0000000..2bbedb3 --- /dev/null +++ b/microsoft/knowledge/performance/al-methods-limited-during-write-transactions.md @@ -0,0 +1,46 @@ +--- +bc-version: [all] +domain: performance +keywords: [limited-during-write-transactions, write-transaction, runmodal, report-run, page-runmodal, report-runmodal, xmlport-run, codeunit-run, commit, requestpage] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL methods limited during write transactions: commit before RunModal and Codeunit.Run + +## Description + +Once AL code has written to the database in the current transaction — an `Insert`, `Modify`, or `Delete` with no `Commit` since — the platform restricts four methods until that transaction is committed. The exact conditions, as enforced: + +- `Page.RunModal` — not allowed in a write transaction, under any circumstances. +- `Report.RunModal` and `Report.Run` — both allowed only if the request page is suppressed: `Report.RunModal(ReportId, false)` / `Report.Run(ReportId, false)` (the second argument is `RequestWindow` in both), or `UseRequestPage(false)` on a report instance. With a request page shown, either fails identically — `Run` and `RunModal` differ only in whether the report instance is cleared afterward, not in this guard. +- `Xmlport.Run` — same rule when it would show a request page: allowed only if the request page is suppressed, `Xmlport.Run(XmlPortId, false)` (the second argument is `RequestWindow`) or the `UseRequestPage = false;` object property. With a request page shown it fails. There is no `XmlPort.RunModal` method — see the note on the platform's error text below. +- `Codeunit.Run` — allowed only if its Boolean return value is not used. `OK := Codeunit.Run()` and `if Codeunit.Run() then` fail, because that form commits — see `codeunit-run-requires-prior-commit-inside-transaction.md`. + +This is a runtime error, not a compiler diagnostic: the code builds, and the first execution that reaches the call with an open write transaction dies. The guard keys on transaction state alone, not on any relation between what was written and what is opened: an `Item.Insert()` followed by `Page.RunModal(Page::"Customer Card")` — an unrelated table — fails on the `RunModal` line, and because the error stops the transaction, the insert rolls back with it. + +The platform's message (Business Central 26, reproduced 2026-09-07) reads: "The following AL methods are limited during write transactions because one or more tables will be locked: Form.RunModal, Codeunit.Run, Report.RunModal, XmlPort.RunModal. Form.RunModal is not allowed in write transactions. Codeunit.Run is allowed in write transactions only if the return value is not used. For example, 'OK := Codeunit.Run()' is not allowed. Report.RunModal is allowed in write transactions only if 'RequestForm = false'. For example, 'Report.RunModal(...,false)' is allowed. XmlPort.RunModal is allowed in write transactions only if 'RequestForm = false'. For example, 'XmlPort.RunModal(...,false)' is allowed. Use the commit method to save the changes before this call, or structure the code differently." The message still uses the legacy names `Form.RunModal` and `RequestForm` even though the AL method is `Page.RunModal` and the report parameter is `RequestWindow`; older versions said "C/AL functions" instead of "AL methods" (microsoft/AL#5452, 2019). The message also names `XmlPort.RunModal`, which is legacy phrasing too — there is no such AL method; the actual API the guard applies to is `Xmlport.Run`, and the message's `RequestForm = false` condition corresponds to `Xmlport.Run`'s `RequestWindow` argument or the `UseRequestPage` property. The behavior is unchanged across versions. + +The reason is the same one behind `avoid-user-prompts-inside-transactions.md`: a modal object waits for the user, and the platform will not let a write transaction — and every lock it holds — sit open for as long as that takes. The difference is enforcement. `Confirm` and `StrMenu` are *allowed* inside a write transaction and silently hold the locks; `RunModal` is *refused*. Both point at the same design fix. + +## Best Practice + +Sequence the work so the modal interaction happens before the write phase: run the lookup or dialog page first, then perform the writes the user's choice requires, and let the transaction end. When a modal object genuinely must follow a write, `Commit()` first — but only when the state written so far is complete and safe to persist on its own, because that Commit is a real transaction boundary, not a formality. Microsoft's own Base Application follows exactly this pattern where the preceding state is final (`ActivityLog.Table.al` commits the log entry before `Page.RunModal(Page::"Activity Log", Rec)`; `DocumentSendingProfile.Table.al` commits before `Page.RunModal(Page::"Select Sending Options", …)`). For a report, suppressing the request page — `Report.UseRequestPage(false)` on a report instance, or `false` as the `RequestWindow` argument to `Run`/`RunModal` — is a legitimate way to run it inside a write transaction when no user input is needed. For an XMLport, the equivalent is `false` as the `RequestWindow` argument to `Xmlport.Run`, or the `UseRequestPage = false;` object property; XMLports have no instance `UseRequestPage` method. `Database.IsInWriteTransaction()` (runtime 11.0+) lets library code that cannot control its caller detect the state, with the same caveat as the Codeunit.Run article: branching production flow on it usually signals unclear transaction ownership. + +See sample: [`al-methods-limited-during-write-transactions.good.al`](al-methods-limited-during-write-transactions.good.al). + +## Anti Pattern + +Writing to the database and then calling `Page.RunModal` (or a report/XMLport with its request page) in the same trigger — the first production run hits the runtime error. The reflexive fixes are worse than the error: dropping in `Commit()` to silence it persists a half-finished state that can no longer roll back with the rest of the operation, and swapping the page for a `Confirm` or `StrMenu` to "avoid the error" trades a loud failure for the silent lock-holding that `avoid-user-prompts-inside-transactions.md` warns about. + +See sample: [`al-methods-limited-during-write-transactions.bad.al`](al-methods-limited-during-write-transactions.bad.al). + +## Source + +- Microsoft Learn, `Codeunit.Run` transaction semantics ("If you're already in a transaction you must commit first before calling Codeunit.Run"): https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/codeunit/codeunit-run-method +- Microsoft Learn, `Xmlport.Run(Integer [, Boolean] [, Boolean] [, var Record])` (the `RequestWindow` argument; confirms the XMLport data type has no `RunModal` method, static or instance): https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/xmlport/xmlport-run-method +- Microsoft Learn, `UseRequestPage` property ("Applies to: Xml Port, Report" — an object property, not an XMLport instance method; the instance method of the same name exists only on `Report`): https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-userequestpage-property +- microsoft/AL issue #5452 (verbatim English error text, reproduced on "any current version of Business Central", 2019-11-13): https://github.com/microsoft/AL/issues/5452 +- Reproduced 2026-09-07 on Business Central 26 (runtime error dialog, English and Danish clients): a page `OnAction` doing `Item.Insert()` then `Page.RunModal(Page::"Customer Card")` fails with the text quoted in the Description, the AL call stack pointing at the `RunModal` line, and the dialog stating that the transaction was stopped. +- Microsoft Base Application (BCApps, W1): the `Commit(); … Page.RunModal(…)` pattern in `Modules/System/Logging/ActivityLog.Table.al`, `Foundation/Reporting/DocumentSendingProfile.Table.al`, `Bank/Setup/PaymentServiceSetup.Table.al`, and others; BCApps test suites annotate the same guard as "COMMIT is required for Write Transaction Error" (`Tests/General Journal/ERMTestMultipleGenJnlLines.Codeunit.al`). diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md index bb03f41..f871c98 100644 --- a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md @@ -11,7 +11,7 @@ application-area: [all] ## Description -A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a write transaction stalls the transaction — and therefore every lock it holds — until the user responds. Per the upstream guidance, "Avoid user interactions (Confirm, StrMenu) inside transactions — they hold locks while waiting for user input." The wait is bounded only by the user; meanwhile other sessions block on whatever this transaction has acquired. +A `Confirm`, `StrMenu`, or other user prompt issued from inside a write transaction stalls the transaction — and therefore every lock it holds — until the user responds. Per the upstream guidance, "Avoid user interactions (Confirm, StrMenu) inside transactions — they hold locks while waiting for user input." The wait is bounded only by the user; meanwhile other sessions block on whatever this transaction has acquired. ## Best Practice diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 6109f7b..cfaf9f7 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-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 tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration. - The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation. -- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`). +- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`, `Page.RunModal`, `Report.RunModal`, `Report.Run`, `Xmlport.Run`, `UseRequestPage`). 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. @@ -58,6 +58,7 @@ Apply these targeted cues even when simple token overlap would rank the article - Worklist `job-queue-on-hold-does-not-stop-running-work.md` when a running job queue handler polls the entry's `Status` or `On Hold` value as a cancellation signal. Exclude application-owned stop requests that are checked before every bounded unit of work, including the first, when completed work and its checkpoint remain consistent and resume logic clears the request. - Worklist `job-queue-category-code-serializes-conflicting-jobs.md` when two or more job queue entries in the same company are shown by the changed context to require mutual exclusion but have empty or different Job Queue Category Codes. Do not infer a conflict merely because jobs touch the same tables, and do not recommend a category to coordinate across companies, environments, or workers outside the job queue dispatcher. - Worklist `store-scheduled-task-id-to-avoid-duplicate-tasks.md` when `TaskScheduler.CreateTask` runs from initialization, login, setup, or another repeatable path without persisting its returned GUID and checking it with `TaskScheduler.TaskExists` before creating a replacement. Exclude one-shot creation and correctly persisted check-before-create flows; concurrent callers still require serialization around that sequence. +- Worklist `al-methods-limited-during-write-transactions.md` when `Page.RunModal` follows an `Insert`, `Modify`, or `Delete` in the same trigger or procedure with no intervening `Commit`; or when `Report.RunModal`/`Report.Run` without `false` as its `RequestWindow` argument and without `UseRequestPage(false)` follows one; or when `Xmlport.Run` without `false` as its `RequestWindow` argument and without the `UseRequestPage = false` object property follows one. Do not worklist it from a call that precedes every write, from a report run with its request page suppressed via `UseRequestPage(false)`/`Run(...,false)`/`RunModal(...,false)`, or from an XMLport run with its request page suppressed via the `RequestWindow` argument or the `UseRequestPage` property. A `Codeunit.Run` whose return value is used in that position belongs to `codeunit-run-requires-prior-commit-inside-transaction.md`. - A new or changed report object whose usage/name/caption identifies it as a single-record document (invoice, statement, order confirmation) sets or retains `DefaultRenderingLayout = RDLC` — `document-report-word-layout.md`. Do not worklist this from a tabular/list report with heavy aggregation or calculated columns; RDLC/Excel remains the better fit there. These targeted inclusions and exclusions override generic token overlap. Do not retain an excluded article solely because the diff contains one of its keywords. From 87ba36e650dde6a1cd4c1bd499303cbde3a6a47f Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 29 Sep 2026 17:21:28 +0200 Subject: [PATCH 37/51] Add BC performance knowledge from OptimAL learnings (#198) * Add BC performance knowledge from OptimAL learnings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address review feedback on performance guidance Clarify predicate-supporting keys versus covering queries, demonstrate proven cache reuse, and evaluate the updated partial-load and bulk-update rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- evaluation/review-fixtures.json | 17 +++- ...ore-persisting-intermediate-results.bad.al | 73 +++++++++++++++ ...re-persisting-intermediate-results.good.al | 93 +++++++++++++++++++ ...-before-persisting-intermediate-results.md | 28 ++++++ .../avoid-get-inside-loop-on-large-table.md | 4 +- ...void-repeating-unchanged-validation.bad.al | 39 ++++++++ ...oid-repeating-unchanged-validation.good.al | 38 ++++++++ .../avoid-repeating-unchanged-validation.md | 28 ++++++ ...iltered-results-with-explicit-scope.bad.al | 24 +++++ ...ltered-results-with-explicit-scope.good.al | 48 ++++++++++ ...ed-filtered-results-with-explicit-scope.md | 27 ++++++ ...csums-instead-of-calcfields-in-loop.bad.al | 7 +- ...sums-instead-of-calcfields-in-loop.good.al | 7 +- .../calcsums-instead-of-calcfields-in-loop.md | 15 ++- ...ign-covering-keys-from-read-pattern.bad.al | 38 ++++++++ ...gn-covering-keys-from-read-pattern.good.al | 41 ++++++++ .../design-covering-keys-from-read-pattern.md | 30 ++++++ ...mpty-before-findset-is-extra-round-trip.md | 4 +- ...efer-modifyall-over-per-row-modify.good.al | 1 + .../prefer-modifyall-over-per-row-modify.md | 2 +- ...-inserts-by-separating-target-reads.bad.al | 61 ++++++++++++ ...inserts-by-separating-target-reads.good.al | 56 +++++++++++ ...ered-inserts-by-separating-target-reads.md | 27 ++++++ ...y-key-get-in-loop-is-transaction-cached.md | 6 +- .../query-results-bypass-primary-key-cache.md | 2 +- ...lapping-keys-before-adding-an-index.bad.al | 43 +++++++++ ...apping-keys-before-adding-an-index.good.al | 43 +++++++++ ...overlapping-keys-before-adding-an-index.md | 29 ++++++ ...rrentkey-sets-sort-order-not-index-hint.md | 8 +- ...porary-tables-have-no-database-cost.bad.al | 39 ++++++++ ...orary-tables-have-no-database-cost.good.al | 39 ++++++++ .../temporary-tables-have-no-database-cost.md | 6 +- ...etautocalcfields-for-per-row-flowfields.md | 4 +- ...e-setloadfields-for-partial-records.bad.al | 10 +- ...-setloadfields-for-partial-records.good.al | 11 ++- .../use-setloadfields-for-partial-records.md | 2 +- .../skills/review/al-performance-review.md | 7 +- 37 files changed, 920 insertions(+), 37 deletions(-) create mode 100644 microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.bad.al create mode 100644 microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.good.al create mode 100644 microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.md create mode 100644 microsoft/knowledge/performance/avoid-repeating-unchanged-validation.bad.al create mode 100644 microsoft/knowledge/performance/avoid-repeating-unchanged-validation.good.al create mode 100644 microsoft/knowledge/performance/avoid-repeating-unchanged-validation.md create mode 100644 microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.bad.al create mode 100644 microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.good.al create mode 100644 microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.md create mode 100644 microsoft/knowledge/performance/design-covering-keys-from-read-pattern.bad.al create mode 100644 microsoft/knowledge/performance/design-covering-keys-from-read-pattern.good.al create mode 100644 microsoft/knowledge/performance/design-covering-keys-from-read-pattern.md create mode 100644 microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.bad.al create mode 100644 microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.good.al create mode 100644 microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.md create mode 100644 microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.bad.al create mode 100644 microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.good.al create mode 100644 microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.md create mode 100644 microsoft/knowledge/performance/temporary-tables-have-no-database-cost.bad.al create mode 100644 microsoft/knowledge/performance/temporary-tables-have-no-database-cost.good.al diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 90fc7d1..7732e7e 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -39,7 +39,22 @@ "job-queue-handlers-must-not-require-ui", "job-queue-handlers-must-propagate-failures", "job-queue-on-hold-does-not-stop-running-work", - "store-scheduled-task-id-to-avoid-duplicate-tasks" + "store-scheduled-task-id-to-avoid-duplicate-tasks", + "design-covering-keys-from-read-pattern", + "review-overlapping-keys-before-adding-an-index", + "setcurrentkey-sets-sort-order-not-index-hint", + "preserve-buffered-inserts-by-separating-target-reads", + "aggregate-before-persisting-intermediate-results", + "cache-repeated-filtered-results-with-explicit-scope", + "avoid-repeating-unchanged-validation", + "avoid-get-inside-loop-on-large-table", + "isempty-before-findset-is-extra-round-trip", + "query-results-bypass-primary-key-cache", + "calcsums-instead-of-calcfields-in-loop", + "use-setautocalcfields-for-per-row-flowfields", + "temporary-tables-have-no-database-cost", + "use-setloadfields-for-partial-records", + "prefer-modifyall-over-per-row-modify" ] }, "privacy": { diff --git a/microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.bad.al b/microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.bad.al new file mode 100644 index 0000000..9e3de05 --- /dev/null +++ b/microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.bad.al @@ -0,0 +1,73 @@ +// Input stays stable for this run; output contains one row per group with entries. +table 50367 "Perf Posted Entry" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Item No."; Code[20]) { } + field(3; "Location Code"; Code[10]) { } + field(4; "Posting Date"; Date) { } + field(5; Quantity; Decimal) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByItemLocationDate; "Item No.", "Location Code", "Posting Date") + { + SumIndexFields = Quantity; + } + } +} + +table 50368 "Perf Quantity Summary" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Run ID"; Guid) { } + field(2; "Item No."; Code[20]) { } + field(3; "Location Code"; Code[10]) { } + field(4; Quantity; Decimal) { } + } + + keys + { + key(PK; "Run ID", "Item No.", "Location Code") { Clustered = true; } + } +} + +codeunit 50369 "Perf Summary Bad" +{ + procedure BuildSummary(CutoffDate: Date) RunId: Guid + var + Entry: Record "Perf Posted Entry"; + Scan: Record "Perf Posted Entry"; + Summary: Record "Perf Quantity Summary"; + begin + RunId := CreateGuid(); + Entry.SetFilter("Posting Date", '..%1', CutoffDate); + if Entry.FindSet() then + repeat + Scan.SetRange("Item No.", Entry."Item No."); + Scan.SetRange("Location Code", Entry."Location Code"); + Scan.SetFilter("Posting Date", '..%1', CutoffDate); + Scan.CalcSums(Quantity); + + if Summary.Get(RunId, Entry."Item No.", Entry."Location Code") then begin + Summary.Quantity := Scan.Quantity; + Summary.Modify(false); + end else begin + Summary.Init(); + Summary."Run ID" := RunId; + Summary."Item No." := Entry."Item No."; + Summary."Location Code" := Entry."Location Code"; + Summary.Quantity := Scan.Quantity; + Summary.Insert(false); + end; + until Entry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.good.al b/microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.good.al new file mode 100644 index 0000000..c94e970 --- /dev/null +++ b/microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.good.al @@ -0,0 +1,93 @@ +// Input stays stable for this run; output contains one row per group with entries. +table 50367 "Perf Posted Entry" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Item No."; Code[20]) { } + field(3; "Location Code"; Code[10]) { } + field(4; "Posting Date"; Date) { } + field(5; Quantity; Decimal) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByItemLocationDate; "Item No.", "Location Code", "Posting Date") + { + SumIndexFields = Quantity; + } + } +} + +table 50368 "Perf Quantity Summary" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Run ID"; Guid) { } + field(2; "Item No."; Code[20]) { } + field(3; "Location Code"; Code[10]) { } + field(4; Quantity; Decimal) { } + } + + keys + { + key(PK; "Run ID", "Item No.", "Location Code") { Clustered = true; } + } +} + +query 50370 "Perf Grouped Quantity" +{ + QueryType = Normal; + + elements + { + dataitem(Entry; "Perf Posted Entry") + { + column(ItemNo; "Item No.") { } + column(LocationCode; "Location Code") { } + column(TotalQuantity; Quantity) + { + Method = Sum; + } + filter(PostingDate; "Posting Date") { } + } + } +} + +codeunit 50369 "Perf Summary Good" +{ + procedure BuildSummary(CutoffDate: Date) RunId: Guid + var + Totals: Query "Perf Grouped Quantity"; + TempSummary: Record "Perf Quantity Summary" temporary; + Summary: Record "Perf Quantity Summary"; + begin + RunId := CreateGuid(); + Totals.SetFilter(PostingDate, '..%1', CutoffDate); + Totals.Open(); + while Totals.Read() do begin + TempSummary.Init(); + TempSummary."Run ID" := RunId; + TempSummary."Item No." := Totals.ItemNo; + TempSummary."Location Code" := Totals.LocationCode; + TempSummary.Quantity := Totals.TotalQuantity; + TempSummary.Insert(); + end; + Totals.Close(); + + if TempSummary.FindSet() then + repeat + Summary.Init(); + Summary."Run ID" := RunId; + Summary."Item No." := TempSummary."Item No."; + Summary."Location Code" := TempSummary."Location Code"; + Summary.Quantity := TempSummary.Quantity; + Summary.Insert(false); + until TempSummary.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.md b/microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.md new file mode 100644 index 0000000..bdff7ca --- /dev/null +++ b/microsoft/knowledge/performance/aggregate-before-persisting-intermediate-results.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [query, grouping, aggregate, intermediate-results, temporary-buffer, persistent-writes, calcsums, modify, method-sum] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Aggregate source rows before persisting completed results + +## Description + +A batch may repeatedly calculate the same group total while inserting and updating persistent working rows, even though only one completed result per group is needed. An AL Query with `Method = Sum` groups by its other output columns, so the required grain can often be computed from qualifying source rows before writing final output. This is a data-path change, not a blanket replacement for posting or for intermediate rows needed for recovery or business behavior. + +## Best Practice + +Specify the output grain and whether absent groups should produce zero rows. Apply source filters (including cutoff dates), aggregate at exactly that grain, and write only completed results; a small temporary buffer can separate the query from persistent output, but streaming or bounded units may be better for large grouped sets. Avoid a one-to-many join that duplicates quantities or an extra output column that silently changes the grouping. Preserve security filters, signs, units, FlowFilters, relevant master-data restrictions, transaction/trigger behavior, and acceptable read consistency; a shared date cutoff does not create a snapshot. Compare complete keyed outputs and measure source reads, repeated calculations, temporary memory, and persistent writes. See sample: [`aggregate-before-persisting-intermediate-results.good.al`](aggregate-before-persisting-intermediate-results.good.al). + +## Anti Pattern + +For an output that only needs one signed total per item/location with qualifying entries, summing the same source range and rewriting a persistent summary for *every* entry. Do not flag incremental persisted state that is required for locking, resumability, or downstream processing, or require an in-memory copy of all raw history to perform SQL grouping. See sample: [`aggregate-before-persisting-intermediate-results.bad.al`](aggregate-before-persisting-intermediate-results.bad.al). + +## References + +- [Aggregating data in query objects](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-totals-grouping). +- [Query performance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-query-objects-and-performance). +- [Buffered inserts](preserve-buffered-inserts-by-separating-target-reads.md). diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md index db4f5e1..08c73a9 100644 --- a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: performance -keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table] +keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table, item-get, query] technologies: [al] countries: [w1] application-area: [all] @@ -15,7 +15,7 @@ A `Get` or `FindFirst` against another persistent table inside a loop can produc ## Best Practice -Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If keys repeat, a dictionary cache can reduce lookups to one per distinct key. `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so. +Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If complete lookup keys repeat and the result remains valid, a [scoped cache](cache-repeated-filtered-results-with-explicit-scope.md) can reduce lookups to one per distinct key; do not assume primary-key `Get` calls each hit SQL (see [transaction caching](primary-key-get-in-loop-is-transaction-cached.md)). `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so. See sample: [`avoid-get-inside-loop-on-large-table.good.al`](avoid-get-inside-loop-on-large-table.good.al). diff --git a/microsoft/knowledge/performance/avoid-repeating-unchanged-validation.bad.al b/microsoft/knowledge/performance/avoid-repeating-unchanged-validation.bad.al new file mode 100644 index 0000000..d3bc03b --- /dev/null +++ b/microsoft/knowledge/performance/avoid-repeating-unchanged-validation.bad.al @@ -0,0 +1,39 @@ +// Quantity validation depends only on Quantity and Unit Price in this demo. +table 50371 "Perf Validated Line" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Line No."; Integer) { } + field(2; Quantity; Decimal) + { + trigger OnValidate() + begin + Amount := Quantity * "Unit Price"; + end; + } + field(3; "Unit Price"; Decimal) { } + field(4; Amount; Decimal) { } + field(5; Note; Text[100]) { } + } + + keys + { + key(PK; "Line No.") { Clustered = true; } + } +} + +codeunit 50372 "Perf Validation Bad" +{ + procedure UpdateLine(LineNo: Integer; NewQuantity: Decimal; NewNote: Text[100]) + var + Line: Record "Perf Validated Line"; + begin + Line.Get(LineNo); + Line.Validate(Quantity, NewQuantity); + Line.Note := NewNote; + Line.Validate(Quantity, NewQuantity); + Line.Modify(false); + end; +} diff --git a/microsoft/knowledge/performance/avoid-repeating-unchanged-validation.good.al b/microsoft/knowledge/performance/avoid-repeating-unchanged-validation.good.al new file mode 100644 index 0000000..de03f3a --- /dev/null +++ b/microsoft/knowledge/performance/avoid-repeating-unchanged-validation.good.al @@ -0,0 +1,38 @@ +// Quantity validation depends only on Quantity and Unit Price in this demo. +table 50371 "Perf Validated Line" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Line No."; Integer) { } + field(2; Quantity; Decimal) + { + trigger OnValidate() + begin + Amount := Quantity * "Unit Price"; + end; + } + field(3; "Unit Price"; Decimal) { } + field(4; Amount; Decimal) { } + field(5; Note; Text[100]) { } + } + + keys + { + key(PK; "Line No.") { Clustered = true; } + } +} + +codeunit 50372 "Perf Validation Good" +{ + procedure UpdateLine(LineNo: Integer; NewQuantity: Decimal; NewNote: Text[100]) + var + Line: Record "Perf Validated Line"; + begin + Line.Get(LineNo); + Line.Validate(Quantity, NewQuantity); + Line.Note := NewNote; + Line.Modify(false); + end; +} diff --git a/microsoft/knowledge/performance/avoid-repeating-unchanged-validation.md b/microsoft/knowledge/performance/avoid-repeating-unchanged-validation.md new file mode 100644 index 0000000..96325b3 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-repeating-unchanged-validation.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [validate, onvalidate, repeated-write, sales-line, subscriber, unchanged-value] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid repeating validation when its inputs have not changed + +## Description + +`Validate` runs field validation logic, which can invoke subscribers and additional reads or writes even if the assigned value is unchanged. When a document-building path validates the same field twice without changing any input its validation depends on, the second cascade may do the same work again. A field-value comparison alone does not prove that the dependent context or required event behavior is unchanged. + +## Best Practice + +Trace the table's `OnValidate`, subscribers, and dependent fields before removing a duplicate invocation. Keep the required validation and write, but skip a second `Validate` only when all its inputs, its order-dependent effects, and the business contract are demonstrably unchanged. Measure validations and writes per business unit and test pricing, reservations, error behavior, and subscriber effects on real document paths. The sample's custom field validation depends only on Quantity and Unit Price; a note assigned between calls does not affect either. See sample: [`avoid-repeating-unchanged-validation.good.al`](avoid-repeating-unchanged-validation.good.al). + +## Anti Pattern + +Validating Quantity, changing only an unrelated local or record note, validating the same Quantity again, then modifying the row, without any required second event. Do not replace `Validate` with direct assignment, use `Insert(false)` indiscriminately, or reorder validations on a Sales Line solely to reduce call counts: its standard logic can depend on other fields and event subscribers. See sample: [`avoid-repeating-unchanged-validation.bad.al`](avoid-repeating-unchanged-validation.bad.al). + +## References + +- [Record.Validate and field validation behavior](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-validate-method). +- [Equivalent bulk assignments versus per-row validation](prefer-modifyall-over-per-row-modify.md). +- [Subscriber guards](guard-event-subscribers-before-db-call.md). diff --git a/microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.bad.al b/microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.bad.al new file mode 100644 index 0000000..e200297 --- /dev/null +++ b/microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.bad.al @@ -0,0 +1,24 @@ +codeunit 50363 "Perf Variant Cache Bad" +{ + procedure CountAndCollectVariantLines(var TempSalesLine: Record "Sales Line" temporary; OrderNo: Code[20]; var VariantItemNos: List of [Code[20]]) VariantLines: Integer + var + ItemVariant: Record "Item Variant"; + begin + TempSalesLine.SetRange("Document Type", TempSalesLine."Document Type"::Order); + TempSalesLine.SetRange("Document No.", OrderNo); + TempSalesLine.SetRange(Type, TempSalesLine.Type::Item); + if TempSalesLine.FindSet() then + repeat + ItemVariant.SetRange("Item No.", TempSalesLine."No."); + if not ItemVariant.IsEmpty() then + VariantLines += 1; + until TempSalesLine.Next() = 0; + + if TempSalesLine.FindSet() then + repeat + ItemVariant.SetRange("Item No.", TempSalesLine."No."); + if not ItemVariant.IsEmpty() then + VariantItemNos.Add(TempSalesLine."No."); + until TempSalesLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.good.al b/microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.good.al new file mode 100644 index 0000000..d5db5d6 --- /dev/null +++ b/microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.good.al @@ -0,0 +1,48 @@ +codeunit 50363 "Perf Variant Cache Good" +{ + procedure CountAndCollectVariantLines(var TempSalesLine: Record "Sales Line" temporary; OrderNo: Code[20]; var VariantItemNos: List of [Code[20]]) VariantLines: Integer + var + ItemVariant: Record "Item Variant"; + HasVariantsByItem: Dictionary of [Code[20], Boolean]; + begin + TempSalesLine.SetRange("Document Type", TempSalesLine."Document Type"::Order); + TempSalesLine.SetRange("Document No.", OrderNo); + TempSalesLine.SetRange(Type, TempSalesLine.Type::Item); + if TempSalesLine.FindSet() then + repeat + if HasVariants(TempSalesLine."No.", ItemVariant, HasVariantsByItem) then + VariantLines += 1; + until TempSalesLine.Next() = 0; + + if TempSalesLine.FindSet() then + repeat + if HasVariants(TempSalesLine."No.", ItemVariant, HasVariantsByItem) then + VariantItemNos.Add(TempSalesLine."No."); + until TempSalesLine.Next() = 0; + end; + + local procedure HasVariants(ItemNo: Code[20]; var ItemVariant: Record "Item Variant"; var HasVariantsByItem: Dictionary of [Code[20], Boolean]): Boolean + var + CachedResult: Boolean; + begin + if HasVariantsByItem.Get(ItemNo, CachedResult) then + exit(CachedResult); + + ItemVariant.SetRange("Item No.", ItemNo); + CachedResult := not ItemVariant.IsEmpty(); + HasVariantsByItem.Add(ItemNo, CachedResult); + exit(CachedResult); + end; + + procedure CountDistinctItemsWithVariants(var TempItems: Record Item temporary) VariantItems: Integer + var + ItemVariant: Record "Item Variant"; + begin + if TempItems.FindSet() then + repeat + ItemVariant.SetRange("Item No.", TempItems."No."); + if not ItemVariant.IsEmpty() then + VariantItems += 1; + until TempItems.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.md b/microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.md new file mode 100644 index 0000000..b85bff0 --- /dev/null +++ b/microsoft/knowledge/performance/cache-repeated-filtered-results-with-explicit-scope.md @@ -0,0 +1,27 @@ +--- +bc-version: [all] +domain: performance +keywords: [cache, dictionary, filtered-lookup, isempty, repeated-query, invalidation, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reuse repeated filtered results within a correct cache scope + +## Description + +A loop can ask the same filtered existence or calculation question for many rows sharing a business key, or two phases can ask it for the same rows. Unlike repeated primary-key `Get` calls, non-keyed filtered lookups are not automatically answered by the primary-key record cache. Memoization can remove repeated AL and data-access work, but a cache keyed by too few inputs or kept past a data change returns the wrong answer. A single pass over lines with unknown, possibly distinct item numbers does not establish reuse. + +## Best Practice + +First reuse an already-loaded result if valid. Require evidence that complete lookup keys actually repeat, such as repeated queries for the same item in two passes over an unchanged line set (as in the sample), or measured cache hits. For a repeated, stable filtered lookup, keep a local dictionary for one operation, keyed by every input that affects the result (including company, filters, date, unit, currency, and quantity where applicable). Cache negative results as well as positive ones; distinguish a missing dictionary entry from an entry whose value is `false`. If underlying records can change during the run, update or invalidate the entry, or do not cache it. Bound entries or process in chunks when key cardinality is large. Check distinct complete keys, hits/misses, SQL work, AL time, and memory before adding a cache to a low-reuse workload. Use a temporary table for record-shaped values or multiple keys. See sample: [`cache-repeated-filtered-results-with-explicit-scope.good.al`](cache-repeated-filtered-results-with-explicit-scope.good.al). + +## Anti Pattern + +Running the same filtered `IsEmpty` in two passes over the same unchanged lines (even when every item number is distinct within a pass), or caching a price by item alone when customer, variant, date, and quantity affect it. Do **not** infer a cache opportunity from a single pass with no established key reuse, such as visiting each distinct Item once; the [good sample](cache-repeated-filtered-results-with-explicit-scope.good.al) also shows this valid direct lookup. Do not equate each `Get` with a SQL round trip or automatically wrap a cached primary-key read in another dictionary; see [primary-key cache exceptions](primary-key-get-in-loop-is-transaction-cached.md). See sample: [`cache-repeated-filtered-results-with-explicit-scope.bad.al`](cache-repeated-filtered-results-with-explicit-scope.bad.al). + +## References + +- [Data access and caching](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-data-access). +- [Dictionary type and `Get`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/dictionary/dictionary-data-type). diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al index 48318a9..8153e70 100644 --- a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al @@ -1,15 +1,14 @@ codeunit 50223 "Perf Sample CalcSums Bad" { - procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + procedure TotalSales(CustomerNo: Code[20]) Total: Decimal var CustLedgerEntry: Record "Cust. Ledger Entry"; begin + CustLedgerEntry.SetCurrentKey("Customer No."); CustLedgerEntry.SetRange("Customer No.", CustomerNo); - // One SQL query per row over a 10M-row ledger. if CustLedgerEntry.FindSet() then repeat - CustLedgerEntry.CalcFields("Remaining Amount"); - Total += CustLedgerEntry."Remaining Amount"; + Total += CustLedgerEntry."Sales (LCY)"; until CustLedgerEntry.Next() = 0; end; } diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al index 2e1f367..2e41049 100644 --- a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al @@ -1,11 +1,12 @@ codeunit 50222 "Perf Sample CalcSums Good" { - procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + procedure TotalSales(CustomerNo: Code[20]) Total: Decimal var CustLedgerEntry: Record "Cust. Ledger Entry"; begin + CustLedgerEntry.SetCurrentKey("Customer No."); CustLedgerEntry.SetRange("Customer No.", CustomerNo); - CustLedgerEntry.CalcSums("Remaining Amount"); - Total := CustLedgerEntry."Remaining Amount"; + CustLedgerEntry.CalcSums("Sales (LCY)"); + Total := CustLedgerEntry."Sales (LCY)"; end; } diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md index f4d7ad9..fff5e25 100644 --- a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md @@ -1,26 +1,31 @@ --- bc-version: [all] domain: performance -keywords: [calcfields, calcsums, loop, flowfield, n-plus-one, aggregation] +keywords: [calcfields, calcsums, loop, flowfield, source-field, sumindexfields, aggregation] technologies: [al] countries: [w1] application-area: [all] --- -# Use CalcSums to aggregate, not CalcFields inside a loop +# Use CalcSums for stored-field totals, not as a shortcut for FlowFields ## Description -`CalcFields` materializes FlowField values for one record. Each call against a persistent table is "a separate SQL query"; running it inside a `repeat ... until Next() = 0` over a large table issues one query per row on top of the iteration itself. `CalcSums` answers the same aggregation question — "give me the sum of this FlowField over the filtered set" — as a single SQL statement. Per the upstream guidance, `CalcFields` inside loops on large persistent tables is "a performance problem"; the aggregation form is `CalcSums()`. +`CalcFields` evaluates a FlowField for one record; `CalcSums` totals stored numeric fields in a filtered source table. They do not generally answer the same question. A loop that adds a normal source field for one total can often use one `CalcSums`, possibly backed by a compatible SIFT index. A loop that adds calculated FlowFields cannot be replaced with `CalcSums` on those FlowFields: each `CalcFormula` may depend on its parent record, FlowFilters, and the selected parent set. `CalcFields` requests can also use a recent calculation cache, so source-level call counts are not SQL statement counts. ## Best Practice -When the procedure totals a FlowField (or several) across a filtered set, set the filters, then call `CalcSums("Field 1", "Field 2", ...)`. The platform issues one query; the result is read off the record's FlowField slot. Single `CalcFields` outside loops is fine, and `CalcFields` on the current row in a page's `OnAfterGetRecord` or in `OnValidate` is the standard pattern — those are per-action, not per-row over a large set. +When only one total over stored source fields is needed, set the source-table filters and call `CalcSums` on those fields; select an appropriate current key with `SumIndexFields` when relying on SIFT, and measure the read/write trade-off. If the inputs are FlowFields, derive any proposed source aggregation from their `CalcFormula`, including the selected parent set and FlowFilters, and verify equivalent results before replacing the loop. When every row needs its own FlowField value, [use `SetAutoCalcFields`](use-setautocalcfields-for-per-row-flowfields.md) where appropriate rather than replacing row values with one total. See [SIFT trade-offs](choose-maintainsiftindex-by-read-write-ratio.md). See sample: [`calcsums-instead-of-calcfields-in-loop.good.al`](calcsums-instead-of-calcfields-in-loop.good.al). ## Anti Pattern -`if CustLedgerEntry.FindSet() then repeat CustLedgerEntry.CalcFields("Remaining Amount"); Total += CustLedgerEntry."Remaining Amount"; until CustLedgerEntry.Next() = 0;` — exactly the upstream-flagged shape. The iteration is the cheap part; the per-row `CalcFields` is what scales linearly with table size. +Looping over filtered `Cust. Ledger Entry` records and adding the stored `"Sales (LCY)"` field when the only output is its total. The opposite mistake is proposing `Customer.CalcSums(Balance)` as a generic replacement for adding selected customers' FlowField balances; that changes or fails to express the required calculation. See sample: [`calcsums-instead-of-calcfields-in-loop.bad.al`](calcsums-instead-of-calcfields-in-loop.bad.al). + +## References + +- [CalcFields and CalcSums operate on different field calculations](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-calcfields-calcsums-fielderror-fieldname-init-testfield-and-validate-methods). +- [Record.CalcSums](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-calcsums-method). diff --git a/microsoft/knowledge/performance/design-covering-keys-from-read-pattern.bad.al b/microsoft/knowledge/performance/design-covering-keys-from-read-pattern.bad.al new file mode 100644 index 0000000..7358289 --- /dev/null +++ b/microsoft/knowledge/performance/design-covering-keys-from-read-pattern.bad.al @@ -0,0 +1,38 @@ +table 50360 "Perf Read Entry" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Customer No."; Code[20]) { } + field(3; "Posting Date"; Date) { } + field(4; "Item No."; Code[20]) { } + field(5; Quantity; Decimal) { } + field(6; Description; Text[100]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByDescription; Description, "Item No.", "Posting Date") { } + key(ByQuantity; Quantity, "Customer No.") { } + } +} + +codeunit 50361 "Perf Read Entry Bad" +{ + procedure SumNonblankItems(CustomerNo: Code[20]; FromDate: Date; ToDate: Date) Total: Decimal + var + Entry: Record "Perf Read Entry"; + begin + Entry.SetRange("Customer No.", CustomerNo); + Entry.SetRange("Posting Date", FromDate, ToDate); + Entry.SetLoadFields("Item No.", Quantity); + if Entry.FindSet() then + repeat + if Entry."Item No." <> '' then + Total += Entry.Quantity; + until Entry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/design-covering-keys-from-read-pattern.good.al b/microsoft/knowledge/performance/design-covering-keys-from-read-pattern.good.al new file mode 100644 index 0000000..17ea82d --- /dev/null +++ b/microsoft/knowledge/performance/design-covering-keys-from-read-pattern.good.al @@ -0,0 +1,41 @@ +table 50360 "Perf Read Entry" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Customer No."; Code[20]) { } + field(3; "Posting Date"; Date) { } + field(4; "Item No."; Code[20]) { } + field(5; Quantity; Decimal) { } + field(6; Description; Text[100]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByCustomerDate; "Customer No.", "Posting Date") + { + // Supports the filters and explicit payload; implicit system fields may still require lookups. + IncludedFields = "Item No.", Quantity; + } + } +} + +codeunit 50361 "Perf Read Entry Good" +{ + procedure SumNonblankItems(CustomerNo: Code[20]; FromDate: Date; ToDate: Date) Total: Decimal + var + Entry: Record "Perf Read Entry"; + begin + Entry.SetRange("Customer No.", CustomerNo); + Entry.SetRange("Posting Date", FromDate, ToDate); + Entry.SetLoadFields("Item No.", Quantity); + if Entry.FindSet() then + repeat + if Entry."Item No." <> '' then + Total += Entry.Quantity; + until Entry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/design-covering-keys-from-read-pattern.md b/microsoft/knowledge/performance/design-covering-keys-from-read-pattern.md new file mode 100644 index 0000000..0650086 --- /dev/null +++ b/microsoft/knowledge/performance/design-covering-keys-from-read-pattern.md @@ -0,0 +1,30 @@ +--- +bc-version: [19..] +domain: performance +keywords: [includedfields, covering-index, secondary-key, filter, projection, selectivity, key, setrange] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Design a read-pattern key and verify whether it covers the query + +## Description + +A secondary key should serve a particular read, not a list of fields that happen to look important. The order of key fields affects which filters and sort orders it can support; `IncludedFields` supplies non-key payload columns without making them ordered key columns. `SetCurrentKey` sets an order, not an index hint (see [sort guidance](setcurrentkey-sets-sort-order-not-index-hint.md)). A key that supports the predicates is not necessarily a covering index, and coverage alone does not make a query selective. + +## Best Practice + +For a costly, frequent read, establish its equality and range filters, joins, required ordering, actual SQL projection, cardinality, and existing physical indexes. Test a key whose leading fields support the useful predicates; for example, customer equality followed by a posting-date range. On a nonclustered secondary key, consider `IncludedFields` for small payload fields read but not filtered or ordered. The sample's key supports customer/date filters and includes the explicit payload, but is **not demonstrated to cover the read**: Business Central also projects `SystemId` and system audit fields on partial records. Check the generated SQL, including automatically selected, clustered-key, and extension fields, against the physical index before claiming coverage. Adding more included fields has storage and write-maintenance costs; measure reads, sorts, lookups, latency, and writes before expanding it. The [Database Missing Indexes page](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/database-missing-indexes) supplies candidates, not a mandate to add every suggested key. + +`IncludedFields` requires runtime 8.0 (BC 19) or later and cannot be set on a primary or clustered secondary key. An included field does not participate in `SetCurrentKey` matching or maintain a SIFT sum. If the item is also a selective predicate or required ordering column, evaluate it as a key field instead. Respect table-extension key field-ownership restrictions. See sample: [`design-covering-keys-from-read-pattern.good.al`](design-covering-keys-from-read-pattern.good.al). + +## Anti Pattern + +Adding a key on output-only fields or requesting an unrelated `SetCurrentKey` ordering to "force" the optimizer to use that key, without establishing the read's filters or validating its plan. Likewise, calling an index covering just because it contains the fields explicitly listed in `SetLoadFields` ignores automatically projected columns. Do not report every uncovered read as a defect: a small table, a low-frequency query, or a write-heavy table may be better without another maintained index. See sample: [`design-covering-keys-from-read-pattern.bad.al`](design-covering-keys-from-read-pattern.bad.al). + +## References + +- [Table keys, included columns, and extension restrictions](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-table-keys). +- [IncludedFields property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-includedfields-property). +- [Table keys and performance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-table-keys-and-performance). diff --git a/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md b/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md index c88cdfe..cdbd8ae 100644 --- a/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md +++ b/microsoft/knowledge/performance/isempty-before-findset-is-extra-round-trip.md @@ -13,11 +13,11 @@ application-area: [all] ## Description -`IsEmpty` is the right API when the caller only needs existence — see `microsoft/knowledge/performance/use-isempty-for-existence-check.md`. It is not a cheap guard in front of a loop that will `FindSet` anyway. Both calls hit the database; `FindSet` already returns false when the filter matches nothing. Agents and reviewers often insert `if not Rec.IsEmpty() then` "for performance" and pay a second query for a result the iterator already provides. +`IsEmpty` is the right API when the caller only needs existence — see `microsoft/knowledge/performance/use-isempty-for-existence-check.md`. It is not a cheap guard in front of a loop that will `FindSet` anyway. `FindSet` already returns false when the filter matches nothing; an extra `IsEmpty` is unnecessary AL work and can issue a second database request, depending on caching. Agents and reviewers often insert `if not Rec.IsEmpty() then` "for performance" and duplicate a result the iterator already provides. ## Best Practice -When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set. +When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set. An early check before a *bulk write* or lock is a different, workload-dependent decision: it can help when the filtered set is usually empty, but adds work when rows exist and does not lock the set against change. See sample: [`isempty-before-findset-is-extra-round-trip.good.al`](isempty-before-findset-is-extra-round-trip.good.al). diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al index 9a3ad4c..1c0fa22 100644 --- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al @@ -20,6 +20,7 @@ codeunit 50242 "Perf Sample ModifyAll Good" StagingEntry: Record "Perf Import Staging Entry"; begin StagingEntry.SetRange("Batch ID", BatchId); + StagingEntry.SetRange(Processed, false); // Processed has no OnValidate logic, and the equivalent loop uses Modify(false). StagingEntry.ModifyAll(Processed, true, false); end; diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md index ad7a6cf..34ea81c 100644 --- a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md @@ -15,7 +15,7 @@ application-area: [all] ## Best Practice -Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics. +Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. For a data-only update, exclude rows already holding the target value when that filter preserves the business outcome. Do not skip unchanged rows if the original call's per-row effects are required. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics. See sample: [`prefer-modifyall-over-per-row-modify.good.al`](prefer-modifyall-over-per-row-modify.good.al). diff --git a/microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.bad.al b/microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.bad.al new file mode 100644 index 0000000..d6c02c3 --- /dev/null +++ b/microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.bad.al @@ -0,0 +1,61 @@ +// Each invocation exclusively owns a new run; these tables have no write logic. +table 50364 "Perf Input" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Row No."; Integer) { } + field(2; "Item No."; Code[20]) { } + field(3; Quantity; Decimal) { } + } + + keys + { + key(PK; "Row No.") { Clustered = true; } + } +} + +table 50365 "Perf Output" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Run ID"; Guid) { } + field(2; "Line No."; Integer) { } + field(3; "Item No."; Code[20]) { } + field(4; Quantity; Decimal) { } + } + + keys + { + key(PK; "Run ID", "Line No.") { Clustered = true; } + } +} + +codeunit 50366 "Perf Buffered Insert Bad" +{ + procedure CopyRun(var TempInput: Record "Perf Input" temporary) RunId: Guid + var + Output: Record "Perf Output"; + NextLineNo: Integer; + begin + RunId := CreateGuid(); + Output.SetRange("Run ID", RunId); + if TempInput.FindSet() then + repeat + if Output.FindLast() then + NextLineNo := Output."Line No." + 1 + else + NextLineNo := 1; + Output.Init(); + Output."Run ID" := RunId; + Output."Line No." := NextLineNo; + Output."Item No." := TempInput."Item No."; + Output.Insert(false); + Output.Quantity := TempInput.Quantity; + Output.Modify(false); + until TempInput.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.good.al b/microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.good.al new file mode 100644 index 0000000..96d0faa --- /dev/null +++ b/microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.good.al @@ -0,0 +1,56 @@ +// Each invocation exclusively owns a new run; these tables have no write logic. +table 50364 "Perf Input" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Row No."; Integer) { } + field(2; "Item No."; Code[20]) { } + field(3; Quantity; Decimal) { } + } + + keys + { + key(PK; "Row No.") { Clustered = true; } + } +} + +table 50365 "Perf Output" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Run ID"; Guid) { } + field(2; "Line No."; Integer) { } + field(3; "Item No."; Code[20]) { } + field(4; Quantity; Decimal) { } + } + + keys + { + key(PK; "Run ID", "Line No.") { Clustered = true; } + } +} + +codeunit 50366 "Perf Buffered Insert Good" +{ + procedure CopyRun(var TempInput: Record "Perf Input" temporary) RunId: Guid + var + Output: Record "Perf Output"; + NextLineNo: Integer; + begin + RunId := CreateGuid(); + if TempInput.FindSet() then + repeat + NextLineNo += 1; + Output.Init(); + Output."Run ID" := RunId; + Output."Line No." := NextLineNo; + Output."Item No." := TempInput."Item No."; + Output.Quantity := TempInput.Quantity; + Output.Insert(false); + until TempInput.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.md b/microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.md new file mode 100644 index 0000000..7459ee4 --- /dev/null +++ b/microsoft/knowledge/performance/preserve-buffered-inserts-by-separating-target-reads.md @@ -0,0 +1,27 @@ +--- +bc-version: [all] +domain: performance +keywords: [buffered-inserts, bulk-inserts, findlast, insert, target-table, commit, staging] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep eligible inserts together instead of re-reading their target + +## Description + +Business Central can automatically buffer eligible `Insert` calls. `Find`/`Calc` on the **target** table, `Modify`/`Delete` on it, or `Commit` flushes pending inserts; consuming the `Insert` return value, or BLOB/AutoIncrement fields on the target, prevents buffering. A source-table read is not itself a target-table flush. There is no general `InsertAll` replacement for an AL loop. + +## Best Practice + +When writing completed rows to an application-owned data-only table, allocate a collision-free run or range once, prepare every field before each `Insert`, and keep the insert sequence free of intervening target-table reads and writes. Let the owning business transaction determine the commit point. Trace called procedures and events as well as the visible loop; inspect actual SQL batches and writes, then test failures, retries, and any concurrent writers. The sample assumes an exclusive new run ID and no required trigger, validation, number-series, or subscriber effects. See sample: [`preserve-buffered-inserts-by-separating-target-reads.good.al`](preserve-buffered-inserts-by-separating-target-reads.good.al). + +## Anti Pattern + +Calling `FindLast` on the target for every row to allocate its next line number, inserting an incomplete row and immediately modifying it, or committing each iteration of an otherwise eligible insert sequence. Moving a shared `FindLast` out of the loop without concurrency-safe allocation is **not** a valid fix. Do not recommend skipping required triggers or persistence steps in sales documents, journals, or posting ledgers just to obtain buffering; repeated `Modify` calls do not automatically batch like eligible inserts. See sample: [`preserve-buffered-inserts-by-separating-target-reads.bad.al`](preserve-buffered-inserts-by-separating-target-reads.bad.al). + +## References + +- [Bulk inserts and flush/non-buffering conditions](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-bulk-inserts). +- [Transaction checkpoints and restart safety](avoid-commit-inside-loops.md). diff --git a/microsoft/knowledge/performance/primary-key-get-in-loop-is-transaction-cached.md b/microsoft/knowledge/performance/primary-key-get-in-loop-is-transaction-cached.md index ed41f1b..3594a05 100644 --- a/microsoft/knowledge/performance/primary-key-get-in-loop-is-transaction-cached.md +++ b/microsoft/knowledge/performance/primary-key-get-in-loop-is-transaction-cached.md @@ -13,12 +13,12 @@ application-area: [all] The Business Central server caches primary-key reads within a transaction. Repeated `Record.Get()` calls for the same key are served from that cache rather than re-queried, so a guarded `if not Rec.Get(...) then exit;` inside a per-row helper is not a genuine N+1 pattern. When each row legitimately carries a distinct key — for example one `Bin Content` row per bin, so `Bin.Get` and `BinType.Get` see a different bin each iteration — the `Get` must run per row regardless, and there is nothing to hoist. -Reviewers sometimes see two `Get` calls inside a routine that runs once per row and recommend wrapping them in a `Dictionary` cache. That is over-engineering: it duplicates the server's built-in record cache, adds state that must be invalidated, and breaks the surrounding extension's established pattern of direct guarded `Get` calls. +Reviewers sometimes see two `Get` calls inside a routine that runs once per row and recommend wrapping them in a `Dictionary` cache. Without evidence of a material additional cost, that duplicates the server's built-in record cache and adds state that must be invalidated. Filtered, non-keyed reads are a different case (see [repeated filtered results](cache-repeated-filtered-results-with-explicit-scope.md)). ## Best Practice -Treat a primary-key `Get()` — especially a guarded `if not Rec.Get(...) then exit;` — as a cheap, transaction-cached read. Do not recommend a manual `Dictionary` cache around per-row primary-key `Get` calls. Reserve N+1 concerns for genuinely repeated non-keyed queries (`FindSet`/`FindFirst` with filters, `Count`) that re-hit the database each iteration. +Treat a primary-key `Get()` — especially a guarded `if not Rec.Get(...) then exit;` — as a transaction-cached read, not as proof of N+1 SQL. Do not recommend a manual cache solely from source-level call counts. If profiling shows repeated AL work or cache misses are material and the complete result can be reused safely, assess an explicitly scoped cache on its own merits. Investigate genuinely repeated non-keyed queries (`FindSet`/`FindFirst` with filters, `Count`) separately. ## Anti Pattern -Reporting repeated primary-key `Get` calls (such as `Bin.Get` and `BinType.Get`) inside a per-row helper as a performance defect, or recommending they be cached in a `Dictionary`. The reads are already cached by the server within the transaction, and per-row keys often differ so the calls cannot be hoisted. +Reporting repeated primary-key `Get` calls (such as `Bin.Get` and `BinType.Get`) inside a per-row helper as one SQL round trip per call, or recommending a `Dictionary` without measuring reuse and cost. Per-row keys often differ, and the server can satisfy repeated keys from its transaction cache. diff --git a/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md b/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md index 5bcfbd8..52f590c 100644 --- a/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md +++ b/microsoft/knowledge/performance/query-results-bypass-primary-key-cache.md @@ -13,7 +13,7 @@ application-area: [all] ## Description -The Business Central server caches primary-key `Get` calls within a transaction. Query objects do not use that cache: every `Open`/`Read` goes to SQL. `avoid-get-inside-loop-on-large-table.md` is right when an unbounded inner `Get`/`FindFirst` joins two large sets. It is wrong as a blanket rewrite of repeated `Get` on the same keys. Replacing a cached `Get` with a Query that re-executes per call can be slower. This file exists so reviewers stop treating every `Get` inside a loop as a Query candidate. +The Business Central server caches primary-key `Get` calls within a transaction. Query objects do not use that primary-key cache: reopening a Query per lookup executes the query again; `Read` consumes rows from an open query, not a new query for each row. `avoid-get-inside-loop-on-large-table.md` is right when an unbounded inner `Get`/`FindFirst` joins two large sets. It is wrong as a blanket rewrite of repeated `Get` on the same keys. Replacing a cached `Get` with a Query reopened per call can be slower. ## Best Practice diff --git a/microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.bad.al b/microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.bad.al new file mode 100644 index 0000000..7ad16a6 --- /dev/null +++ b/microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.bad.al @@ -0,0 +1,43 @@ +// The only supported read filters customer/date and displays item, quantity, and amount. +// No consumer needs ordering on the displayed fields or a SIFT aggregate. +table 50362 "Perf Document Entry" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Customer No."; Code[20]) { } + field(3; "Posting Date"; Date) { } + field(4; "Item No."; Code[20]) { } + field(5; Quantity; Decimal) { } + field(6; Amount; Decimal) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(CustomerDate; "Customer No.", "Posting Date") { } + key(CustomerDateItem; "Customer No.", "Posting Date", "Item No.") { } + key(CustomerDateQuantity; "Customer No.", "Posting Date", Quantity) { } + key(CustomerDateAmount; "Customer No.", "Posting Date", Amount) { } + } +} + +codeunit 50375 "Perf Document Reader Bad" +{ + procedure CustomerDateTotals(CustomerNo: Code[20]; FromDate: Date; ToDate: Date; var ItemNos: List of [Code[20]]; var TotalAmount: Decimal; var TotalQuantity: Decimal) + var + Entry: Record "Perf Document Entry"; + begin + Entry.SetRange("Customer No.", CustomerNo); + Entry.SetRange("Posting Date", FromDate, ToDate); + Entry.SetLoadFields("Item No.", Quantity, Amount); + if Entry.FindSet() then + repeat + ItemNos.Add(Entry."Item No."); + TotalQuantity += Entry.Quantity; + TotalAmount += Entry.Amount; + until Entry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.good.al b/microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.good.al new file mode 100644 index 0000000..8608396 --- /dev/null +++ b/microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.good.al @@ -0,0 +1,43 @@ +// The only supported read filters customer/date and displays item, quantity, and amount. +// No consumer needs ordering on the displayed fields or a SIFT aggregate. +table 50362 "Perf Document Entry" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Customer No."; Code[20]) { } + field(3; "Posting Date"; Date) { } + field(4; "Item No."; Code[20]) { } + field(5; Quantity; Decimal) { } + field(6; Amount; Decimal) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(CustomerDatePayload; "Customer No.", "Posting Date") + { + IncludedFields = "Item No.", Quantity, Amount; + } + } +} + +codeunit 50375 "Perf Document Reader Good" +{ + procedure CustomerDateTotals(CustomerNo: Code[20]; FromDate: Date; ToDate: Date; var ItemNos: List of [Code[20]]; var TotalAmount: Decimal; var TotalQuantity: Decimal) + var + Entry: Record "Perf Document Entry"; + begin + Entry.SetRange("Customer No.", CustomerNo); + Entry.SetRange("Posting Date", FromDate, ToDate); + Entry.SetLoadFields("Item No.", Quantity, Amount); + if Entry.FindSet() then + repeat + ItemNos.Add(Entry."Item No."); + TotalQuantity += Entry.Quantity; + TotalAmount += Entry.Amount; + until Entry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.md b/microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.md new file mode 100644 index 0000000..61ed36e --- /dev/null +++ b/microsoft/knowledge/performance/review-overlapping-keys-before-adding-an-index.md @@ -0,0 +1,29 @@ +--- +bc-version: [all] +domain: performance +keywords: [overlapping-keys, redundant-index, includedfields, sift, write-amplification, index-portfolio, key, setrange] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Review overlapping keys as a portfolio + +## Description + +Adding a secondary key to a frequently written table maintains another SQL index for every affected write. Several keys with the same leading fields might be serving distinct filters, sort orders, unique constraints, or SIFT aggregates; they might instead be redundant payload variants. A shared prefix, absent `SetCurrentKey` calls, or low usage in a short window is not proof that a key is unused. + +## Best Practice + +Before adding or removing a key, inventory keys from the table and installed extensions and identify each key's consumers and purpose: seek, ordering, uniqueness, or aggregation. Check `SQLIndex`, `MaintainSQLIndex`, `SumIndexFields`, and `MaintainSIFTIndex`, not only the AL key name. For *confirmed* payload-only variants on BC 19 or later, a single nonclustered key with `IncludedFields` can be a consolidation candidate (see [read-pattern key design](design-covering-keys-from-read-pattern.md)); an included field cannot replace a key column used for ordering or a maintained SIFT sum. Including the explicit payload does not prove that the resulting index covers every automatically selected field. Measure representative read and write workloads, including periodic reports, integrations, and other companies, before and after a supported extension/schema change. Retain a rollback path for a critical reader that regresses. See sample: [`review-overlapping-keys-before-adding-an-index.good.al`](review-overlapping-keys-before-adding-an-index.good.al). + +## Anti Pattern + +Keeping another customer/date key for each displayed field without checking whether the trailing fields support distinct operations. Conversely, merging `(Customer, Date)` with `(Customer, Item, Date)` merely because they share a prefix can regress item-selective queries; removing a unique key or a SIFT aggregate changes more than write cost. Do not report a key as unused solely because AL never calls `SetCurrentKey` on it. See sample: [`review-overlapping-keys-before-adding-an-index.bad.al`](review-overlapping-keys-before-adding-an-index.bad.al). + +## References + +- [Table keys: benefits, costs, and constraints](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-table-keys). +- [IncludedFields property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-includedfields-property). +- [Manage index usage (BC 2026 release wave 1 and later)](https://learn.microsoft.com/en-us/dynamics365/business-central/manage-indexes). +- [SIFT read/write trade-off](choose-maintainsiftindex-by-read-write-ratio.md). diff --git a/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md index ad0bb40..ea99299 100644 --- a/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md +++ b/microsoft/knowledge/performance/setcurrentkey-sets-sort-order-not-index-hint.md @@ -7,11 +7,11 @@ countries: [w1] application-area: [all] --- -# SetCurrentKey only sets sort order — it is not an index hint +# SetCurrentKey is not a SQL index hint for record reads ## Description -A common misconception is that `SetCurrentKey` tells SQL Server which index to use for a query. It does not. In Business Central, `SetCurrentKey` only changes the `ORDER BY` clause of the generated SQL statement. It does not add an index hint, and the SQL Server query optimizer is free to ignore the named key entirely. +A common misconception is that `SetCurrentKey` tells SQL Server which index to use for a filtered `FindSet`/`FindFirst` query. It does not. For record iteration, `SetCurrentKey` changes the `ORDER BY` clause of the generated SQL statement; it does not add an index hint, and the SQL Server query optimizer is free to ignore the named key entirely. A distinct use is selecting an appropriate key with `SumIndexFields` before `CalcSums` so the platform can use a compatible SIFT aggregate (see [SIFT and SQL Server](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-sift-and-sql-server)). The optimizer picks the index from the `WHERE` clause (your `SetRange`/`SetFilter`) together with table statistics and estimated cost. In practice it almost never chooses an index just because that key appears in `ORDER BY`. So calling `SetCurrentKey` to "steer" the plan toward an index is a no-op for index selection — and can make things worse: an `ORDER BY` that the query does not otherwise need can push the optimizer toward a less selective index or add a Sort operator to the plan. @@ -19,13 +19,15 @@ Selectivity comes from having the right index available (a key on the table whos ## Best Practice -Decide `SetCurrentKey` on one question only: **do I need the result set in a specific order?** +For a record-iteration read, decide `SetCurrentKey` on one question: **do I need the result set in a specific order?** - If yes — you iterate rows in a defined sequence, or rely on `FindFirst`/`FindLast`/`Next` returning a particular row — call `SetCurrentKey` for that sort. The order is a functional requirement, and the `ORDER BY` is justified. - If no — omit `SetCurrentKey`. Let the optimizer choose the cheapest plan for your filters; it may pick a better index and skip a sort. To make a filtered read fast, ensure a key (index) exists on the table whose leading fields cover the filter, and filter on those fields with `SetRange`/`SetFilter`. That is what lets the optimizer seek. Defining the key creates the index; `SetCurrentKey` is not required to make the optimizer use it. +For a stored-field `CalcSums` instead of iteration, consider a matching current key with the summed field in `SumIndexFields`, as documented for SIFT; do not classify that call as an unnecessary `ORDER BY` on a row iterator (see [stored-field totals](calcsums-instead-of-calcfields-in-loop.md)). + See sample: [`setcurrentkey-sets-sort-order-not-index-hint.good.al`](setcurrentkey-sets-sort-order-not-index-hint.good.al). ## Anti Pattern diff --git a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.bad.al b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.bad.al new file mode 100644 index 0000000..f3de8f5 --- /dev/null +++ b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.bad.al @@ -0,0 +1,39 @@ +// Caller supplies an unfiltered temporary buffer that does not change during this call. +table 50373 "Perf Cell" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Cell No."; Integer) { } + field(2; "Item No."; Code[20]) { } + field(3; Quantity; Decimal) { } + } + + keys + { + key(PK; "Cell No.") { Clustered = true; } + } +} + +codeunit 50374 "Perf Temp Totals Bad" +{ + procedure SumDisplayedItemTotals(var TempCells: Record "Perf Cell" temporary) DisplayedTotal: Decimal + var + TempScan: Record "Perf Cell" temporary; + ItemTotal: Decimal; + begin + TempScan.Copy(TempCells, true); + if TempCells.FindSet() then + repeat + TempScan.Reset(); + TempScan.SetRange("Item No.", TempCells."Item No."); + ItemTotal := 0; + if TempScan.FindSet() then + repeat + ItemTotal += TempScan.Quantity; + until TempScan.Next() = 0; + DisplayedTotal += ItemTotal; + until TempCells.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.good.al b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.good.al new file mode 100644 index 0000000..c02da0c --- /dev/null +++ b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.good.al @@ -0,0 +1,39 @@ +// Caller supplies an unfiltered temporary buffer that does not change during this call. +table 50373 "Perf Cell" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "Cell No."; Integer) { } + field(2; "Item No."; Code[20]) { } + field(3; Quantity; Decimal) { } + } + + keys + { + key(PK; "Cell No.") { Clustered = true; } + } +} + +codeunit 50374 "Perf Temp Totals Good" +{ + procedure SumDisplayedItemTotals(var TempCells: Record "Perf Cell" temporary) DisplayedTotal: Decimal + var + TotalsByItem: Dictionary of [Code[20], Decimal]; + ItemTotal: Decimal; + begin + if TempCells.FindSet() then + repeat + if TotalsByItem.Get(TempCells."Item No.", ItemTotal) then + TotalsByItem.Set(TempCells."Item No.", ItemTotal + TempCells.Quantity) + else + TotalsByItem.Add(TempCells."Item No.", TempCells.Quantity); + until TempCells.Next() = 0; + + if TempCells.FindSet() then + repeat + DisplayedTotal += TotalsByItem.Get(TempCells."Item No."); + until TempCells.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md index 3fd8072..a392e0b 100644 --- a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md +++ b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md @@ -15,8 +15,12 @@ A temporary table stores its rows in Business Central Server memory instead of a ## Best Practice -Do not apply SQL-specific findings such as missing `SetLoadFields`, lock contention, or N+1 database round-trips to a temporary record. Still assess memory volume and repeated scans or lookups. For a pure key-to-value collection, consider an AL `Dictionary`; keep a temporary table when record fields, keys, filtering, or ordered iteration are required. +Do not apply SQL-specific findings such as missing `SetLoadFields`, lock contention, or N+1 database round-trips to a temporary record. Still assess memory volume and repeated scans or lookups. In a matrix, if each cell re-sums the same group, calculate the totals once at the complete group key and reuse them; invalidate or adjust totals if cells change. For a pure key-to-value collection, consider an AL `Dictionary`; keep a temporary table when record fields, keys, filtering, or ordered iteration are required. Measure AL time and peak memory, not just SQL time. + +See sample: [`temporary-tables-have-no-database-cost.good.al`](temporary-tables-have-no-database-cost.good.al). ## Anti Pattern Claiming that every temporary-table access pattern is free because no SQL is involved. A nested scan over a large in-memory buffer can still dominate service-tier CPU, while adding `SetLoadFields` to that buffer addresses a database cost that does not exist. + +See sample: [`temporary-tables-have-no-database-cost.bad.al`](temporary-tables-have-no-database-cost.bad.al). diff --git a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md index b5f9d6a..e23cc98 100644 --- a/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md +++ b/microsoft/knowledge/performance/use-setautocalcfields-for-per-row-flowfields.md @@ -15,12 +15,12 @@ application-area: [all] ## Best Practice -Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. Use `CalcSums` instead when the required result is one aggregate over the filtered set (see `calcsums-instead-of-calcfields-in-loop.md`). +Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. For one total of a **stored source field**, consider `CalcSums` instead (see [stored-field totals versus FlowFields](calcsums-instead-of-calcfields-in-loop.md)). If the required result is a total of selected FlowField values, derive the underlying source filters from `CalcFormula`; do not sum the FlowField directly with `CalcSums`. See sample: [`use-setautocalcfields-for-per-row-flowfields.good.al`](use-setautocalcfields-for-per-row-flowfields.good.al). ## Anti Pattern -Calling `CalcFields` inside the loop when every iteration reads the same FlowField. Each `CalcFields` request requires a separate SQL statement unless a compatible recent result is cached. Do not replace row-specific decisions with `CalcSums`; an aggregate cannot preserve which rows met the condition. +Calling `CalcFields` inside the loop when every iteration reads the same FlowField. A compatible recent result can be cached, so do not equate each call with a SQL statement. Do not replace row-specific decisions with `CalcSums`; an aggregate cannot preserve which rows met the condition. See sample: [`use-setautocalcfields-for-per-row-flowfields.bad.al`](use-setautocalcfields-for-per-row-flowfields.bad.al). diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al index c9b4ce2..c7054f3 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al @@ -1,14 +1,18 @@ codeunit 50219 "Perf Sample LoadFields Bad" { - procedure ListUSCustomerNames() + procedure CollectUSCustomerNamesAndCities(var DisplayNames: List of [Text]) var Customer: Record Customer; begin - // Loads every Customer column on every row, when only Name is read. Customer.SetRange("Country/Region Code", 'US'); if Customer.FindSet() then repeat - Message(Customer.Name); + DisplayNames.Add(CustomerDisplayText(Customer)); until Customer.Next() = 0; end; + + local procedure CustomerDisplayText(Customer: Record Customer): Text + begin + exit(Customer.Name + ' ' + Customer.City); + end; } diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al index a5bee5f..c663bc4 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al @@ -1,17 +1,22 @@ codeunit 50218 "Perf Sample LoadFields Good" { - procedure ListUSCustomerNames() + procedure CollectUSCustomerNamesAndCities(var DisplayNames: List of [Text]) var Customer: Record Customer; begin Customer.SetRange("Country/Region Code", 'US'); - Customer.SetLoadFields(Name); + Customer.SetLoadFields(Name, City); if Customer.FindSet() then repeat - Message(Customer.Name); + DisplayNames.Add(CustomerDisplayText(Customer)); until Customer.Next() = 0; end; + local procedure CustomerDisplayText(Customer: Record Customer): Text + begin + exit(Customer.Name + ' ' + Customer.City); + end; + procedure LookupSkuPolicy(LocationCode: Code[10]) Policy: Enum "SKU Creation Method" var Location: Record Location; diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index de92bb1..5bd9bb7 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -17,7 +17,7 @@ Its position relative to `SetRange`/`SetFilter` does not change the projection: ## Best Practice -Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. For example, `SetLoadFields(...); if Record.Get(...) then ...` selects fields before the read. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons ([singleton setup tables](singleton-setup-tables-need-no-access-optimization.md), [temporary tables](temporary-tables-have-no-database-cost.md)). The numeric cutoffs are BCQuality review heuristics, not Microsoft platform thresholds. For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see [report partial loads](addloadfields-in-report-onpredataitem.md)). +Before a `Get`, `FindSet`, or `FindFirst` that the complete read path follows by reading only a handful of the table's fields, call `SetLoadFields` listing the normal fields used by the caller **and its helpers**. For example, `SetLoadFields(...); if Record.Get(...) then ...` selects fields before the read. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. Check for later `Reset` and for unloaded fields read by a [by-value helper](pass-var-record-to-preserve-partial-load-enumerator.md); a JIT load can be served from cache, so it is not automatically a SQL round trip. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons ([singleton setup tables](singleton-setup-tables-need-no-access-optimization.md), [temporary tables](temporary-tables-have-no-database-cost.md)). The numeric cutoffs are BCQuality review heuristics, not Microsoft platform thresholds. For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see [report partial loads](addloadfields-in-report-onpredataitem.md)). See sample: [`use-setloadfields-for-partial-records.good.al`](use-setloadfields-for-partial-records.good.al). diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index cfaf9f7..6eae659 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -39,13 +39,16 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration. - The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, SetAutoCalcFields, CalcSums, FlowField access, Commit calls, checkpoint helpers, record copying, RecordRef conversion, Modify/Delete calls, or cross-table navigation. -- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `FlowField`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`, `Page.RunModal`, `Report.RunModal`, `Report.Run`, `Xmlport.Run`, `UseRequestPage`). +- Tokens extracted from the diff that relate to data access, hot-path costs, and background scheduling (`key`, `IncludedFields`, `SumIndexFields`, `SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `FindLast`, `IsEmpty`, `ReadIsolation`, `LockTable`, `Insert`, `ModifyAll`, `DeleteAll`, `Modify`, `Delete`, `Validate`, `Commit`, `checkpoint`, `Copy`, `RecordRef`, `GetTable`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `SetAutoCalcFields`, `CalcSums`, `CalcFormula`, `FlowField`, `Query.Open`, `Query.Read`, `Visible`, `Job Queue Entry`, `Job Queue Category Code`, `Confirm`, `RunModal`, `GuiAllowed`, `TryFunction`, `Codeunit.Run`, `HttpClient`, `Status`, `On Hold`, `stop request`, `TaskScheduler.CreateTask`, `TaskScheduler.TaskExists`, `Page.RunModal`, `Report.RunModal`, `Report.Run`, `Xmlport.Run`, `UseRequestPage`). 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. Apply these targeted cues even when simple token overlap would rank the article below the worklist cutoff: -- Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Worklist `calcsums-instead-of-calcfields-in-loop.md` instead when the loop only accumulates one set total. +- Worklist `design-covering-keys-from-read-pattern.md` for a changed secondary key alongside a filtered reader of its table, and `review-overlapping-keys-before-adding-an-index.md` when added or changed keys have overlapping leading fields. A changed key alone is not a finding; read and write workloads determine whether either rule applies. +- Worklist `preserve-buffered-inserts-by-separating-target-reads.md` when a loop calls `Insert` and interleaves operations on the insert target or `Commit`. Worklist `aggregate-before-persisting-intermediate-results.md` when repeated grouping calculations and persistent intermediate summaries appear in the same processing path. Do not infer either pattern from `Insert` or `CalcSums` alone. +- Worklist `cache-repeated-filtered-results-with-explicit-scope.md` only when the code or workload establishes repeated **complete** lookup keys (for example, querying the same filtered set in multiple passes, or measured key reuse), or shows a cache that omits result-affecting inputs. A single loop over possibly distinct keys does not establish reuse or justify a cache finding. Worklist `avoid-repeating-unchanged-validation.md` for repeated `Validate` of the same field in one path; do not worklist it from a single validation call. +- Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Also worklist `calcsums-instead-of-calcfields-in-loop.md` when the loop accumulates one set total: use `CalcSums` directly only for stored source fields, never directly on FlowFields; a FlowField total requires deriving equivalent source filters from its `CalcFormula`. - Worklist `hidden-flowfields-still-calculate-before-bc26-opt-in.md` when a page control directly sources a FlowField and sets `Visible = false` or a visibility expression. Suppress it when the target is known to have BC26's **Calculate only visible FlowFields** feature enabled, or when the FlowField is cheap and intentionally preloaded. - Worklist `avoid-commit-inside-loops.md` when `Commit()` is inside a record-iteration body or a checkpoint loop lacks persisted progress that excludes completed work on retry. Do not match a commit after a complete business unit when the same transaction persists a restart-safe watermark/state and errors propagate. Still match a full-tail `FindSet` with periodic commits as unbounded retrieval; restart safety does not make it `TOP X`. - Worklist `prefer-modifyall-over-per-row-modify.md` for a constant-assignment `Modify(false)` loop with no validation or per-row semantics. Worklist `triggers-and-media-field-regress-modifyall.md` when table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields affect a bulk path. A progress dialog does not generically exempt a loop; accept it only when the equivalent bulk call already falls back to individual operations and semantics are preserved. From 164b27d0b235f01ce3155cc7bafb0121867e7352 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 29 Sep 2026 17:29:36 +0200 Subject: [PATCH 38/51] Restore deterministic review fixture coverage (#202) Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- evaluation/review-fixtures.json | 52 ++++++++++++++++--- ...ust-not-restate-what-code-already-shows.md | 2 +- .../pages-must-not-contain-business-logic.md | 2 +- .../testing/test-feature-scenario-tags.md | 2 +- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 7732e7e..a43ec1f 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -11,6 +11,21 @@ "breaking-changes": { "article": "do-not-expose-sensitive-data-through-public-api" }, + "data-modeling": { + "articles": [ + "check-blocked-in-referencing-code-not-in-master", + "code-must-not-change-workdate", + "pictures-must-use-media-not-blob", + "table-design-must-match-bc-table-type-conventions" + ] + }, + "error-handling": { + "articles": [ + "collect-validation-errors-with-errorbehavior", + "defensive-vs-offensive-code-must-match-blast-radius", + "log-writes-must-survive-rollback" + ] + }, "events": { "article": "reset-ishandled-only-when-the-value-can-carry-over" }, @@ -54,7 +69,9 @@ "use-setautocalcfields-for-per-row-flowfields", "temporary-tables-have-no-database-cost", "use-setloadfields-for-partial-records", - "prefer-modifyall-over-per-row-modify" + "prefer-modifyall-over-per-row-modify", + "al-methods-limited-during-write-transactions", + "avoid-user-prompts-inside-transactions" ] }, "privacy": { @@ -98,13 +115,16 @@ "security": { "articles": [ "al-has-no-built-in-htmlencode", - "do-not-concatenate-external-text-into-setfilter" + "do-not-concatenate-external-text-into-setfilter", + "exposed-objects-must-be-in-a-permission-set" ] }, "style": { "articles": [ "label-comment-explains-placeholders", - "dateformula-evaluate-needs-language-independent-literals" + "dateformula-evaluate-needs-language-independent-literals", + "al-comments-must-not-restate-what-code-already-shows", + "pages-must-not-contain-business-logic" ] }, "telemetry": { @@ -113,11 +133,30 @@ "testing": { "articles": [ "ui-handlers-in-tests", - "reset-per-test-state-before-the-isinitialized-guard" + "reset-per-test-state-before-the-isinitialized-guard", + "asserterror-needs-expectederror-and-code", + "bcpt-scenarios-must-be-app-specific", + "commit-shared-test-fixture-inside-lazy-initialize", + "given-blocks-must-cover-full-precondition-chain", + "table-relation-test-exclude-known-invalid-relations-via-event", + "test-feature-scenario-tags", + "test-one-when-per-test", + "transactionmodel-attribute-governs-test-transactions", + "ui-test-codeunit-naming", + "use-assert-isfalse-not-asserterror-for-boolean-checks" + ] + }, + "ui": { + "articles": [ + "default-descending-sort-on-historical-pages", + "page-design-must-match-bc-page-type-conventions" ] }, "upgrade": { - "article": "initvalue-does-not-update-existing-rows", + "articles": [ + "initvalue-does-not-update-existing-rows", + "upgrade-tag-logic-must-not-nest-deeply" + ], "context": "The extended table existed in the previous app version and already contains rows." }, "web-services": { @@ -126,7 +165,8 @@ "handle-httpclient-platform-failure-before-response-access", "check-http-status-before-consuming-response-body", "check-json-null-before-converting-values", - "format-exchanged-values-with-standard-format-9" + "format-exchanged-values-with-standard-format-9", + "api-page-least-privilege-write-access" ] } } diff --git a/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md index 09a2ea4..52c3c0a 100644 --- a/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md +++ b/microsoft/knowledge/style/al-comments-must-not-restate-what-code-already-shows.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: style -keywords: [comments, verbosity, self-documenting, restate, tutorial-style] +keywords: [comments, verbosity, self-documenting, restate, tutorial-style, credit-memo-routing] technologies: [al] countries: [w1] application-area: [all] diff --git a/microsoft/knowledge/style/pages-must-not-contain-business-logic.md b/microsoft/knowledge/style/pages-must-not-contain-business-logic.md index 3326a25..ed5a4cb 100644 --- a/microsoft/knowledge/style/pages-must-not-contain-business-logic.md +++ b/microsoft/knowledge/style/pages-must-not-contain-business-logic.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: style -keywords: [pages, business-logic, codeunit, separation-of-concerns, presentation-layer] +keywords: [pages, business-logic, codeunit, separation-of-concerns, presentation-layer, rec-modify] technologies: [al] countries: [w1] application-area: [all] diff --git a/microsoft/knowledge/testing/test-feature-scenario-tags.md b/microsoft/knowledge/testing/test-feature-scenario-tags.md index 2247c7e..89198fb 100644 --- a/microsoft/knowledge/testing/test-feature-scenario-tags.md +++ b/microsoft/knowledge/testing/test-feature-scenario-tags.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: testing -keywords: [feature, scenario, given, when, then, tags, bdd, atdd, comments] +keywords: [feature, scenario, given, when, then, tags, bdd, atdd, comments, subtype-test] technologies: [al] countries: [w1] application-area: [all] From fd599197788ff4c11d01d98fb809f4eb4b6d6095 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:22:38 +0200 Subject: [PATCH 39/51] 9 AL/BC patterns: document distribution, price calculation & barcode extensibility (#175) * Add 5 AL/BC patterns: document distribution (Report Selections, Document Sending Profile, Find Entries, TransferFields) Five rules about Business Central's document distribution architecture, verified against BCApps source and Microsoft Learn. - custom-document-dispatch-must-not-bypass-report-selections - document-print-and-email-actions-call-report-selections-directly - extend-find-entries-navigate-for-new-document-types - extend-report-selection-usage-for-new-document-types - transferfields-mirrored-fields-must-match-type-and-length Wired into al-data-modeling-review.md's worklist cues. Added a disambiguation note on the TransferFields article distinguishing it from the existing transferfields-skip-type-mismatch-can-drop-data.md (type-mismatch skipping vs. length mismatch, which SkipFieldsNotMatchingType does not affect). Co-Authored-By: Claude Sonnet 5 * Fix four merge-critical blockers from Jesper's review; add 4 more patterns Addresses microsoft/BCQuality#175 review feedback: - Extend al-data-modeling-review's entry gate/relevance scope and token list to recognize document actions, Navigate subscribers, Report Selection registration, price-calculation/price-source extensibility, TransferFields posting-cascade mirroring, and barcode font-provider usage - previously excluded before any worklist cue could run. - Fix document-print-and-email-actions-call-report-selections-directly: permit the legitimate stateless DocumentSendingProfile.TrySendToPrinter/ TrySendToEMail path; rework the bad fixture to load a configured profile instead of demonstrating a trivial blank-record no-op. - Fix extend-report-selection-usage-for-new-document-types: scope to the applicable single counterparty (ReportSelectionHandlerCZZ partitions strictly; only genuinely two-sided usages like Compensation need both), and add the page-facing usage-enum map/validate events alongside the filter-event subscription for full Document Layouts support. - Fix a stale field-citation in custom-document-dispatch-must-not-bypass- report-selections (Custom Report Layout Code is field 7, not part of the 19-26 email-configuration range). - Add deterministic positive/clean evaluation coverage (review-fixtures.json additionalArticles + Test-ReviewFixtures.ps1 support) so all 9 new good/bad pairs are actually exercised, not just present. - Add 4 new patterns: activate-new-price-calculation-handler-via- onfindsupportedsetup, extend-price-source-type-must-sync-document- subset-enum, new-price-source-must-add-candidate-and-trigger- recalculation, report-barcodes-must-use-barcode-module-and-production- font-name. All claims verified against live microsoft/BCApps source and Microsoft Learn. Validators: frontmatter 0/0, review-fixtures 52 cases/17 domains PASSED, knowledge-index 309 articles PASSED. Co-Authored-By: Claude Sonnet 5 * Fix 5 merge-critical issues from Jesper's 2026-09-24 review round - activate-new-price-calculation-handler-via-onfindsupportedsetup: Default := true is required only for the fallback branch of PriceCalculationMgt's two-stage FindSetup - a handler reachable via a specific Dtld. Price Calculation Setup row needs no Default. Softened the article and its worklist cue accordingly. Also fixed an undefined "Sample Price Calc - Special" codeunit referenced but never declared in the eval fixtures - added a real implementation of interface "Price Calculation" with stub methods. - new-price-source-must-add-candidate-and-trigger-recalculation: the good fixture called UpdateUnitPriceByField directly, which is a silent no-op without a prior PlanPriceCalcByField call (FieldCausedPriceCalculation gating, verified against SalesLine.Table.al). Switched to the public UpdateUnitPrice wrapper, matching real BCApps usage in ItemReferenceManagement.Codeunit.al. - report-barcodes-must-use-barcode-module-and-production-font-name: split the 1D (ValidateInput + EncodeFont) and 2D (EncodeFont only) Barcode Font Provider interfaces, which the article previously conflated. Reframed the Code 39 anti-pattern around demonstrable encoding/checksum mismatch (verified against IDA1DCode39Encoder.Codeunit.al's real '(value)' output) rather than rejecting all manual delimiter use, since '*' is a legitimate Code 39 start/stop character. Also fixed extend-find-entries-navigate- for-new-document-types' eval fixtures, which referenced an undefined "Sample Posted Document Header" table/page - declared both. All claims re-verified against live microsoft/BCApps source. Validators: frontmatter 0/0, review-fixtures 126/20 domains PASSED, knowledge-index 342/575 PASSED, skill-index 19 leaves PASSED. Co-Authored-By: Claude Sonnet 5 * Align price-source and barcode routing cues with corrected articles - Price-source cue now accepts UpdateUnitPrice, or the explicit PlanPriceCalcByField + UpdateUnitPriceByField sequence; bare UpdateUnitPriceByField does not count. Both APIs added to tokens. - Barcode cue no longer flags manual delimiters as a category; routes only demonstrably invalid/provider-font-mismatched hand encoding, and requires ValidateInput + EncodeFont for 1D, EncodeFont only for 2D. Co-Authored-By: Claude Opus 5.5 * Make barcode bad fixture self-contained: 1D EncodeFont without ValidateInput The previous bad fixture (literal '*' delimiters, no layout/font/provider evidence) no longer matched the narrowed routing cue. It now shows an IDAutomation 1D provider path that calls EncodeFont without ValidateInput, which is visible in AL alone. Article Anti Pattern and Source updated to describe this variant (verified: IDAutomation 1D Provider's EncodeFont does not call IsValidInput). Co-Authored-By: Claude Opus 5.5 * Fix three merge-critical items from Jesper's 2026-09-29 review - Barcode: drop the false claim that '*value*' is mismatched with the IDAutomation Code 39 font; '*' is a documented start/stop form and '(' / ')' an accepted alternative. Cue and article now route only independently provable validation/checksum/font-binding defects. - Dispatch good samples (and matching bad samples) now pass a Sales Invoice Header with the S.Invoice usage, matching the record the selected report (1306 "Standard Sales - Invoice") expects. - custom-document-dispatch rule made disjunctive: a hardcoded report or a hand-built email is each a bypass on its own; scoped to customer/vendor-facing documents. Bad fixture shows the hardcoded report alone. Co-Authored-By: Claude Opus 5.5 * Clarify TrySendToEMail comment in print/email good sample Make explicit that TrySendToEMail is also correct *because* it never reads the customer's assigned profile (local record, E-Mail option set by the helper itself), and name Get/GetDefaultForCustomer + Send as the anti-pattern. Matches the article's Best Practice and BaseApp's own Sales Invoice Header.EmailRecords. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Sonnet 5 --- evaluation/review-fixtures.json | 11 +- ...on-handler-via-onfindsupportedsetup.bad.al | 74 +++++++++++++ ...n-handler-via-onfindsupportedsetup.good.al | 90 ++++++++++++++++ ...lation-handler-via-onfindsupportedsetup.md | 98 +++++++++++++++++ ...h-must-not-bypass-report-selections.bad.al | 20 ++++ ...-must-not-bypass-report-selections.good.al | 31 ++++++ ...patch-must-not-bypass-report-selections.md | 69 ++++++++++++ ...ons-call-report-selections-directly.bad.al | 45 ++++++++ ...ns-call-report-selections-directly.good.al | 63 +++++++++++ ...actions-call-report-selections-directly.md | 100 ++++++++++++++++++ ...ies-navigate-for-new-document-types.bad.al | 35 ++++++ ...es-navigate-for-new-document-types.good.al | 68 ++++++++++++ ...entries-navigate-for-new-document-types.md | 93 ++++++++++++++++ ...type-must-sync-document-subset-enum.bad.al | 14 +++ ...ype-must-sync-document-subset-enum.good.al | 19 ++++ ...rce-type-must-sync-document-subset-enum.md | 68 ++++++++++++ ...ection-usage-for-new-document-types.bad.al | 47 ++++++++ ...ction-usage-for-new-document-types.good.al | 87 +++++++++++++++ ...-selection-usage-for-new-document-types.md | 99 +++++++++++++++++ ...candidate-and-trigger-recalculation.bad.al | 25 +++++ ...andidate-and-trigger-recalculation.good.al | 38 +++++++ ...add-candidate-and-trigger-recalculation.md | 97 +++++++++++++++++ ...ode-module-and-production-font-name.bad.al | 41 +++++++ ...de-module-and-production-font-name.good.al | 54 ++++++++++ ...barcode-module-and-production-font-name.md | 99 +++++++++++++++++ ...d-fields-must-match-type-and-length.bad.al | 30 ++++++ ...-fields-must-match-type-and-length.good.al | 28 +++++ ...rored-fields-must-match-type-and-length.md | 87 +++++++++++++++ .../skills/review/al-data-modeling-review.md | 19 +++- tools/Test-ReviewFixtures.ps1 | 1 + 30 files changed, 1644 insertions(+), 6 deletions(-) create mode 100644 microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al create mode 100644 microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al create mode 100644 microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md create mode 100644 microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al create mode 100644 microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al create mode 100644 microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md create mode 100644 microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al create mode 100644 microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al create mode 100644 microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md create mode 100644 microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al create mode 100644 microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al create mode 100644 microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md create mode 100644 microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al create mode 100644 microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al create mode 100644 microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md create mode 100644 microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al create mode 100644 microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al create mode 100644 microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md create mode 100644 microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al create mode 100644 microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al create mode 100644 microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md create mode 100644 microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al create mode 100644 microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al create mode 100644 microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md create mode 100644 microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al create mode 100644 microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al create mode 100644 microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index a43ec1f..5f0e50a 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -13,10 +13,19 @@ }, "data-modeling": { "articles": [ + "activate-new-price-calculation-handler-via-onfindsupportedsetup", "check-blocked-in-referencing-code-not-in-master", "code-must-not-change-workdate", + "custom-document-dispatch-must-not-bypass-report-selections", + "document-print-and-email-actions-call-report-selections-directly", + "extend-find-entries-navigate-for-new-document-types", + "extend-price-source-type-must-sync-document-subset-enum", + "extend-report-selection-usage-for-new-document-types", + "new-price-source-must-add-candidate-and-trigger-recalculation", "pictures-must-use-media-not-blob", - "table-design-must-match-bc-table-type-conventions" + "report-barcodes-must-use-barcode-module-and-production-font-name", + "table-design-must-match-bc-table-type-conventions", + "transferfields-mirrored-fields-must-match-type-and-length" ] }, "error-handling": { diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al new file mode 100644 index 0000000..6a02378 --- /dev/null +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al @@ -0,0 +1,74 @@ +enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation Handler" +{ + value(50102; "Sample Special Price") + { + Caption = 'Sample Special Price'; + Implementation = "Price Calculation" = "Sample Price Calc - Special"; + } +} + +// Demonstration-only AL: every method below is stubbed. This article is +// about activating a handler through OnFindSupportedSetup, not about the +// "Price Calculation" interface's own pricing logic. +codeunit 50103 "Sample Price Calc - Special" implements "Price Calculation" +{ + procedure Init(LineWithPrice: Interface "Line With Price"; PriceCalculationSetup: Record "Price Calculation Setup") + begin + end; + + procedure GetLine(var Line: Variant) + begin + end; + + procedure ApplyDiscount() + begin + end; + + procedure ApplyPrice(CalledByFieldNo: Integer) + begin + end; + + procedure CountDiscount(ShowAll: Boolean) Result: Integer + begin + end; + + procedure CountPrice(ShowAll: Boolean) Result: Integer + begin + end; + + procedure FindDiscount(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean + begin + end; + + procedure FindPrice(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean + begin + end; + + procedure IsDiscountExists(ShowAll: Boolean) Result: Boolean + begin + end; + + procedure IsPriceExists(ShowAll: Boolean) Result: Boolean + begin + end; + + procedure PickDiscount() + begin + end; + + procedure PickPrice() + begin + end; + + procedure ShowPrices(var TempPriceListLine: Record "Price List Line") + begin + end; +} + +// WRONG: no subscriber to Price Calculation Mgt.'s OnFindSupportedSetup. +// "Sample Special Price" is a real, working implementation of the Price +// Calculation interface - it simply has no Price Calculation Setup row +// naming it, so Price Calculation Mgt. never selects it for any sale, +// purchase, or job line, whether through the Default fallback or through +// a "Dtld. Price Calculation Setup" row. It ships invisible until someone +// notices and configures a setup row for it by hand. diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al new file mode 100644 index 0000000..8f27beb --- /dev/null +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al @@ -0,0 +1,90 @@ +enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation Handler" +{ + value(50102; "Sample Special Price") + { + Caption = 'Sample Special Price'; + Implementation = "Price Calculation" = "Sample Price Calc - Special"; + } +} + +// Demonstration-only AL: every method below is stubbed. This article is +// about activating a handler through OnFindSupportedSetup, not about the +// "Price Calculation" interface's own pricing logic. +codeunit 50103 "Sample Price Calc - Special" implements "Price Calculation" +{ + procedure Init(LineWithPrice: Interface "Line With Price"; PriceCalculationSetup: Record "Price Calculation Setup") + begin + end; + + procedure GetLine(var Line: Variant) + begin + end; + + procedure ApplyDiscount() + begin + end; + + procedure ApplyPrice(CalledByFieldNo: Integer) + begin + end; + + procedure CountDiscount(ShowAll: Boolean) Result: Integer + begin + end; + + procedure CountPrice(ShowAll: Boolean) Result: Integer + begin + end; + + procedure FindDiscount(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean + begin + end; + + procedure FindPrice(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean + begin + end; + + procedure IsDiscountExists(ShowAll: Boolean) Result: Boolean + begin + end; + + procedure IsPriceExists(ShowAll: Boolean) Result: Boolean + begin + end; + + procedure PickDiscount() + begin + end; + + procedure PickPrice() + begin + end; + + procedure ShowPrices(var TempPriceListLine: Record "Price List Line") + begin + end; +} + +codeunit 50104 "Sample Price Calc Setup Install" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Price Calculation Mgt.", 'OnFindSupportedSetup', '', false, false)] + local procedure AddSampleSpecialPriceSetup(var TempPriceCalculationSetup: Record "Price Calculation Setup" temporary) + begin + TempPriceCalculationSetup.Init(); + TempPriceCalculationSetup.Code := 'SAMPLE-SPECIAL'; + TempPriceCalculationSetup.Method := TempPriceCalculationSetup.Method::"Lowest Price"; + TempPriceCalculationSetup.Type := TempPriceCalculationSetup.Type::Sale; + TempPriceCalculationSetup."Asset Type" := TempPriceCalculationSetup."Asset Type"::" "; + TempPriceCalculationSetup.Implementation := TempPriceCalculationSetup.Implementation::"Sample Special Price"; + TempPriceCalculationSetup.Enabled := true; + // Default := true here because this row is meant as the fallback + // for Method = Lowest Price / Type = Sale / Asset Type = " " (all) + // - the combination Price Calculation Mgt.'s FindSetup selects via + // its own SetRange(Default, true) branch when no "Dtld. Price + // Calculation Setup" row names a more specific match. A handler + // meant to be picked only through such a specific, explicit + // detailed-setup row would not need Default := true at all. + TempPriceCalculationSetup.Default := true; + TempPriceCalculationSetup.Insert(); + end; +} diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md new file mode 100644 index 0000000..3654468 --- /dev/null +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md @@ -0,0 +1,98 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [price-calculation, price-calculation-handler, price-calculation-setup, integration-event, pricing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Activate a new Price Calculation Handler through OnFindSupportedSetup, not just by implementing it + +## Description + +`enum 7011 "Price Calculation Handler"` (`implements "Price +Calculation"`) is how a new pricing engine plugs into Business Central — +extend the enum with a value pointing at a codeunit that implements the +`Price Calculation` interface. That alone does not make the new handler +usable on any document. `codeunit 7001 "Price Calculation Mgt."` decides +which handler applies to a given line by looking up `table 7006 "Price +Calculation Setup"`, a table of `(Code, Method, Type, Asset Type, +Implementation, Enabled, Default)` rows populated at startup by its own +`OnFindSupportedSetup` event — every implementation codeunit is expected +to subscribe to that event and insert its own setup row(s). A handler +enum value with no matching setup row is real and selectable in the enum +itself, but never chosen for any actual sale, purchase, or job line, +because `Price Calculation Mgt.` has no setup row that names it. + +`FindSetup` resolves a handler in two stages, and only the second one +looks at `Default`. It first asks `codeunit 7004 "Price Calculation Dtld. +Setup"` to match the line against `table 7008 "Dtld. Price Calculation +Setup"` ("Detailed Price Calculation Setup", keyed to an exact +`Method`/`Type`/`Asset Type`/`Source`/`Asset No.` combination via its own +`"Setup Code"`); on a match it does `PriceCalculationSetup.Get(... +"Setup Code")` directly, with no `Default` filter. Only when no detailed +row matches does it fall back to `SetRange(Default, true)` plus +`SetRange(Method, ...)` to pick the one catch-all row for that +combination. A row without `Default := true` is invisible to *that* +fallback, but not invisible outright — a detailed-setup row can still +select it by naming its `Code`. A row whose `Method` matches neither path +is invisible either way — same symptom, different cause. + +## Best Practice + +Ship a new `Price Calculation Handler` value together with an +`OnFindSupportedSetup` subscriber that inserts at least one `Price +Calculation Setup` record naming it as the `Implementation`, for the +relevant `Method` (e.g. `"Lowest Price"`), `Type` (`Sale`/`Purchase`), and +`Asset Type`. `Default := true` is required only when this row is the +*fallback* for that combination — the row `FindSetup`'s own +`SetRange(Default, true)` branch selects when no more specific setup +applies. A handler meant to be selected only for specific customers or +items should instead be reachable through a matching `"Dtld. Price +Calculation Setup"` row; `FindSetup` resolves that before it ever checks +`Default`, so it needs no `Default := true`. + +See sample: [`activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al`](activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al). + +## Anti Pattern + +Extending `Price Calculation Handler` and implementing the `Price +Calculation` interface, without subscribing to `OnFindSupportedSetup` to +insert a setup record. The new handler exists, compiles, and can even be +selected manually if a user creates their own `Price Calculation Setup` +row through the UI — but ships with no default row, so it's never active +for anyone until someone notices it's missing and configures it by hand. + +See sample: [`activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al`](activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al). + +## Source + +BCApps (`src/Layers/W1/BaseApp/Pricing/Calculation/`): +`PriceCalculationHandler.Enum.al` (`enum 7011 "Price Calculation Handler" +implements "Price Calculation"`); `PriceCalculationMgt.Codeunit.al` +(`OnFindSupportedSetup(var TempPriceCalculationSetup: Record "Price +Calculation Setup" temporary)`, and `FindSetup(...): Boolean`, which +first calls `PriceCalculationDtldSetup.FindSetup(DtldPriceCalcSetup)` and +on a match does `PriceCalculationSetup.Get(... "Setup Code")` with no +`Default` filter — only on failure does it fall back to +`SetRange(Enabled, true)`, `SetRange(Default, true)`, `SetRange(Method, +...)`); `PriceCalculationSetup.Table.al` (`table 7006 "Price Calculation +Setup"`: `Code`, `Method`, `Type`, `"Asset Type"`, `Implementation`, +`Enabled`, `Default`); `PriceCalculationDtldSetup.Codeunit.al` (`codeunit +7004 "Price Calculation Dtld. Setup"`, `FindSetup(var DtldPriceCalcSetup: +Record "Dtld. Price Calculation Setup"): Boolean`, matching progressively +looser `Source Group`/`Source No.`/`Asset Type`/`Asset No.` combinations — +never `Default`); `DtldPriceCalculationSetup.Table.al` (`table 7008 "Dtld. +Price Calculation Setup"`, Caption "Detailed Price Calculation Setup", +`"Setup Code"` relates to `"Price Calculation Setup".Code where(Enabled = +const(true))` — no `Default` condition). + +Microsoft Learn, "Extending Price Calculations": "Each codeunit that +implements the Price Calculation interface must subscribe to the +OnFindSupportedSetup() event... to fill the price calculation setup +table." Same article: "You can enter detailed setup records for +non-default setup lines... If a matching setup is found its +implementation is used... If there is no matching setup exception, we +use the default implementation." +(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations) diff --git a/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al new file mode 100644 index 0000000..fbff442 --- /dev/null +++ b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al @@ -0,0 +1,20 @@ +codeunit 50102 "Sample Posted Invoice Send" +{ + procedure SendPostedInvoice(SalesInvoiceHeader: Record "Sales Invoice Header") + var + Customer: Record Customer; + begin + Customer.Get(SalesInvoiceHeader."Bill-to Customer No."); + Customer.TestField("E-Mail"); + + // WRONG: the report is hardcoded instead of resolved through the + // registered "S.Invoice" usage in Report Selections. This alone is + // the defect - no hand-built email is needed for it: a Report + // Selections row or a per-customer "Document Layouts" override + // that points this usage at a different report or layout is + // silently ignored, and the only way to change what this code + // prints is a code change and a new release. + SalesInvoiceHeader.SetRecFilter(); + Report.RunModal(Report::"Standard Sales - Invoice", false, false, SalesInvoiceHeader); + end; +} diff --git a/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al new file mode 100644 index 0000000..f19b00a --- /dev/null +++ b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al @@ -0,0 +1,31 @@ +codeunit 50102 "Sample Posted Invoice Send" +{ + procedure SendPostedInvoice(SalesInvoiceHeader: Record "Sales Invoice Header") + var + ReportSelections: Record "Report Selections"; + ReportDistributionMgt: Codeunit "Report Distribution Management"; + begin + // Custom validation specific to this dispatch stays here... + CheckReadyToSend(SalesInvoiceHeader); + + // ...but dispatch goes through the registered usage. "S.Invoice" + // resolves to a report built on "Sales Invoice Header" (by default + // report 1306 "Standard Sales - Invoice"), so the record passed in + // matches what the selected report expects, and per-account + // report/layout overrides and email attachment/body configuration + // on Report Selections all apply automatically. + SalesInvoiceHeader.SetRecFilter(); + ReportSelections.SendEmailToCust( + "Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, SalesInvoiceHeader."No.", + ReportDistributionMgt.GetFullDocumentTypeText(SalesInvoiceHeader), true, + SalesInvoiceHeader."Bill-to Customer No."); + end; + + local procedure CheckReadyToSend(SalesInvoiceHeader: Record "Sales Invoice Header") + var + Customer: Record Customer; + begin + Customer.Get(SalesInvoiceHeader."Bill-to Customer No."); + Customer.TestField("E-Mail"); + end; +} diff --git a/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md new file mode 100644 index 0000000..f6b15f9 --- /dev/null +++ b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md @@ -0,0 +1,69 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [report-selections, document-layouts, custom-report-layout, email-attachment, bespoke-dispatch] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Custom document dispatch must not bypass Report Selections + +## Description + +A codeunit that hardcodes which report to run (`Report.RunModal(MyReportId, ...)`), +or builds its own email directly, instead of registering the document +through `table 77 "Report Selections"` and calling its own +Print/Email procedures, works for the one case it was written for — and +loses everything the platform's registry provides for free. Either +bypass is a defect on its own: a hardcoded report ignores the registered +report and any per-account layout override even when no email is +involved, and a hand-built email ignores the registry's attachment and +email-body configuration even when the report itself came from it. `Report +Selections` carries its own attachment/email-body configuration per usage +(`"Use for Email Attachment"`, `"Use for Email Body"`, `"Email Body Layout +Code"`, `"Email Body Layout Type"`), plus a separate per-usage layout +override, `"Custom Report Layout Code"`, and +`table 9657 "Custom Report Selection"` (the "Document Layouts" page on the +Customer/Vendor card) lets one specific account override the report or +layout without touching code at all. None of that exists for a document +whose dispatch was hand-rolled: there is no registry row to point +"Document Layouts" at, so an admin who goes looking for where to change +this document's layout — the same place they'd look for every other +document in the system — finds nothing, because the document was never +registered there. + +## Best Practice + +Register the document under a `Report Selection Usage` value (see +`extend-report-selection-usage-for-new-document-types.md`) and dispatch +through `Report Selections`' own Print/Email procedures (see +`document-print-and-email-actions-call-report-selections-directly.md`), +even when the surrounding business logic — which counterparty to use, +what validation must pass before sending — is genuinely specific to the +document. Custom logic belongs around the call to `Report Selections`, +not instead of it. + +See sample: [`custom-document-dispatch-must-not-bypass-report-selections.good.al`](custom-document-dispatch-must-not-bypass-report-selections.good.al). + +## Anti Pattern + +A codeunit that runs a hardcoded report ID, or builds its own email +message directly, for a document that has (or should have) a +`Report Selections` usage — each is independently a bypass, and the +sample shows the first on its own. It works for the default case, but the report/layout cannot be changed per account +without a code change and a new release, and the document is invisible to +"Document Layouts" — the standard place every other document's +distribution is configured. + +See sample: [`custom-document-dispatch-must-not-bypass-report-selections.bad.al`](custom-document-dispatch-must-not-bypass-report-selections.bad.al). + +## Source + +BCApps `ReportSelections.Table.al` (table 77 — field 7, +`"Custom Report Layout Code"`; fields 19–26 for email attachment/body +configuration; `SendEmailToCust`/`PrintWithDialogForCust` as the +registry-backed dispatch entry points) and +`CustomReportSelection.Table.al` (table 9657, the per-account override +backing the "Document Layouts" page) — both under +`src/Layers/W1/BaseApp/Foundation/Reporting/`. diff --git a/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al new file mode 100644 index 0000000..a8799c0 --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al @@ -0,0 +1,45 @@ +page 50101 "Sample Posted Invoice Card" +{ + PageType = Card; + SourceTable = "Sales Invoice Header"; + ApplicationArea = All; + Editable = false; + + actions + { + area(Processing) + { + action(EmailDocument) + { + ApplicationArea = All; + Caption = 'Email'; + Image = Email; + + trigger OnAction() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + DocumentSendingProfile: Record "Document Sending Profile"; + ReportDistributionMgt: Codeunit "Report Distribution Management"; + begin + // WRONG: this is a plain, on-demand "Email" button, not + // part of a combined Post-and-Send action - but this + // loads the customer's ACTUAL assigned profile (or the + // tenant default, if none is assigned - the same lookup + // Sales-Post and Send performs) and calls Send on it, so + // the outcome now silently depends on that profile. A + // profile set up for Post-and-Send printing only (say, + // Printer = Yes, "E-Mail" = No) turns this button into a + // silent no-op, with no indication an unrelated setup + // field is why. + SalesInvoiceHeader := Rec; + CurrPage.SetSelectionFilter(SalesInvoiceHeader); + DocumentSendingProfile.GetDefaultForCustomer(Rec."Bill-to Customer No.", DocumentSendingProfile); + DocumentSendingProfile.Send( + "Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, Rec."No.", + Rec."Bill-to Customer No.", ReportDistributionMgt.GetFullDocumentTypeText(Rec), + SalesInvoiceHeader.FieldNo("Bill-to Customer No."), SalesInvoiceHeader.FieldNo("No.")); + end; + } + } + } +} diff --git a/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al new file mode 100644 index 0000000..c68724b --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al @@ -0,0 +1,63 @@ +page 50101 "Sample Posted Invoice Card" +{ + PageType = Card; + SourceTable = "Sales Invoice Header"; + ApplicationArea = All; + Editable = false; + + actions + { + area(Processing) + { + action(EmailDocument) + { + ApplicationArea = All; + Caption = 'Email'; + Image = Email; + + trigger OnAction() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + ReportSelections: Record "Report Selections"; + ReportDistributionMgt: Codeunit "Report Distribution Management"; + begin + // Calls Report Selections directly - the button's outcome + // depends only on this customer's registered report/layout, + // not on any Document Sending Profile setting. Calling + // DocumentSendingProfile.TrySendToEMail(...) instead would + // also be correct, because it never reads the customer's + // assigned profile: it only uses a local record that it + // never retrieves with Get, and sets its "E-Mail" option + // itself. The + // anti-pattern is Get/GetDefaultForCustomer followed by + // Send, which makes the outcome depend on that profile. + // "S.Invoice" resolves to a report on "Sales Invoice + // Header", which is the record passed here. + SalesInvoiceHeader := Rec; + CurrPage.SetSelectionFilter(SalesInvoiceHeader); + ReportSelections.SendEmailToCust( + "Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, Rec."No.", + ReportDistributionMgt.GetFullDocumentTypeText(Rec), true, Rec."Bill-to Customer No."); + end; + } + action(PrintDocument) + { + ApplicationArea = All; + Caption = 'Print'; + Image = Print; + + trigger OnAction() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + ReportSelections: Record "Report Selections"; + begin + SalesInvoiceHeader := Rec; + CurrPage.SetSelectionFilter(SalesInvoiceHeader); + ReportSelections.PrintWithDialogForCust( + "Report Selection Usage"::"S.Invoice", SalesInvoiceHeader, true, + SalesInvoiceHeader.FieldNo("Bill-to Customer No.")); + end; + } + } + } +} diff --git a/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md new file mode 100644 index 0000000..f331619 --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md @@ -0,0 +1,100 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [report-selections, document-sending-profile, print, email, post-and-send] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A document's own Print/Email actions call Report Selections directly; Document Sending Profile is scoped to Post-and-Send + +## Description + +`table 60 "Document Sending Profile"` is not a general gateway for every +print/email path — it exists specifically for the combined **Post and +Send** action: "You can set each customer up with a preferred method of +sending sales documents, so that you do not have to select a sending +option every time you choose the Post and Send action" (Microsoft Learn, +"Set Up Document Sending Profiles"). A document's own, ordinary +Print/Email actions are unaffected by any *configured* profile either +way: the unposted Sales Order's "Print Confirmation"/"Email +Confirmation" (`codeunit "Document-Print"`, +`PrintSalesOrder`/`EmailSalesHeader`) and the posted `Purch. Inv. +Header`'s `PrintRecords` call `Report Selections` literally directly +(`PrintWithDialogForCust`/`SendEmailToCust`/`PrintWithDialogForVend`), +while the posted `Sales Invoice Header`'s `PrintRecords`/`EmailRecords` +and the unposted `Purchase Header`'s `PrintRecords` go through +`DocumentSendingProfile.TrySendToPrinter`/`TrySendToEMail`/ +`TrySendToPrinterVendor` instead. Those three helpers each declare a +fresh, local, never-`Get`'d profile record, hardcode its +`Printer`/`"E-Mail"` field to a "Yes" option themselves, and feed it into +`SendToPrinter`/`SendToEMailGroupedMultipleSelection` — which resolve +into Report Selections just like the direct route. The table is a +throwaway options carrier here, not the counterparty's configuration. + +Only a genuinely configured profile changes the outcome, and that only +happens for the combined Post-and-Send flow: `Sales-Post and Send` loads +the customer's assigned profile (`Get(Customer."Document Sending +Profile")`, or the tenant default) before `Sales Invoice +Header.SendProfile` → `DocumentSendingProfile.Send`, which gates +`SendToPrinter`/`SendToEMail`/`SendToDisk` on whatever that record holds. + +Whether a document needs outbound distribution isn't determined by +Customer vs. Vendor, but by whether it's genuinely *outbound* to that +party: a posted Purchase Invoice records what a vendor already billed, +so the posted `Purch. Inv. Header` has only a bare `PrintRecords`; a +Purchase *Order* is still outbound before posting, so the rich +`SendProfile`/`SendRecords`/`PrintRecords` triplet lives there instead. + +## Best Practice + +For a document's own interactive Print/Email actions, either call the +relevant `Report Selections` procedure directly — +`PrintForCust`/`PrintWithDialogForCust`/`SendEmailToCust` for a +customer-facing document, `PrintWithDialogForVend`/`SendEmailToVendor` +for a vendor-facing one — or call one of `Document Sending Profile`'s +stateless `TrySendToPrinter`/`TrySendToEMail`/`TrySendToPrinterVendor` +helpers, using the usage value registered per +`extend-report-selection-usage-for-new-document-types.md`. Both are +equally correct; neither reads the counterparty's assigned profile. +Reserve a genuine `Get`/`GetDefaultForCustomer`/`GetDefaultForVendor` +lookup and `Send`/`SendVendor` for Post-and-Send. + +See sample: [`document-print-and-email-actions-call-report-selections-directly.good.al`](document-print-and-email-actions-call-report-selections-directly.good.al). + +## Anti Pattern + +Loading the counterparty's *actually assigned* `Document Sending +Profile` (or the tenant default, via `Get`/`GetDefaultForCustomer`/ +`GetDefaultForVendor` — the same lookup `Sales-Post and Send` performs) +and calling `Send`/`SendVendor` on it from a plain, on-demand "Email" +button, instead of `ReportSelections.SendEmailToCust`/`SendEmailToVendor` +directly. The button's outcome now silently depends on a profile +configured for Post-and-Send — if its `"E-Mail"` option is `No`, +clicking "Email" does nothing observable. A second version of the same +mistake: an email action on a document that only receives from its +counterparty and was never meant to send anything back. + +See sample: [`document-print-and-email-actions-call-report-selections-directly.bad.al`](document-print-and-email-actions-call-report-selections-directly.bad.al). + +## Source + +BCApps `DocumentPrint.Codeunit.al` (`EmailSalesHeader`/`DoPrintSalesHeader`/ +`PrintSalesOrder` → `ReportSelections.SendEmailToCust`/`PrintForCust`/ +`PrintWithDialogForCust` directly), `SalesInvoiceHeader.Table.al` +(`PrintRecords`/`EmailRecords`, lines 1453/1528 → `TrySendToPrinter`/ +`TrySendToEMail`, lines 1462/1541, on a local never-`Get`'d record), +`PurchaseHeader.Table.al` (`PrintRecords` line 6357 → +`TrySendToPrinterVendor` line 6374; `SendProfile` line 6387 → +`SendVendor` line 6403), `PurchInvHeader.Table.al` (`PrintRecords` → +`ReportSelection.PrintWithDialogForVend` directly, no send capability), +`SalesPostandSend.Codeunit.al`/`SalesPost.Codeunit.al` +(`ConfirmPostAndSend` loads `Get(Customer."Document Sending +Profile")`/`GetDefault`; `SendPostedDocumentRecord` line 7660 → +`SalesInvHeader.SendProfile` lines 7680/7699 → +`DocumentSendingProfile.Send`), `DocumentSendingProfile.Table.al` (table +60; `TrySendToPrinter`/`TrySendToEMail` lines 536/562, +`TrySendToPrinterVendor` line 552, `GetDefaultForCustomer` line 195, +`Send`/`SendVendor` lines 482/506) — all under `src/Layers/W1/BaseApp/`. +Microsoft Learn, "Set Up Document Sending Profiles": https://learn.microsoft.com/dynamics365/business-central/sales-how-setup-document-send-profiles diff --git a/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al new file mode 100644 index 0000000..486eee8 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al @@ -0,0 +1,35 @@ +table 50104 "Sample Posted Document Header" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { Caption = 'No.'; } + field(2; "Posting Date"; Date) { Caption = 'Posting Date'; } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +codeunit 50103 "Sample Navigate Subscribers" +{ + // WRONG: registers the row, so it appears in the Find Entries result + // list with a correct table name and record count - but there is no + // OnBeforeShowRecords subscriber for this table. ShowRecords()'s own + // case statement has no branch and no else for it either, so + // selecting this row and choosing "Show records" does nothing, + // silently, with no error. + [EventSubscriber(ObjectType::Page, Page::Navigate, 'OnAfterFindRecords', '', false, false)] + local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text) + var + SampleDocHeader: Record "Sample Posted Document Header"; + begin + SampleDocHeader.SetFilter("No.", DocNoFilter); + SampleDocHeader.SetFilter("Posting Date", PostingDateFilter); + DocumentEntry.InsertIntoDocEntry( + Database::"Sample Posted Document Header", SampleDocHeader.TableCaption(), SampleDocHeader.Count()); + end; +} diff --git a/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al new file mode 100644 index 0000000..c0d658b --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al @@ -0,0 +1,68 @@ +table 50104 "Sample Posted Document Header" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { Caption = 'No.'; } + field(2; "Posting Date"; Date) { Caption = 'Posting Date'; } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50104 "Sample Posted Document" +{ + PageType = Card; + SourceTable = "Sample Posted Document Header"; + UsageCategory = None; + ApplicationArea = All; + + layout + { + area(Content) + { + field("No."; Rec."No.") { ApplicationArea = All; } + field("Posting Date"; Rec."Posting Date") { ApplicationArea = All; } + } + } +} + +codeunit 50103 "Sample Navigate Subscribers" +{ + [EventSubscriber(ObjectType::Page, Page::Navigate, 'OnAfterFindRecords', '', false, false)] + local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text) + var + SampleDocHeader: Record "Sample Posted Document Header"; + begin + SampleDocHeader.SetFilter("No.", DocNoFilter); + SampleDocHeader.SetFilter("Posting Date", PostingDateFilter); + DocumentEntry.InsertIntoDocEntry( + Database::"Sample Posted Document Header", SampleDocHeader.TableCaption(), SampleDocHeader.Count()); + end; + + // Without this second subscriber, the row added above shows up in the + // Find Entries result list with a correct count, but "Show records" + // has nothing to open it with - see the .bad.al sample. + [EventSubscriber(ObjectType::Page, Page::Navigate, 'OnBeforeShowRecords', '', false, false)] + local procedure OnBeforeShowRecords(var TempDocumentEntry: Record "Document Entry" temporary; DocNoFilter: Text; PostingDateFilter: Text; ItemTrackingSearch: Boolean; ContactNo: Code[250]; ExtDocNo: Code[250]; var IsHandled: Boolean) + var + SampleDocHeader: Record "Sample Posted Document Header"; + begin + if TempDocumentEntry."Table ID" <> Database::"Sample Posted Document Header" then + exit; + + SampleDocHeader.SetFilter("No.", DocNoFilter); + SampleDocHeader.SetFilter("Posting Date", PostingDateFilter); + if TempDocumentEntry."No. of Records" = 1 then begin + SampleDocHeader.FindFirst(); + Page.Run(Page::"Sample Posted Document", SampleDocHeader); + end else + Page.Run(0, SampleDocHeader); + + IsHandled := true; + end; +} diff --git a/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md new file mode 100644 index 0000000..468756e --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md @@ -0,0 +1,93 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [navigate, find-entries, document-entry, integration-event, drill-down] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Extend Find Entries (Navigate) for new document or transaction tables + +## Description + +`page 344 Navigate` (caption "Find entries") lets a user enter a document +number and posting date and see, across every document and ledger entry +table BC knows about, how many matching records exist — then drill into +any of those rows. It works over a temporary `table "Document Entry"` +that gets populated, one row per source table, by dozens of separate +lookups hardcoded into the page (`Rec.InsertIntoDocEntry(Database::"Sales +Invoice Header", ...)` and similar, one per table). A new custom document +or transaction table is invisible to Find Entries by default — nobody +searching by document number will ever see it in the result list — until +it registers itself. + +Registration is a two-sided integration event, and only implementing one +side produces a page that is worse than not participating at all. The +`OnAfterFindRecords` event lets a subscriber add a row to the result list +for a custom table. But the subsequent "show records" action, `procedure +ShowRecords`, resolves which page to open through its own hardcoded `case +Rec."Table ID" of` — the same shape as the row-population code, and just +as unaware of any table added by an extension. That `case` statement has +no `else` branch. A custom table's row can appear in the result list, +with a correct count, and be entirely un-clickable: the user selects it, +chooses "Show records", and nothing happens, silently. + +## Best Practice + +Subscribe to both `Navigate::OnAfterFindRecords` and +`Navigate::OnBeforeShowRecords` together, as one unit of work, for any +custom table that should be searchable by document number: + +- In `OnAfterFindRecords`, filter the custom table by the given + `DocNoFilter`/`PostingDateFilter` and call + `DocumentEntry.InsertIntoDocEntry(Database::"My Table", TableCaption, + Count)` to add it to the result list. +- In `OnBeforeShowRecords`, check whether + `TempDocumentEntry."Table ID" = Database::"My Table"`; if so, re-apply + the same filters, open the appropriate card or list page, and set + `IsHandled := true` so the page's own unrelated `case` statement is + never reached for this table. +- If `OnAfterFindRecords` filters the custom table by a field that is not + already that table's own unique key — for example an external + reference number received from a counterparty, rather than the + table's own `No.` — add a key combining that field with `Posting Date`, + the same way BCApps does for `Purch. Inv. Header`'s `"Vendor Invoice + No."` (see Source). This does not apply when filtering the table's own + primary key, which is already unique on its own: `Sales Invoice + Header` filters `"No."` and `"Posting Date"` through two separate, + uncombined keys, with no compound key between them, because `"No."` + alone is already sufficient. + +See sample: [`extend-find-entries-navigate-for-new-document-types.good.al`](extend-find-entries-navigate-for-new-document-types.good.al). + +## Anti Pattern + +Subscribing only to `OnAfterFindRecords` (or only to +`OnBeforeShowRecords`). Registering the row without handling its +drill-down produces a search result that looks complete — the table name +and a correct record count both show up — but leads nowhere when +selected, with no error and no indication to the user that anything is +wrong. + +See sample: [`extend-find-entries-navigate-for-new-document-types.bad.al`](extend-find-entries-navigate-for-new-document-types.bad.al). + +## Source + +BCApps `Navigate.Page.al` (page 344, `src/Layers/W1/BaseApp/Foundation/Navigate/`): +- `[IntegrationEvent(true, false)] local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)` +- `[IntegrationEvent(true, false)] local procedure OnBeforeShowRecords(var TempDocumentEntry: Record "Document Entry" temporary; DocNoFilter: Text; PostingDateFilter: Text; ItemTrackingSearch: Boolean; ContactNo: Code[250]; ExtDocNo: Code[250]; var IsHandled: Boolean)` +- `procedure ShowRecords()`'s `case Rec."Table ID" of ... end;` has no `else` branch — confirmed by reading the full case block, which ends directly with `end;` followed by `OnAfterShowRecords(...)`. + +BCApps `DocumentEntry.Table.al` (table backing page 344): +`procedure InsertIntoDocEntry(DocTableID: Integer; DocTableName: Text; DocNoOfRecords: Integer)` — the registration entry point called from `OnAfterFindRecords` subscribers. + +BCApps `SalesInvoiceHeader.Table.al` (`src/Layers/W1/BaseApp/Sales/History/`): +`key(Key1; "No.")` (`Clustered = true`) and `key(Key9; "Posting Date")` are +two separate, uncombined keys — no compound key exists between them. + +BCApps `PurchInvHeader.Table.al` (`src/Layers/W1/BaseApp/Purchases/History/`): +`key(Key4; "Vendor Invoice No.", "Posting Date")` — a compound key +combining a non-unique, externally-supplied reference number with +`Posting Date`, distinct from `key(Key1; "No.")`, its own unique primary +key. diff --git a/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al new file mode 100644 index 0000000..0435992 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al @@ -0,0 +1,14 @@ +enumextension 50100 "Sample Price Source Ext" extends "Price Source Type" +{ + value(50100; "Sample.LoyaltyTier") + { + Caption = 'Loyalty Tier'; + Implementation = "Price Source" = "Price Source - Customer", "Price Source Group" = "Price Source Group - Customer"; + } +} + +// WRONG: no matching value was added to "Sales Price Source Type" (or the +// purchase/job equivalents). "Sample.LoyaltyTier" compiles, installs, and +// is a real value on "Price Source Type" - it just never appears as an +// Applies-to Type option on the Sales Price List page, because that page +// is driven by the separate subset enum, not the base one. diff --git a/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al new file mode 100644 index 0000000..8acd6c6 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al @@ -0,0 +1,19 @@ +enumextension 50100 "Sample Price Source Ext" extends "Price Source Type" +{ + value(50100; "Sample.LoyaltyTier") + { + Caption = 'Loyalty Tier'; + Implementation = "Price Source" = "Price Source - Customer", "Price Source Group" = "Price Source Group - Customer"; + } +} + +enumextension 50101 "Sample Sales Price Source Ext" extends "Sales Price Source Type" +{ + // Same numeric ID (50100) as the Price Source Type value above. That + // match is what makes "Sample.LoyaltyTier" show up as a selectable + // Applies-to Type on an actual sales price list. + value(50100; "Sample.LoyaltyTier") + { + Caption = 'Loyalty Tier'; + } +} diff --git a/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md new file mode 100644 index 0000000..02e6287 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md @@ -0,0 +1,68 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [price-calculation, price-source, price-source-type, enumextension, pricing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Extend Price Source Type and its matching document subset enum together, with the same ID + +## Description + +`enum 7003 "Price Source Type"` (`implements "Price Source", "Price Source +Group"`) is the base list of who a price can apply to — Customer, Vendor, +Customer Price Group, Campaign, and so on. It is not, by itself, what +drives the "Applies-to Type" field on an actual sales, purchase, or job +price list. Each document area has its own subset enum — +`enum 7006 "Sales Price Source Type"`, the equivalent purchase and job +enums — and these are what the price list pages actually expose. Every +value the two enums share today uses the identical numeric ID: `All +Customers`/`Customer`/`Customer Price Group`/`Customer Disc. +Group`/`Campaign`/`Contact` are 10/11/12/13/50/51 in both `Price Source +Type` and `Sales Price Source Type`. + +Adding a new value to `Price Source Type` alone does nothing for a sales +price list: the base enum and the document subset enum are two separate +extensible enums, linked only by convention, not by any platform +mechanism that keeps their IDs in sync. Give the new value a different ID +in each enum, or extend only the base enum, and the source is real and +selectable in some contexts (the base enum is used elsewhere, such as +the generic `Price Source` table) but absent from the specific document +price list a developer actually tested against. + +## Best Practice + +When a new price source should be usable in a sales, purchase, or job +price list, extend `Price Source Type` and the matching document subset +enum (`Sales Price Source Type`, `Purchase Price Source Type`, `Job Price +Source Type`) together, using the identical numeric ID in both. + +See sample: [`extend-price-source-type-must-sync-document-subset-enum.good.al`](extend-price-source-type-must-sync-document-subset-enum.good.al). + +## Anti Pattern + +Extending `Price Source Type` with a new value intended for sales price +lists, without extending `Sales Price Source Type` with a value of the +same ID — or giving it a different ID. Either way, the new source is +absent from the "Applies-to Type" options on an actual sales price list, +with no error anywhere: the base enum extension compiles and installs +cleanly on its own. + +See sample: [`extend-price-source-type-must-sync-document-subset-enum.bad.al`](extend-price-source-type-must-sync-document-subset-enum.bad.al). + +## Source + +BCApps (`src/Layers/W1/BaseApp/`): `Pricing/Source/PriceSourceType.Enum.al` +(`enum 7003 "Price Source Type"`, values `10/11/12/13/50/51` for `All +Customers`/`Customer`/`Customer Price Group`/`Customer Disc. +Group`/`Campaign`/`Contact`) and `Sales/Pricing/SalesPriceSourceType.Enum.al` +(`enum 7006 "Sales Price Source Type"`, the same six values at the same +six IDs). Microsoft Learn, "Extending Price Calculations": "The Price +Source Type enum implements the Applies-to Type field in the header of +the price list. Additionally, the Sales Price Source Type, Purchase Price +Source Type, and Job Price Source Type are subsets of the Price Source +Type enum... For compatibility, the new value must have the same ID in +both enums." +(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations) diff --git a/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al new file mode 100644 index 0000000..d09d34a --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al @@ -0,0 +1,47 @@ +enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selection Usage" +{ + value(50100; "Sample.SettlementDoc") + { + Caption = 'Sample Settlement Document'; + } +} + +report 50100 "Sample Settlement Document" +{ + UsageCategory = ReportsAndAnalysis; + ApplicationArea = All; + + dataset + { + dataitem(Customer; Customer) + { + column(No_Customer; "No.") { } + } + } +} + +codeunit 50100 "Sample Report Selection Install" +{ + procedure InstallDefaultReportSelection() + var + ReportSelections: Record "Report Selections"; + begin + ReportSelections.InsertRecord( + "Report Selection Usage"::"Sample.SettlementDoc", '1', Report::"Sample Settlement Document"); + // Registration ends here. No enumextension was added to + // "Custom Report Selection Sales" (or "Report Selection Usage + // Vendor"), and no subscriber was added to + // OnAfterOnMapTableUsageValueToPageValue, OnValidateUsage2OnCaseElse, + // or OnAfterFilterCustomerUsageReportSelections / + // OnAfterFilterVendorUsageReportSelections. + // + // The tenant-wide default works, so the gap isn't visible in + // testing - but on the Document Layouts page for a specific + // customer or vendor: an existing row for this usage shows blank in + // the Usage column (no map event), a user cannot pick this usage + // from the Usage dropdown at all (no validate event and no + // page-facing enum value to pick), and "Copy from Report Selection" + // never lists it either (no filter event). No error, no visible + // sign that anything is missing. + end; +} diff --git a/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al new file mode 100644 index 0000000..065e802 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al @@ -0,0 +1,87 @@ +enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selection Usage" +{ + value(50100; "Sample.SettlementDoc") + { + Caption = 'Sample Settlement Document'; + } +} + +// This document is only ever issued to a customer, so only the customer-side +// page-facing enum is extended - not the vendor-side one too. This mirrors +// BCApps' ReportSelectionHandlerCZZ, which extends "Custom Report Selection +// Sales" for its customer-only usages and "Report Selection Usage Vendor" +// for its vendor-only usages, never both for the same one-sided value. +enumextension 50101 "Sample Cust. Rep. Sel. Sales Ext" extends "Custom Report Selection Sales" +{ + value(50100; "Sample.SettlementDoc") + { + Caption = 'Sample Settlement Document'; + } +} + +report 50100 "Sample Settlement Document" +{ + UsageCategory = ReportsAndAnalysis; + ApplicationArea = All; + + dataset + { + dataitem(Customer; Customer) + { + column(No_Customer; "No.") { } + } + } +} + +codeunit 50100 "Sample Report Selection Install" +{ + procedure InstallDefaultReportSelection() + var + ReportSelections: Record "Report Selections"; + begin + ReportSelections.InsertRecord( + "Report Selection Usage"::"Sample.SettlementDoc", '1', Report::"Sample Settlement Document"); + end; +} + +codeunit 50101 "Sample Report Selection Subscribers" +{ + // Customer-only document: all three subscribers below are on + // "Customer Report Selections" only. There are no matching subscribers + // on "Vendor Report Selections" - subscribing there too would be the + // overbroad mistake this sample avoids (see the .bad.al companion and + // the article's Anti Pattern #2). + + // 1) Map: lets an existing row display in the Usage column instead of + // showing blank. + [EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnAfterOnMapTableUsageValueToPageValue', '', false, false)] + local procedure AddSampleUsageOnAfterOnMapTableUsageValueToPageValue(var Usage2: Enum "Custom Report Selection Sales"; CustomReportSelection: Record "Custom Report Selection") + begin + if CustomReportSelection.Usage = "Report Selection Usage"::"Sample.SettlementDoc" then + Usage2 := "Custom Report Selection Sales"::"Sample.SettlementDoc"; + end; + + // 2) Validate: lets a user pick the new value from the Usage dropdown. + [EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnValidateUsage2OnCaseElse', '', false, false)] + local procedure AddSampleUsageOnValidateUsage2OnCaseElse(var CustomReportSelection: Record "Custom Report Selection"; ReportUsage: Option) + begin + if ReportUsage = "Custom Report Selection Sales"::"Sample.SettlementDoc".AsInteger() then + CustomReportSelection.Usage := "Report Selection Usage"::"Sample.SettlementDoc"; + end; + + // 3) Filter: wires "Copy from Report Selection" - the piece most + // guidance stops at, appending to whatever filter already exists rather + // than replacing it. + [EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnAfterFilterCustomerUsageReportSelections', '', false, false)] + local procedure AddSampleUsageOnAfterFilterCustomerUsageReportSelections(var ReportSelections: Record "Report Selections") + begin + ReportSelections.SetFilter(Usage, GetUsageFilter(ReportSelections)); + end; + + local procedure GetUsageFilter(var ReportSelections: Record "Report Selections") UsageFilter: Text + begin + UsageFilter := Format("Report Selection Usage"::"Sample.SettlementDoc"); + if ReportSelections.GetFilter(Usage) <> '' then + UsageFilter := StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage), UsageFilter); + end; +} diff --git a/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md new file mode 100644 index 0000000..3750645 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md @@ -0,0 +1,99 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [report-selections, report-selection-usage, enumextension, document-layouts, custom-report-selection] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register a new document type through Report Selections, and wire it into Document Layouts correctly + +## Description + +A custom document that needs printing/emailing should be registered +through `table 77 "Report Selections"`. `enum 77 "Report Selection Usage"` +is `Extensible = true` for exactly this: add a value via `enumextension`, +then `ReportSelections.InsertRecord(Usage, Sequence, ReportID)` for a +tenant-wide default — the mechanism every standard document uses. + +That alone does not make the value usable in "Document Layouts" +(`page 9657 "Customer Report Selections"` / `page 9658 "Vendor Report +Selections"`, table 9657 "Custom Report Selection"). Both pages hide +`enum 77` behind their own page-facing enum — `enum 9657 "Custom Report +Selection Sales"` (customer) / `enum 9658 "Report Selection Usage Vendor"` +(vendor) — in a field named `Usage2`. A new value stays invisible there +until that page enum is extended too and three events are handled: +`OnAfterOnMapTableUsageValueToPageValue` / `OnMapTableUsageValueToPage +ValueOnCaseElse` (Usage column display), `OnValidateUsage2OnCaseElse` +(picking it from the dropdown), and `OnAfterFilterCustomerUsageReport +Selections` / `OnAfterFilterVendorUsageReportSelections` (the **"Copy from +Report Selection"** action only — a hardcoded-list filter, nothing more). + +Which side(s) need this depends on the counterparty the document actually +applies to — not "always both." BCApps' `ReportSelectionHandlerCZZ` +(Advance Payments) partitions strictly: `"Sales Advance..."` usages get +only the customer-side triad, `"Purchase Advance..."` only the vendor-side +triad. `ReportSelectionHandlerCZC` (Compensation) subscribes both sides — +legitimately, since that document posts to both ledgers, not by default. + +## Best Practice + +1. Add the usage value (`enumextension ... extends "Report Selection + Usage"`) and register the tenant-wide default. +2. Decide which counterparty(ies) apply — customer, vendor, or both. +3. For each applicable side, extend the matching page enum + (`"Custom Report Selection Sales"` / `"Report Selection Usage Vendor"`) + and subscribe to that page's map, validate, and filter events — + appending with `StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage), + UsageFilter)`, never overwriting. +4. Do not subscribe the other side for a one-sided document: skip the + triad and the value is unreachable in Document Layouts; wire both sides + needlessly and the picker is cluttered with a value that never applies. + +See sample: [`extend-report-selection-usage-for-new-document-types.good.al`](extend-report-selection-usage-for-new-document-types.good.al) +(customer-only document — only the customer-side enum and triad added). + +## Anti Pattern + +1. Register the usage value but add no page-enum extension and no + subscribers. Works via the tenant-wide default, so it's invisible in + testing — but Document Layouts shows the value's rows blank, can't offer + it in the Usage dropdown, and "Copy from Report Selection" never lists + it. See sample: [`extend-report-selection-usage-for-new-document-types.bad.al`](extend-report-selection-usage-for-new-document-types.bad.al). +2. Subscribe both counterparties' triads for a one-sided document. This is + the overbroad default Jesper Schulz-Wedde's review caught: it + contradicts how `ReportSelectionHandlerCZZ` actually partitions its + usages, and clutters the other counterparty's picker with a value that + will never resolve a report there. + +## Source + +`ReportSelections.Table.al` (table 77, `InsertRecord` line 344), +`ReportSelectionUsage.Enum.al` (enum 77, `Extensible = true`), +`CustomReportSelection.Table.al` (table 9657) — all under +`src/Layers/W1/BaseApp/Foundation/Reporting/`. + +`CustomerReportSelections.Page.al` (page 9657, `.../Sales/Setup/`): +`FilterCustomerUsageReportSelections` (307), +`OnAfterFilterCustomerUsageReportSelections` (335), +`OnAfterOnMapTableUsageValueToPageValue` (325), +`OnValidateUsage2OnCaseElse` (330); enum `CustomReportSelectionSales.Enum.al` +(9657, same folder). `VendorReportSelections.Page.al` (page 9658, +`.../Purchases/Setup/`): `FilterVendorUsageReportSelections` (281), +`OnAfterFilterVendorUsageReportSelections` (296), +`OnMapTableUsageValueToPageValueOnCaseElse` (301), +`OnValidateUsage2OnCaseElse` (306); enum `ReportSelectionUsageVendor.Enum.al` +(9658, same folder). + +Partitioning precedent: `.../AdvancePaymentsLocalization/app/Src/Codeunits/ +ReportSelectionHandlerCZZ.Codeunit.al` (codeunit 31420) — customer-only +triad (47, 58, 69) for `"Sales Advance..."`, vendor-only triad (80, 91, +102) for `"Purchase Advance..."`, never both for one usage. Enum +extensions: `CustomReportSelSalesCZZ.EnumExt.al` (31008), `ReportSelUsage +VendorCZZ.EnumExt.al` (11708). + +Contrast (two-sided): `.../CompensationLocalization/app/Src/Codeunits/ +ReportSelectionHandlerCZC.Codeunit.al` (codeunit 11765) subscribes both +triads (16/27/38, 44/55/66) for `"Compensation CZC"`, which posts to both +a customer and a vendor ledger. (Lines as of `main`; may shift by version.) diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al new file mode 100644 index 0000000..6132c60 --- /dev/null +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al @@ -0,0 +1,25 @@ +tableextension 50105 "Sample Sales Line Ext" extends "Sales Line" +{ + fields + { + // WRONG: no OnValidate trigger. The field is registered as a + // price source below via OnAfterAddSources, so new lines price + // correctly - but changing this field on an existing line never + // triggers a recalculation (e.g. via UpdateUnitPrice), so the + // unit price silently keeps its old value. + field(50100; "Sample Loyalty Customer No."; Code[20]) + { + Caption = 'Sample Loyalty Customer No.'; + TableRelation = Customer; + } + } +} + +codeunit 50106 "Sample Sales Line Price Sources" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales Line - Price", 'OnAfterAddSources', '', false, false)] + local procedure AddLoyaltyCustomerSource(SalesHeader: Record "Sales Header"; SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit "Price Source List") + begin + PriceSourceList.Add(Enum::"Price Source Type"::Customer, SalesLine."Sample Loyalty Customer No."); + end; +} diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al new file mode 100644 index 0000000..6f8b157 --- /dev/null +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al @@ -0,0 +1,38 @@ +tableextension 50105 "Sample Sales Line Ext" extends "Sales Line" +{ + fields + { + field(50100; "Sample Loyalty Customer No."; Code[20]) + { + Caption = 'Sample Loyalty Customer No.'; + TableRelation = Customer; + + trigger OnValidate() + begin + // Second half of the wiring: without this call, changing + // the field on an existing line never re-runs price + // calculation, even though the source is already a known + // candidate via OnAfterAddSources below. + // + // UpdateUnitPriceByField(CalledByFieldNo) only recalculates + // if PlanPriceCalcByField(CalledByFieldNo) was already + // called for that same field - calling it alone is a + // silent no-op. UpdateUnitPrice(CalledByFieldNo) does both + // steps in the right order (plan, then update) in one + // call; it's the same method the base app itself calls + // from outside Sales Line to trigger recalculation for a + // field it just changed. + UpdateUnitPrice(FieldNo("Sample Loyalty Customer No.")); + end; + } + } +} + +codeunit 50106 "Sample Sales Line Price Sources" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales Line - Price", 'OnAfterAddSources', '', false, false)] + local procedure AddLoyaltyCustomerSource(SalesHeader: Record "Sales Header"; SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit "Price Source List") + begin + PriceSourceList.Add(Enum::"Price Source Type"::Customer, SalesLine."Sample Loyalty Customer No."); + end; +} diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md new file mode 100644 index 0000000..828fc8c --- /dev/null +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md @@ -0,0 +1,97 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [price-calculation, price-source, onafteraddsources, recalculation, pricing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A new price source needs both a calculation candidate and a recalculation trigger + +## Description + +Making a custom field usable as a price source on a sales line is two +separate, independent pieces of wiring, and doing only one produces a +line that looks like it's using the new source without ever actually +being priced by it. `codeunit "Sales Line - Price"` publishes +`OnAfterAddSources(SalesHeader: Record "Sales Header"; SalesLine: Record +"Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit +"Price Source List")` — subscribing here and calling +`PriceSourceList.Add(SourceType, SourceNo)` makes the source a candidate +the calculation considers. But nothing about that subscription causes +the price to be *recalculated* when the source field's value changes on +an existing line. That's the second, separate piece, and it needs to be +wired correctly: `Sales Line`'s `procedure +UpdateUnitPriceByField(CalledByFieldNo: Integer)` only recalculates if +the field was already *planned* — internally it exits immediately unless +`procedure PlanPriceCalcByField(CurrPriceFieldNo: Integer)` was already +called for that same field number. Calling `UpdateUnitPriceByField` on +its own, without a matching `PlanPriceCalcByField` call first, compiles +fine and looks correct, but silently recalculates nothing. `Sales Line` +also exposes `procedure UpdateUnitPrice(CalledByFieldNo: Integer)`, a +convenience wrapper that does both steps in the right order (plan, then +update) in one call — this is the method the base app itself calls from +*outside* `Sales Line` to trigger recalculation for a field it just +changed (see `Inventory/Item/Catalog/ItemReferenceManagement.Codeunit.al`: +`SalesLine.UpdateUnitPrice(SalesLine.FieldNo("Item Reference No."))`), and +it's what a custom price source field's own trigger should call too — the +same way Microsoft's own Location example is wired from a `Sales Line` +validation event, not from the price source registration itself. + +Add the source without wiring recalculation, and the failure hides +easily: a *new* line still prices correctly, because the field already +holds its value when calculation first runs on insert. The gap only +shows up when someone *changes* the source field's value on an existing +line — the price silently keeps its old value until something unrelated +happens to trigger recalculation. + +## Best Practice + +Wire both halves together whenever a field becomes a price source: an +`OnAfterAddSources` subscriber that adds it via `PriceSourceList.Add`, and +a trigger on the field itself (its own `OnValidate`, or a matching +`OnAfterValidate` integration event) that calls +`SalesLine.UpdateUnitPrice(SalesLine.FieldNo())`. Calling +`UpdateUnitPriceByField` directly, without first calling +`PlanPriceCalcByField` for that same field number, is *not* equivalent — +it exits immediately and recalculates nothing. `UpdateUnitPrice` does +both calls, in the correct order, in one step. + +See sample: [`new-price-source-must-add-candidate-and-trigger-recalculation.good.al`](new-price-source-must-add-candidate-and-trigger-recalculation.good.al). + +## Anti Pattern + +Subscribing to `OnAfterAddSources` to register a custom field as a price +source, without also triggering recalculation (via `UpdateUnitPrice`, or +the `PlanPriceCalcByField` + `UpdateUnitPriceByField` pair) from that +field's own validation. The field is a genuine, working calculation +candidate — new lines price correctly — but editing the field on an +existing line leaves the unit price stale, with nothing to indicate why. + +See sample: [`new-price-source-must-add-candidate-and-trigger-recalculation.bad.al`](new-price-source-must-add-candidate-and-trigger-recalculation.bad.al). + +## Source + +BCApps (`src/Layers/W1/BaseApp/`): `Sales/Pricing/SalesLinePrice.Codeunit.al` +(`local procedure OnAfterAddSources(SalesHeader: Record "Sales Header"; +SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var +PriceSourceList: Codeunit "Price Source List")`); `Pricing/Source/PriceSourceList.Codeunit.al` +(`procedure Add(SourceType: Enum "Price Source Type"; SourceNo: Code[20])`); +`Sales/Document/SalesLine.Table.al` (`procedure +PlanPriceCalcByField(CurrPriceFieldNo: Integer)`; `procedure +UpdateUnitPrice(CalledByFieldNo: Integer)`; `procedure +UpdateUnitPriceByField(CalledByFieldNo: Integer)`, which exits immediately +unless `FieldCausedPriceCalculation` already equals `CalledByFieldNo` — +the state `PlanPriceCalcByField` sets). External, idiomatic use of the +one-call form: `Inventory/Item/Catalog/ItemReferenceManagement.Codeunit.al` +(`SalesLine.UpdateUnitPrice(SalesLine.FieldNo("Item Reference No."))`). + +Microsoft Learn, "Extending Price Calculations" (Location example): "To +recalculate the price, we can subscribe to events that pass the sales +line by reference... We'll call the UpdateUnitPriceByLocationCode() +method, which is a simplified version of the UpdateUnitPriceByField() +method... To add the location in the source list for price calculations, +we'll subscribe to the OnAfterAddSources event of Codeunit 'Sales Line - +Price,' and add the Location Code as a source." +(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations) diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al new file mode 100644 index 0000000..a60692b --- /dev/null +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al @@ -0,0 +1,41 @@ +report 50110 "Sample Item Barcode Label" +{ + UsageCategory = Tasks; + ApplicationArea = All; + Caption = 'Sample Item Barcode Label'; + + dataset + { + dataitem(Item; Item) + { + column(No_; "No.") { } + column(Barcode; BarcodeText) { } + + trigger OnAfterGetRecord() + var + BarcodeFontProvider: Interface "Barcode Font Provider"; + begin + // WRONG: a one-dimensional IDAutomation provider path that + // calls EncodeFont without ValidateInput. "Barcode Font + // Provider" (1D) declares both, and IDAutomation 1D + // Provider's EncodeFont does not validate on its own - it + // hands the text straight to the font encoder. Code 39 + // accepts only 0-9, A-Z, space and - . $ / + % *, but an + // Item "No." can legally contain characters outside that + // set (e.g. "_" or "#"). Such a value is never rejected; + // it silently reaches the font as an unscannable barcode. + BarcodeFontProvider := Enum::"Barcode Font Provider"::IDAutomation1D; + BarcodeText := BarcodeFontProvider.EncodeFont("No.", BarcodeSymbology); + end; + } + } + + var + BarcodeSymbology: Enum "Barcode Symbology"; + BarcodeText: Text; + + trigger OnInitReport() + begin + BarcodeSymbology := Enum::"Barcode Symbology"::Code39; + end; +} diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al new file mode 100644 index 0000000..8364ad8 --- /dev/null +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al @@ -0,0 +1,54 @@ +report 50110 "Sample Item Barcode Label" +{ + UsageCategory = Tasks; + ApplicationArea = All; + Caption = 'Sample Item Barcode Label'; + + dataset + { + dataitem(Item; Item) + { + column(No_; "No.") { } + column(Barcode1D; BarcodeText) { } + column(Barcode2D; QRCodeText) { } + + trigger OnAfterGetRecord() + var + BarcodeFontProvider: Interface "Barcode Font Provider"; + BarcodeFontProvider2D: Interface "Barcode Font Provider 2D"; + begin + // One-dimensional: "Barcode Font Provider" declares both + // ValidateInput and EncodeFont - call both. + BarcodeFontProvider := Enum::"Barcode Font Provider"::IDAutomation1D; + BarcodeFontProvider.ValidateInput("No.", BarcodeSymbology); + BarcodeText := BarcodeFontProvider.EncodeFont("No.", BarcodeSymbology); + + // Two-dimensional: "Barcode Font Provider 2D" declares only + // EncodeFont - there is no ValidateInput to call here. + BarcodeFontProvider2D := Enum::"Barcode Font Provider 2D"::IDAutomation2D; + QRCodeText := BarcodeFontProvider2D.EncodeFont("No.", BarcodeSymbology2D); + end; + } + } + + var + BarcodeSymbology: Enum "Barcode Symbology"; + BarcodeSymbology2D: Enum "Barcode Symbology 2D"; + BarcodeText: Text; + QRCodeText: Text; + + trigger OnInitReport() + begin + BarcodeSymbology := Enum::"Barcode Symbology"::Code39; + BarcodeSymbology2D := Enum::"Barcode Symbology 2D"::"QR-Code"; + end; + + // Layout requirement (can't be enforced in AL, so it's stated here): + // the Barcode1D column's text box must use the real, purchased font + // name - IDAutomationHC39M for Code 39 - never an evaluation name + // like "IDAutomationSHC39M Demo". Per Microsoft Learn, using the + // evaluation name in a Business Central online production + // environment means "the barcode won't render" at all. The + // Barcode2D column's font name is IDAutomation2D (IDAutomation2D + // MaxiCode for Maxicode specifically). +} diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md new file mode 100644 index 0000000..e85d7e1 --- /dev/null +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md @@ -0,0 +1,99 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [barcode, qr-code, barcode-font-provider, barcode-font-provider-2d, report-layout, saas, idautomation, code-39, checksum] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Generate report barcodes through the Barcode module, with the production font name + +## Description + +Business Central's barcode support lives in the System Application's +`Barcode` module (`src/System Application/App/Barcode`): `interface +"Barcode Font Provider"` / `"Barcode Font Provider 2D"`, `enum "Barcode +Symbology"` / `"Barcode Symbology 2D"`, and built-in implementations +(`codeunit 9215`/`9221`). A report encodes a data string via this API; +the layout then displays it using a barcode *font*. + +The two interfaces are not symmetric: `"Barcode Font Provider"` (1D) +declares both `ValidateInput` and `EncodeFont`; `"Barcode Font Provider +2D"` declares only `EncodeFont` (see Source). BCApps' `Item GTIN Label` +report reflects that split exactly — it validates then encodes through +the 1D provider, but only encodes through the 2D provider, for the same +"No." value. + +On Business Central online this needs no setup ("the IDAutomation fonts +are automatically available as part of the service" — Microsoft Learn), +unlike on-premises, where fonts must be purchased and installed. That +ease hides a SaaS-specific trap the API doesn't cover: naming the actual +font. IDAutomation ships both a purchased font and a same-looking +evaluation font per version (Code 39: `IDAutomationHC39M` purchased vs. +`IDAutomationSHC39M Demo`) — per Microsoft Learn, "be sure to use the +purchased font name... If you use the evaluation font name, the barcode +won't render." The wrong name produces nothing, in the layout not AL, so +no reviewer catches it reading the object. + +## Best Practice + +Encode through the real API, matching the calls to what the chosen +interface actually declares. One-dimensional: declare `Interface +"Barcode Font Provider"` and call both `ValidateInput` and `EncodeFont` +— skipping validation lets a value outside the character set, or one +needing a checksum setting never applied, reach the font unchecked. +Two-dimensional: declare `Interface "Barcode Font Provider 2D"` and call +`EncodeFont` alone — there is no `ValidateInput` on this interface. + +Treat naming the production font in the layout as equally required, not +an afterthought. Two-dimensional symbologies other than Maxicode use +`IDAutomation2D` (Maxicode: `IDAutomation2D MaxiCode`); one-dimensional +symbologies use the purchased version name (e.g. `IDAutomationHC39M` for +Code 39), never a name containing `Demo`. + +See sample: [`report-barcodes-must-use-barcode-module-and-production-font-name.good.al`](report-barcodes-must-use-barcode-module-and-production-font-name.good.al). + +## Anti Pattern + +Constructing a barcode string by hand where that construction has a +concrete, independently provable defect: a source value that can contain +characters outside the symbology's character set is never validated, a +checksum the symbology or setup requires is never applied, or there is +concrete evidence of an incompatible font binding. + +The delimiter itself is not the defect. `*value*` is a documented, valid +Code 39 form for IDAutomation fonts (Microsoft Learn's font table and +IDAutomation's own manual both give `*` as start/stop); the `(`/`)` that +IDAutomation 1D Provider's encoder emits (BCApps test: +`EncodeFont('1234', Code39) = '(1234)'`) is an alternative start/stop +form the same fonts accept, used to keep `*` out of the human-readable +text. Never flag delimiter choice alone. + +The same validation gap exists when the module *is* used: a 1D path that +calls `EncodeFont` on `"Barcode Font Provider"` without `ValidateInput` +(IDAutomation 1D Provider's `EncodeFont` does not validate on its own). +The sample shows this variant, visible in AL alone. A last version: +encoding correctly but naming the evaluation font, which BC online +refuses to render. + +See sample: [`report-barcodes-must-use-barcode-module-and-production-font-name.bad.al`](report-barcodes-must-use-barcode-module-and-production-font-name.bad.al). + +## Source + +BCApps (`src/System Application/App/Barcode/src/`): +`Barcode Provider/Font/BarcodeFontProvider.Interface.al` (1D: +`ValidateInput` + `EncodeFont`); `IDAutomation 1D Provider/ +IDAutomation1DProvider.Codeunit.al` (`EncodeFont` goes straight to the +symbology encoder; only `ValidateInput` calls `IsValidInput`); `Barcode Provider 2D/Font/BarcodeFontProvider2D.Interface.al` +(2D: only `EncodeFont`). `IDAutomation 1D Provider/Encoders/IDA1DCode39Encoder.Codeunit.al` +(`codeunit 9204`, regex accepts literal `*`; `EncodeFont` → `DotNet FontEncoder.Code39`). +1D/2D split: `.../Inventory/Item/ItemGTINLabel.Report.al` (`report 6625`, +validates+encodes 1D, only encodes 2D). Encoder output form: `IDA1DCode39Test.Codeunit.al` +(`codeunit 135044`): `EncodeFontSuccessTest('1234', Code39, '(1234)')`. + +Microsoft Learn "Adding Barcodes to Reports" and "Barcode Fonts with +Business Central Online" — quoted above, incl. the Code39 row ("`*` is +used for both start and stop delimiters"). IDAutomation, "Code 39 Font +User Manual" (https://idautomation.com/barcode-fonts/code-39/fontnames/): +`*` start/stop, or parentheses to keep `*` out of the human-readable text. diff --git a/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al new file mode 100644 index 0000000..b91dba8 --- /dev/null +++ b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al @@ -0,0 +1,30 @@ +tableextension 50100 "Sample Sales Header Ext" extends "Sales Header" +{ + fields + { + field(50000; "Reference No."; Code[20]) + { + Caption = 'Reference No.'; + DataClassification = CustomerContent; + } + } +} + +tableextension 50101 "Sample Sales Invoice Header Ext" extends "Sales Invoice Header" +{ + fields + { + // WRONG: same field number 50000, but a shorter length than the + // Sales Header extension above. This compiles fine and posts + // fine for every "Reference No." of 10 characters or less - + // SalesInvHeader.TransferFields(SalesHeader) in + // SalesPost.Codeunit.al only throws once an actual value longer + // than 10 characters reaches posting, which typical test data + // never triggers. + field(50000; "Reference No."; Code[10]) + { + Caption = 'Reference No.'; + DataClassification = CustomerContent; + } + } +} diff --git a/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al new file mode 100644 index 0000000..9f6bcc3 --- /dev/null +++ b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al @@ -0,0 +1,28 @@ +tableextension 50100 "Sample Sales Header Ext" extends "Sales Header" +{ + fields + { + field(50000; "Reference No."; Code[20]) + { + Caption = 'Reference No.'; + DataClassification = CustomerContent; + } + } +} + +tableextension 50101 "Sample Sales Invoice Header Ext" extends "Sales Invoice Header" +{ + fields + { + // Same field number, same type, same length as the Sales Header + // extension above. SalesInvHeader.TransferFields(SalesHeader) in + // SalesPost.Codeunit.al only bridges two fields that agree on all + // three - matching all three here is what makes this value + // survive posting for every possible "Reference No." value. + field(50000; "Reference No."; Code[20]) + { + Caption = 'Reference No.'; + DataClassification = CustomerContent; + } + } +} diff --git a/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md new file mode 100644 index 0000000..70feb22 --- /dev/null +++ b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md @@ -0,0 +1,87 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [transferfields, field-number, posting-cascade, schema-design, custom-field] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mirrored TransferFields cascade fields must match type and length exactly + +## Description + +Most custom fields genuinely belong to only one table — a status used +only before posting, a note relevant only afterwards, whatever the case +may be. That is the ordinary, unremarkable default, and it needs no +justification: `TransferFields` never touches a field that doesn't exist +on the destination. Per Microsoft's own documentation, a source field's +contents are copied "if such a field exists" on the destination with a +matching field number — a field defined on only one side of a posting +cascade is simply outside `TransferFields`' reach, not a gap to fix. + +The narrower case this rule addresses is when a field **is** deliberately +mirrored across a known cascade — the same field number reused on +another table specifically so the value survives posting, for example a +field added to both `Sales Header` (36) and `Sales Invoice Header` (112), +which `SalesPost.Codeunit.al` connects via +`SalesInvHeader.TransferFields(SalesHeader)`. The two definitions have to +agree on type and, less obviously, on length. A field defined `Text[100]` +on `Sales Header` and `Text[50]` on `Sales Invoice Header` compiles +cleanly on both sides, and the `TransferFields` call runs without error +for every value up to 50 characters. Per Microsoft's documentation, a +runtime error only occurs when there isn't "room for the actual length +of the contents of the field to be copied" — so nothing fails while test +data, or early production data, stays short. The error surfaces only the +day an actual value finally exceeds the shorter definition, on a document +type that may have been posting cleanly for months. + +See also `transferfields-skip-type-mismatch-can-drop-data.md`, which +covers `SkipFieldsNotMatchingType = true` silently skipping a *type* +mismatch between same-extension fields. That parameter has no effect on +length: two fields of the same type but different length still raise the +runtime error described above regardless of how `SkipFieldsNotMatchingType` +is set, which is the distinct failure mode this article addresses. + +## Best Practice + +When mirroring a field across a `TransferFields` cascade, define it with +the exact same field number, data type, and length on every table in +that cascade, at creation time. A field intentionally left local to one +table is unaffected by this and needs no mirroring at all — this is a +consistency requirement between definitions that are already meant to be +linked, not a mandate to check every field against every table on the +cascade. + +See sample: [`transferfields-mirrored-fields-must-match-type-and-length.good.al`](transferfields-mirrored-fields-must-match-type-and-length.good.al). + +## Anti Pattern + +The same field number added to two tables that `TransferFields` connects +in a posting cascade (e.g. `Sales Header` (36) and `Sales Invoice Header` +(112), linked by `SalesPost.Codeunit.al`), with a shorter length — or an +incompatible data type — on one side. Both definitions compile without +error; nothing fails until an actual value exceeds the shorter one, which +typical test data never does. + +See sample: [`transferfields-mirrored-fields-must-match-type-and-length.bad.al`](transferfields-mirrored-fields-must-match-type-and-length.bad.al). + +## Source + +Microsoft Learn, `Record.TransferFields(var Record [, Boolean])`: +"The `TransferFields` method copies fields based on the field number on +the fields. For each field in `Record` (the destination), the contents +of the field that has the same field number in `FromRecord` (the source) +will be copied, **if such a field exists**." And: "The fields must have +the *same data type* for the copying to succeed... There must be room +for the actual length of the contents of the field to be copied in the +field to which it is to be copied. If any one of these conditions aren't +fulfilled, a runtime error will occur." +(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-transferfields-table-boolean-method) + +BCApps `SalesPost.Codeunit.al` (`src/Layers/W1/BaseApp/Sales/Posting/`): +`SalesShptHeader.TransferFields(SalesHeader);` (line 7104), +`ReturnRcptHeader.TransferFields(SalesHeader);` (line 7166), +`SalesInvHeader.TransferFields(SalesHeader);` (line 7220), +`SalesCrMemoHeader.TransferFields(SalesHeader);` (line 7275) — the real +cascade a mirrored field on `Sales Header` (36) is checked against. diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index a73f929..5f4cec7 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, document print/email/Post-and-Send actions, `Navigate` page subscribers, Report Selection registration or dispatch, price-calculation/price-source extensibility, `TransferFields`-based posting-cascade field mirroring, barcode/report-layout font-provider usage, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. The skill returns `not-applicable` when none of those apply. ## Source @@ -37,9 +37,9 @@ Discard files that are not applicable. Retain conditionally applicable files (an Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: -- The changed AL object names and types — especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, and document or journal lines that reference a master. -- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, and posting validation. -- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`). +- The changed AL object names and types — especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, document or journal lines that reference a master, document pages/codeunits exposing print/email/Post-and-Send actions, codeunits subscribing to `Navigate`, enumextensions to `"Report Selection Usage"`/`"Price Calculation Handler"`/`"Price Source Type"`, and report objects that render barcodes. +- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, posting validation, and posting-cascade `TransferFields` calls. +- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`). 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 data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -58,6 +58,15 @@ The following targeted checks cover every current `data-modeling` article. Treat - A `Media` or `MediaSet` field is assigned directly between different table types or different field IDs instead of registering each shared item with `MediaSet.Insert` — `share-mediaset-items-with-insert-not-field-assignment`. - A custom document header assigns defaults outside an `InitRecord` boundary, calls `InitRecord` before assigning its number, or places UI-independent defaults only in a page trigger — `initialize-document-defaults-in-initrecord`. - Directed `Round` calls use `'<'` as mathematical floor or `'>'` as mathematical ceiling, especially where negative amounts are possible — `round-direction-symbols-use-magnitude`. +- A codeunit dispatches a document by calling `Report.Run`/`Report.RunModal` with a hardcoded report ID, or by building its own email directly, instead of going through the `Report Selections` usage for that document — `custom-document-dispatch-must-not-bypass-report-selections`. Either bypass is a finding on its own; both need not be present. Scope this to customer/vendor-facing documents that have (or should have) a `Report Selection Usage` — a hardcoded `Report.Run` of an ordinary list/analysis report is not this anti-pattern. A call that already goes through `Report Selections`' own Print/Email procedures is not this anti-pattern. +- A document's own interactive Print/Email action routes through `Document Sending Profile` (`DocumentSendingProfile.Send`/`SendVendor`) instead of calling `Report Selections` (`PrintForCust`/`PrintWithDialogForCust`/`SendEmailToCust`/`PrintWithDialogForVend`/`SendEmailToVendor`) directly — `document-print-and-email-actions-call-report-selections-directly`. Do not flag `Document Sending Profile` usage that is genuinely part of a combined Post-and-Send action. +- An `EventSubscriber` is added for `Navigate::OnAfterFindRecords` (registering a custom table in Find Entries) without a matching `Navigate::OnBeforeShowRecords` subscriber for the same table, or vice versa — `extend-find-entries-navigate-for-new-document-types`. Both subscribers must be added together for the same table. +- An `enumextension` extends `"Report Selection Usage"` and registers a report via `ReportSelections.InsertRecord`, without subscribing to the matching *single* counterparty's full triad — the filter event (`OnAfterFilterCustomerUsageReportSelections` on `page 9657` for a sales usage, `OnAfterFilterVendorUsageReportSelections` on `page 9658` for a purchase usage) AND the page-facing usage-enum map/validate events (`enumextension` on `"Custom Report Selection Sales"`/`"Report Selection Usage Vendor"` plus the matching map/validate subscribers) — `extend-report-selection-usage-for-new-document-types`. Requiring or wiring *both* counterparties by default for a one-sided document is also the anti-pattern (`ReportSelectionHandlerCZZ` partitions strictly by counterparty); only a genuinely two-sided usage (as `ReportSelectionHandlerCZC` demonstrates for Compensation) needs both. +- The same field number is added as a new field on two or more tables connected by a `TransferFields` call in a posting cascade (e.g. a header table and the posted-document table `SalesPost.Codeunit.al`/`PurchPost.Codeunit.al` transfer into), with a different data type or length on one side — `transferfields-mirrored-fields-must-match-type-and-length`. A field defined on only one side of the cascade is out of scope; this cues only on a field deliberately mirrored across the cascade with a type or length mismatch. +- An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Method`/`Type`/`Asset Type` — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. `Default := true` is only required on that row when it is meant as the fallback for its `Method`/`Type`/`Asset Type` combination; a row meant to be selected only through an explicit, specific `"Dtld. Price Calculation Setup"` row does not need it, so do not flag a missing `Default := true` by itself — flag the missing setup row/subscriber entirely. +- An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`. +- A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that triggers recalculation — either `SalesLine.UpdateUnitPrice()`, or the explicit `SalesLine.PlanPriceCalcByField()` followed by `SalesLine.UpdateUnitPriceByField()`. A bare `UpdateUnitPriceByField` without a preceding `PlanPriceCalcByField` for the same field number does not count as recalculation (it exits without recalculating) — `new-price-source-must-add-candidate-and-trigger-recalculation`. +- A report hand-constructs a barcode string only where a concrete, independently provable defect is visible: the source value can contain characters outside the symbology's character set and is never validated, a checksum the symbology/setup requires is never applied, or there is concrete evidence of an incompatible font binding. Do not flag manual start/stop delimiters by themselves — `*value*` is a documented, valid Code 39 form for IDAutomation fonts (IDAutomation also accepts parentheses), so delimiter choice alone is never a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. - A new field is typed `Code`/`Text` and its `OnValidate` calls `DimensionManagement`/`DimMgt`, or a table adds Shortcut Dimension fields, a `Dimension Set ID` field, or `AddDimSource`/`GetDefaultDimID` — `dimension-management-wiring`. A master table calling `SaveDefaultDim` and a document/journal table computing its own `Dimension Set ID` are two different valid shapes; do not flag a master table for lacking a `Dimension Set ID` field or a document for lacking `SaveDefaultDim`. - A journal-based posting codeunit is added or changed and validation, Journal-table access, ledger writes, and user-interaction (`Confirm`/dialogs) all occur in one procedure or one codeunit, rather than split across `Check Line`/`Post Line`/`Post Batch`-shaped companions — `check-post-line-batch-pattern`. A document posting routine calling `Post Line` directly without a `Post Batch` companion is not this anti-pattern. - An existing, already-published table's `keys` block adds, removes, or reorders a field in its primary key or any `Clustered = true` key — `do-not-change-primary-key`. A new table defining its own key for the first time is not this anti-pattern; requires repository/publication context to know the table has already shipped. @@ -91,7 +100,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable data-modeling knowledge survived filtering. -- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, audit-field, dimension-wiring, posting-routine-structure, or Item-Ledger-Entry-document-number surface. +- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface, and no document print/email/Post-and-Send action, `Navigate` subscriber, Report Selection registration/dispatch, price-calculation/price-source extensibility point, posting-cascade `TransferFields` mirroring, barcode/report-font-provider usage, dimension wiring, posting-routine structure, or Item-Ledger-Entry-document-number surface. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index 2941c02..f9be512 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -303,6 +303,7 @@ foreach ($domain in $leafDomains) { $caseList.Add($case) | Out-Null } } + } $cases = @($caseList) From a2685b9c8608717199dea9b40e7d8df1a3a54315 Mon Sep 17 00:00:00 2001 From: Kilian Seizinger <56249171+pri-kise@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:51:34 +0200 Subject: [PATCH 40/51] knowledge(data-modeling): Prices Including VAT decides the basis of sales/purchase/service line amounts (#203) * Frist draft for Prices Incl. VAT Data Modelling * knowledge(data-modeling): cover prepayment, service line and ExclTax helper in Prices Including VAT article Extend the community article on sales/purchase line prices following the header's Prices Including VAT: - Group the line fields: fields that follow the header flag (Unit Price, Direct Unit Cost, Line Amount, discounts, Prepmt. Line Amount, Prepmt. Amt. Inv., Prepmt Amt to Deduct, Prepmt Amt Deducted) versus fields with a fixed basis (Amount, VAT Base Amount, Prepayment Amount are net; Amount Including VAT, Prepmt. Amt. Incl. VAT, Prepmt. Amount Inv. Incl. VAT are gross). - Add the rule to combine only fields of the same group, with BaseApp's UpdatePrepmtAmounts as the correct example. - Add the misleading-name case: CalculateOutstandingAmountExclTax on Sales Line and Purchase Line is based on Line Amount and includes VAT on a Prices Including VAT document. BaseApp pairs it only with Prepmt. Line Amount (same basis); extension code that treats it as net is wrong. - Mention that Service Line uses the same caption switch and UpdateVATAmounts split for Unit Price and Line Amount. - Samples: add GetOutstandingNetAmount (bad: trusts the helper's name; good: takes the uninvoiced share of Amount). - al-data-modeling-review: widen the scope and the worklist rule to service lines, the extra prepayment fields and CalculateOutstandingAmountExclTax, and exclude code that only combines fields of the same group. Verified against BCApps W1 BaseApp (SalesLine, PurchaseLine, ServiceLine, SalesHeader, Sales Line CaptionClass Mgmt). Refs #151 * knowledge(data-modeling): move Prices Including VAT article to Microsoft layer data-modeling is a Microsoft-owned review domain consumed by microsoft/skills/review/al-data-modeling-review.md, and docs/contributing.md does not allow Community as a staging layer for such domains. Move the article and its .good.al/.bad.al samples to microsoft/knowledge/data-modeling/. Content is unchanged; the slug-based evaluation entry stays as is. --- evaluation/review-fixtures.json | 1 + ...-prices-follow-prices-including-vat.bad.al | 33 ++++++++++++ ...prices-follow-prices-including-vat.good.al | 52 +++++++++++++++++++ ...line-prices-follow-prices-including-vat.md | 49 +++++++++++++++++ .../skills/review/al-data-modeling-review.md | 7 +-- 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.bad.al create mode 100644 microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.good.al create mode 100644 microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 5f0e50a..628526d 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -17,6 +17,7 @@ "check-blocked-in-referencing-code-not-in-master", "code-must-not-change-workdate", "custom-document-dispatch-must-not-bypass-report-selections", + "document-line-prices-follow-prices-including-vat", "document-print-and-email-actions-call-report-selections-directly", "extend-find-entries-navigate-for-new-document-types", "extend-price-source-type-must-sync-document-subset-enum", diff --git a/microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.bad.al b/microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.bad.al new file mode 100644 index 0000000..230bc23 --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.bad.al @@ -0,0 +1,33 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50641 "Sales Doc. VAT Basis Bad" +{ + procedure GetNetAndGrossTotals(SalesHeader: Record "Sales Header"; var NetTotal: Decimal; var GrossTotal: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + if SalesLine.FindSet() then + repeat + // Wrong: "Line Amount" already includes VAT when the header has Prices Including VAT, + // so NetTotal is gross and VAT is added a second time. Invoice discount is also ignored. + NetTotal += SalesLine."Line Amount"; + GrossTotal += SalesLine."Line Amount" * (1 + SalesLine."VAT %" / 100); + until SalesLine.Next() = 0; + end; + + procedure GetOutstandingNetAmount(SalesLine: Record "Sales Line"): Decimal + begin + // Wrong: despite its name, CalculateOutstandingAmountExclTax is based on "Line Amount" + // and therefore includes VAT on a Prices Including VAT document. + exit(SalesLine.CalculateOutstandingAmountExclTax()); + end; + + procedure SetUnitPriceFromNetSourcePrice(var SalesLine: Record "Sales Line"; NetSourcePrice: Decimal) + begin + // Wrong: on a Prices Including VAT document this net price is read as a gross price, + // so the net line amount drops to NetSourcePrice / (1 + "VAT %" / 100). + SalesLine.Validate("Unit Price", NetSourcePrice); + SalesLine.Modify(true); + end; +} diff --git a/microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.good.al b/microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.good.al new file mode 100644 index 0000000..167c362 --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.good.al @@ -0,0 +1,52 @@ +// Demonstration only; independently authored, not copied from BaseApp. +codeunit 50640 "Sales Doc. VAT Basis Good" +{ + procedure GetNetAndGrossTotals(SalesHeader: Record "Sales Header"; var NetTotal: Decimal; var GrossTotal: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + // Amount is always net and "Amount Including VAT" always gross, after line and + // invoice discounts, whatever the header's "Prices Including VAT" says. + SalesLine.CalcSums(Amount, "Amount Including VAT"); + NetTotal := SalesLine.Amount; + GrossTotal := SalesLine."Amount Including VAT"; + end; + + procedure GetOutstandingNetAmount(SalesLine: Record "Sales Line"): Decimal + var + SalesHeader: Record "Sales Header"; + Currency: Record Currency; + begin + if SalesLine.Quantity = 0 then + exit(0); + SalesHeader.Get(SalesLine."Document Type", SalesLine."Document No."); + Currency.Initialize(SalesHeader."Currency Code"); + // Amount is net after line and invoice discounts on every document, so the + // uninvoiced share needs no VAT conversion. + exit(Round( + SalesLine.Amount * (SalesLine.Quantity - SalesLine."Quantity Invoiced") / SalesLine.Quantity, + Currency."Amount Rounding Precision")); + end; + + procedure SetUnitPriceFromNetSourcePrice(var SalesLine: Record "Sales Line"; NetSourcePrice: Decimal) + var + SalesHeader: Record "Sales Header"; + Currency: Record Currency; + UnitPrice: Decimal; + begin + SalesHeader.Get(SalesLine."Document Type", SalesLine."Document No."); + // Scope of the conversion below: Normal VAT only; Full VAT and Sales Tax need their own handling. + SalesLine.TestField("VAT Calculation Type", SalesLine."VAT Calculation Type"::"Normal VAT"); + Currency.Initialize(SalesHeader."Currency Code"); + + UnitPrice := NetSourcePrice; + // "Unit Price" is gross on a Prices Including VAT document: convert the net source price into that basis. + if SalesHeader."Prices Including VAT" then + UnitPrice := Round(NetSourcePrice * (1 + SalesLine."VAT %" / 100), Currency."Unit-Amount Rounding Precision"); + + SalesLine.Validate("Unit Price", UnitPrice); + SalesLine.Modify(true); + end; +} diff --git a/microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.md b/microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.md new file mode 100644 index 0000000..dfdbbfc --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-line-prices-follow-prices-including-vat.md @@ -0,0 +1,49 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [prices-including-vat, unit-price, line-amount, direct-unit-cost, prepmt-line-amount, amount-including-vat, sales-line, purchase-line, service-line, net-gross] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Sales, purchase, and service line prices follow the header's Prices Including VAT + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Line prices are gross or net depending on the header's `Prices Including VAT`. When the flag is set, `Unit Price` (sales, service), `Direct Unit Cost` (purchase), `Line Amount`, `Line Discount Amount`, `Inv. Discount Amount`, and the prepayment fields `Prepmt. Line Amount`, `Prepmt. Amt. Inv.`, `Prepmt Amt to Deduct`, and `Prepmt Amt Deducted` all include VAT. When it is cleared they exclude it. Only `Amount`, `VAT Base Amount`, and `Prepayment Amount` (always net) and `Amount Including VAT`, `Prepmt. Amt. Incl. VAT`, and `Prepmt. Amount Inv. Incl. VAT` (always gross) keep a fixed basis. Code that mixes the two groups without looking at the header flag is wrong for every document whose customer or vendor uses the other setting. + +## Best Practice + +When code needs a known basis — exports, integrations, KPIs, custom totals, commission or margin calculations — read `Amount` for net and `Amount Including VAT` for gross. Both are already reduced by line and invoice discounts and are maintained by the line's VAT calculation, so no VAT arithmetic is needed. + +When code combines fields, pair fields of the same group. BaseApp's `UpdatePrepmtAmounts` sets `Prepmt. Line Amount` from `Line Amount` minus `Inv. Discount Amount`. This is correct because all three follow the header flag. `Prepmt. Amt. Inv.` pairs with `Prepmt. Line Amount`; `Prepmt. Amount Inv. Incl. VAT` pairs only with other gross values. + +When code writes a price from an external source whose basis is known (an EDI price list, an API payload, a web-shop order), get the document header and convert the source price into the header's basis before validating `Unit Price` or `Direct Unit Cost`, using the line's `VAT %` and the currency's `Unit-Amount Rounding Precision`. Alternatively, set `Prices Including VAT` on the header to match the source before the first line is created. Toggling it later on a sales header with priced lines asks the user to confirm a recalculation. Without a UI session, or when validation dialogs are hidden, BaseApp converts all line prices without asking. + +The standard price calculation already converts a `Price List Line` whose `Price Includes VAT` differs from the document's setting, so a price it returns is in the document's basis and must not be converted a second time. + +On purchase lines, `Unit Cost` and `Unit Cost (LCY)` are derived from `Direct Unit Cost` with VAT removed, so they stay net. Service lines follow the same rule for `Unit Price` and `Line Amount`: they share the caption switch and the `UpdateVATAmounts` split of the sales line. + +See sample: [`document-line-prices-follow-prices-including-vat.good.al`](document-line-prices-follow-prices-including-vat.good.al). + +## Anti Pattern + +Treating `Unit Price`, `Direct Unit Cost`, or `Line Amount` as net by default. Typical signals: summing `Line Amount` as a document's net total, computing VAT as `Line Amount * "VAT %" / 100`, putting a known net or gross external price straight into `Unit Price` or `Direct Unit Cost`, or comparing a line price with `Item."Unit Price"` or `Item."Last Direct Cost"` — all without reading the header's `Prices Including VAT`. For a gross-price customer at 19 % VAT, a net total built from `Line Amount` is 19 % too high, and a net price of 100 imported unconverted yields a net revenue of only 84.03. + +Trusting a name instead of the source fields. The public procedure `CalculateOutstandingAmountExclTax` on `Sales Line` and `Purchase Line` returns `Line Amount` minus `Inv. Discount Amount` for the uninvoiced quantity, so its result includes VAT on a `Prices Including VAT` document despite its name. BaseApp only combines it with `Prepmt. Line Amount`, which has the same basis. Extension code that uses it as a net outstanding amount — compared with `Amount`, exported as net, or used to compute VAT — has this defect. The same applies to any variable or procedure named `ExclVAT`, `ExclTax`, or `Net` that is fed from a header-dependent field. + +Do not report code that reads the header flag, uses only fixed-basis fields, or only combines fields of the same group (for example, prepayment amounts derived from `Line Amount`, or values copied between two lines of the same document). + +See sample: [`document-line-prices-follow-prices-including-vat.bad.al`](document-line-prices-follow-prices-including-vat.bad.al). + +## References + +- [BCApps: Sales Header `Prices Including VAT` OnValidate recalculates line prices](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Document/SalesHeader.Table.al). +- [BCApps: Sales Line `UpdateVATAmounts`, `UpdatePrepmtAmounts`, and `CalculateOutstandingAmountExclTax`](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al). +- [BCApps: `Sales Line CaptionClass Mgmt` switches captions to Incl./Excl. VAT](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Document/SalesLineCaptionClassMgmt.Codeunit.al). +- [BCApps: Purchase Line `UpdateUnitCost` removes VAT from `Direct Unit Cost`](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Purchases/Document/PurchaseLine.Table.al). +- [BCApps: Service Line `GetCaptionClass` and `UpdateVATAmounts`](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Service/Document/ServiceLine.Table.al). +- [BCApps: `Price Calculation Buffer Mgt.` `ConvertAmountByTax`](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Pricing/Calculation/PriceCalculationBufferMgt.Codeunit.al). diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 5f4cec7..789db91 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, document print/email/Post-and-Send actions, `Navigate` page subscribers, Report Selection registration or dispatch, price-calculation/price-source extensibility, `TransferFields`-based posting-cascade field mirroring, barcode/report-layout font-provider usage, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, document print/email/Post-and-Send actions, `Navigate` page subscribers, Report Selection registration or dispatch, price-calculation/price-source extensibility, code that reads or writes sales/purchase/service line price and amount fields, `TransferFields`-based posting-cascade field mirroring, barcode/report-layout font-provider usage, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. The skill returns `not-applicable` when none of those apply. ## Source @@ -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 `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, document or journal lines that reference a master, document pages/codeunits exposing print/email/Post-and-Send actions, codeunits subscribing to `Navigate`, enumextensions to `"Report Selection Usage"`/`"Price Calculation Handler"`/`"Price Source Type"`, and report objects that render barcodes. - The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, posting validation, and posting-cascade `TransferFields` calls. -- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`). +- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Prices Including VAT`, `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Prepmt. Line Amount`, `Amount Including VAT`, `CalculateOutstandingAmountExclTax`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`). 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 data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -66,6 +66,7 @@ The following targeted checks cover every current `data-modeling` article. Treat - An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Method`/`Type`/`Asset Type` — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. `Default := true` is only required on that row when it is meant as the fallback for its `Method`/`Type`/`Asset Type` combination; a row meant to be selected only through an explicit, specific `"Dtld. Price Calculation Setup"` row does not need it, so do not flag a missing `Default := true` by itself — flag the missing setup row/subscriber entirely. - An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`. - A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that triggers recalculation — either `SalesLine.UpdateUnitPrice()`, or the explicit `SalesLine.PlanPriceCalcByField()` followed by `SalesLine.UpdateUnitPriceByField()`. A bare `UpdateUnitPriceByField` without a preceding `PlanPriceCalcByField` for the same field number does not count as recalculation (it exits without recalculating) — `new-price-source-must-add-candidate-and-trigger-recalculation`. +- Code reads `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Line Discount Amount`, `Inv. Discount Amount`, `Prepmt. Line Amount`, `Prepmt. Amt. Inv.`, `Prepmt Amt to Deduct`, `Prepmt Amt Deducted`, or the result of `CalculateOutstandingAmountExclTax` of a `Sales Line`/`Purchase Line`/`Service Line` as a known net or gross value (a net/gross total, a VAT computation, a comparison with `Amount` or `Item."Unit Price"`/`"Last Direct Cost"`, an export), or writes a source price of known basis into `Unit Price`/`Direct Unit Cost`, without reading the document header's `Prices Including VAT` — `document-line-prices-follow-prices-including-vat`. Reads of fixed-basis fields (`Amount`, `Amount Including VAT`, `Prepayment Amount`, `Prepmt. Amt. Incl. VAT`), combinations of header-dependent fields with each other, prices returned by the standard price calculation, and copies between lines of the same document are not this anti-pattern. - A report hand-constructs a barcode string only where a concrete, independently provable defect is visible: the source value can contain characters outside the symbology's character set and is never validated, a checksum the symbology/setup requires is never applied, or there is concrete evidence of an incompatible font binding. Do not flag manual start/stop delimiters by themselves — `*value*` is a documented, valid Code 39 form for IDAutomation fonts (IDAutomation also accepts parentheses), so delimiter choice alone is never a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. - A new field is typed `Code`/`Text` and its `OnValidate` calls `DimensionManagement`/`DimMgt`, or a table adds Shortcut Dimension fields, a `Dimension Set ID` field, or `AddDimSource`/`GetDefaultDimID` — `dimension-management-wiring`. A master table calling `SaveDefaultDim` and a document/journal table computing its own `Dimension Set ID` are two different valid shapes; do not flag a master table for lacking a `Dimension Set ID` field or a document for lacking `SaveDefaultDim`. - A journal-based posting codeunit is added or changed and validation, Journal-table access, ledger writes, and user-interaction (`Confirm`/dialogs) all occur in one procedure or one codeunit, rather than split across `Check Line`/`Post Line`/`Post Batch`-shaped companions — `check-post-line-batch-pattern`. A document posting routine calling `Post Line` directly without a `Post Batch` companion is not this anti-pattern. @@ -100,7 +101,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable data-modeling knowledge survived filtering. -- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface, and no document print/email/Post-and-Send action, `Navigate` subscriber, Report Selection registration/dispatch, price-calculation/price-source extensibility point, posting-cascade `TransferFields` mirroring, barcode/report-font-provider usage, dimension wiring, posting-routine structure, or Item-Ledger-Entry-document-number surface. +- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface, and no document print/email/Post-and-Send action, `Navigate` subscriber, Report Selection registration/dispatch, price-calculation/price-source extensibility point, sales/purchase/service line price or amount read/write, posting-cascade `TransferFields` mirroring, barcode/report-font-provider usage, dimension wiring, posting-routine structure, or Item-Ledger-Entry-document-number surface. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. From 0867171b1a71ab6fb58fe98134774f8e6b3517ae Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:53:14 +0200 Subject: [PATCH 41/51] 2 AL/BC UI patterns: client-expression in-list (AL0573) and Role Center AccessByPermission (#207) * Add UI knowledge: client-expression in-list and Role Center AccessByPermission Two ui articles with compiled good/bad samples: - page-client-expression-must-not-use-in-list: an `in [...]` list in Enabled/Visible/Editable/StyleExpr is rejected (AL0573 on actions, groups and parts; AL0322 on fields); remediate with an or-chain or a global Boolean, not a procedure call. Plain comparisons stay valid. - rolecenter-permission-gating-must-use-accessbypermission: Role Center pages and pageextensions of them cannot host triggers/procedures (AL0378/AL0569); gate parts by permission with AccessByPermission, with the UI Elements Removal and non-security-boundary caveats. Wired into al-ui-review worklist tokens and high-signal mappings, and registered both pairs in the ui review-fixtures override. Co-Authored-By: Claude Opus 5.5 * Address review: OnAfterGetCurrRecord for action state, RC gating nits - page-client-expression-must-not-use-in-list: recompute action/group/part state in OnAfterGetCurrRecord (OnAfterGetRecord runs per row), cite EDocumentLogs and concrete or-chain examples, note HideValue and that the property list is not exhaustive; good sample uses OnAfterGetCurrRecord. - rolecenter-permission-gating-must-use-accessbypermission: clarify LicenseFile vs LicenseFileAndUserPermissions removal, cite Business Manager RC Control96, samples gate a part the RC does not already have. - al-ui-review: add ReadPermission/WritePermission tokens. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- evaluation/review-fixtures.json | 4 +- ...ent-expression-must-not-use-in-list.bad.al | 59 +++++++++++++ ...nt-expression-must-not-use-in-list.good.al | 83 +++++++++++++++++++ ...-client-expression-must-not-use-in-list.md | 38 +++++++++ ...-gating-must-use-accessbypermission.bad.al | 23 +++++ ...gating-must-use-accessbypermission.good.al | 16 ++++ ...sion-gating-must-use-accessbypermission.md | 32 +++++++ microsoft/skills/review/al-ui-review.md | 4 +- 8 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.bad.al create mode 100644 microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.good.al create mode 100644 microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.md create mode 100644 microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.bad.al create mode 100644 microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.good.al create mode 100644 microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 628526d..974175f 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -159,7 +159,9 @@ "ui": { "articles": [ "default-descending-sort-on-historical-pages", - "page-design-must-match-bc-page-type-conventions" + "page-design-must-match-bc-page-type-conventions", + "page-client-expression-must-not-use-in-list", + "rolecenter-permission-gating-must-use-accessbypermission" ] }, "upgrade": { diff --git a/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.bad.al b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.bad.al new file mode 100644 index 0000000..cb537ca --- /dev/null +++ b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.bad.al @@ -0,0 +1,59 @@ +enum 50700 "Sample Request Status" +{ + Extensible = false; + + value(0; New) { Caption = 'New'; } + value(1; "Needs Review") { Caption = 'Needs Review'; } + value(2; Approved) { Caption = 'Approved'; } +} + +table 50700 "Sample Request" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + field(2; Status; Enum "Sample Request Status") { } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50700 "Sample Request Card" +{ + PageType = Card; + SourceTable = "Sample Request"; + ApplicationArea = All; + + layout + { + area(Content) + { + field("No."; Rec."No.") { } + field(Status; Rec.Status) { } + } + } + + actions + { + area(Processing) + { + action(Approve) + { + Caption = 'Approve'; + // AL0573: InListExpression is not valid for client expressions. + Enabled = Rec.Status in [Rec.Status::New, Rec.Status::"Needs Review"]; + + trigger OnAction() + begin + Rec.Status := Rec.Status::Approved; + Rec.Modify(true); + end; + } + } + } +} diff --git a/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.good.al b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.good.al new file mode 100644 index 0000000..f36ea5a --- /dev/null +++ b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.good.al @@ -0,0 +1,83 @@ +enum 50700 "Sample Request Status" +{ + Extensible = false; + + value(0; New) { Caption = 'New'; } + value(1; "Needs Review") { Caption = 'Needs Review'; } + value(2; Approved) { Caption = 'Approved'; } +} + +table 50700 "Sample Request" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + field(2; Status; Enum "Sample Request Status") { } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50700 "Sample Request Card" +{ + PageType = Card; + SourceTable = "Sample Request"; + ApplicationArea = All; + + layout + { + area(Content) + { + field("No."; Rec."No.") + { + // A plain field comparison is a valid client expression. + Editable = Rec.Status = Rec.Status::New; + } + field(Status; Rec.Status) + { + trigger OnValidate() + begin + UpdateActionStates(); + end; + } + } + } + + actions + { + area(Processing) + { + action(Approve) + { + Caption = 'Approve'; + // The list membership is computed in AL and exposed as a global Boolean. + Enabled = ApproveEnabled; + + trigger OnAction() + begin + Rec.Status := Rec.Status::Approved; + Rec.Modify(true); + UpdateActionStates(); + end; + } + } + } + + var + ApproveEnabled: Boolean; + + trigger OnAfterGetCurrRecord() + begin + UpdateActionStates(); + end; + + local procedure UpdateActionStates() + begin + ApproveEnabled := Rec.Status in [Rec.Status::New, Rec.Status::"Needs Review"]; + end; +} diff --git a/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.md b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.md new file mode 100644 index 0000000..60941cb --- /dev/null +++ b/microsoft/knowledge/ui/page-client-expression-must-not-use-in-list.md @@ -0,0 +1,38 @@ +--- +bc-version: [all] +domain: ui +keywords: [client-expression, in-list, inlistexpression, al0573, al0322, enabled, visible, editable, dynamic-enable] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Page client-expression properties must not use an `in [...]` list + +## Description + +`Enabled`, `Visible`, `Editable`, and `StyleExpr` on page controls (the list is not exhaustive; `HideValue` is rejected the same way) can be bound to a client expression instead of a literal. The documented dynamic forms are a global Boolean page variable, a Boolean field, or a Boolean expression over fields such as `"Credit Limit" > "Sales YTD"`; plain `=`/`<>`/`>` comparisons combined with `and`/`or`/`not` are valid. An `in [...]` set-membership test, such as `Rec.Status in [Rec.Status::New, Rec.Status::"Needs Review"]`, is not: the compiler reports it as "InListExpression is not valid for client expressions. Client expressions can only use simple data types and field references." + +The severity depends on the control. On a page field the diagnostic is already an error (AL0322). On an action, group, or part it is AL0573, a warning that "will become an error in a future release", so the code still builds and is easy to ship, suppress in a ruleset, or carry forward. A procedure call in the same property position is rejected by the same diagnostics, so moving the list test into a method called from the property does not fix it. + +## Best Practice + +Express the condition in a form a client expression accepts. For a short list, rewrite the membership as an `or` chain of field comparisons, which keeps the property a live client expression. For a longer or computed condition, evaluate it in AL (an `in [...]` list is fine there), store the result in a global page `Boolean` variable, and bind the property to that variable. Recompute the variable wherever its inputs change: `OnAfterGetCurrRecord` when the current record changes, and the `OnValidate` of each page field the condition reads for in-place edits. Do not use `OnAfterGetRecord` for action, group, or part state: it runs once per row loaded, so on a List or Worksheet page the variable ends up reflecting the last fetched row rather than the selected one (see [OnAfterGetCurrRecord is not per row](../performance/onaftergetcurrrecord-is-not-per-row.md)). `OnAfterGetRecord` is right only for per-row field state inside a repeater. + +For `Visible` on field and action controls, the Visible property documentation requires the variable to be resolved in `OnInit` or `OnOpenPage`; do not rely on per-record recomputation to show and hide those controls. `Enabled` and `Editable` have no such restriction. See sample: [`page-client-expression-must-not-use-in-list.good.al`](page-client-expression-must-not-use-in-list.good.al). + +## Anti Pattern + +A page or pageextension control property `Enabled`, `Visible`, `Editable`, or `StyleExpr` whose value contains `in [`, typically an enum or option field tested against several values. Reviewer signal: the `in [` token appears directly in the property value rather than inside a trigger or procedure body. Replacing it with a call to a procedure that performs the same test is the same defect in a different shape. + +Do not flag plain comparisons joined with `and`/`or`, such as `Enabled = (Rec.Status = Rec.Status::New) or (Rec.Status = Rec.Status::"Needs Review");`; they compile cleanly and Microsoft uses them, for example in BCApps `AccountantExpenseReports.Page.al` and `AgentTaskLogEntry.Page.al`. Do not flag `in [...]` used inside procedures or triggers that assign a Boolean variable. See sample: [`page-client-expression-must-not-use-in-list.bad.al`](page-client-expression-must-not-use-in-list.bad.al). + +## References + +- [Enabled property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-enabled-property): dynamic values are a Boolean variable, a Boolean field, or a Boolean expression such as "Credit Limit > Sales YTD"; variables must be global page variables. +- [OnAfterGetCurrRecord (Page) trigger](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/page/devenv-onaftergetcurrrecord-page-trigger): in a page with a repeater, called only when the current record is updated, after all `OnAfterGetRecord` calls for the rows. +- [Visible property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-visible-property): variables for field and action controls must be resolved by `OnInit` or `OnOpenPage`. +- [Compiler warning AL0573](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al573) and [compiler error AL0322](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al322). The Learn pages show only the `{0}` template; the compiler's message for this case is "InListExpression is not valid for client expressions. Client expressions can only use simple data types and field references." (AL compiler 30.0: AL0573 for action, group, and part properties; AL0322 for page field properties, including `HideValue`). +- Comparison-based client expressions in BCApps, for example `Enabled = Rec.Status <> Rec.Status::Running;` in [BCPTSetupCard.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Tools/Performance%20Toolkit/App/src/BCPTSetupCard.Page.al). BCApps contains no page client expression that uses an `in [...]` list. +- Global Boolean recomputed on current-record change in BCApps: [EDocumentLogs.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/src/Logging/EDocumentLogs.Page.al), a List page, binds `Enabled = IsExportEnabled;` and sets `IsExportEnabled` in `OnAfterGetCurrRecord`. +- Or-chain client expressions in BCApps: `Enabled = (Rec.Status = Rec.Status::"Pending Approval") or (Rec.Status = Rec.Status::"Interim Approved");` in [AccountantExpenseReports.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/AccountantExpenseReports.Page.al) and `Visible = (Rec.Type = Rec.Type::"Output Message Draft") or (Rec.Type = Rec.Type::"Output Message");` in [AgentTaskLogEntry.Page.al](https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Agent/Troubleshooting/AgentTaskLogEntry.Page.al). diff --git a/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.bad.al b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.bad.al new file mode 100644 index 0000000..c9f9a8b --- /dev/null +++ b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.bad.al @@ -0,0 +1,23 @@ +pageextension 50710 "Sample Bus. Mgr. RC Ext" extends "Business Manager Role Center" +{ + layout + { + addafter(Control16) + { + part(SampleMyCustomers; "My Customers") + { + ApplicationArea = Basic, Suite; + // AL0573: procedure calls are not valid for client expressions. + Visible = CanSeeMyCustomers(); + } + } + } + + // AL0569: a page of type Role Center cannot have procedures. + local procedure CanSeeMyCustomers(): Boolean + var + Customer: Record Customer; + begin + exit(Customer.ReadPermission()); + end; +} diff --git a/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.good.al b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.good.al new file mode 100644 index 0000000..c054d0a --- /dev/null +++ b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.good.al @@ -0,0 +1,16 @@ +pageextension 50710 "Sample Bus. Mgr. RC Ext" extends "Business Manager Role Center" +{ + layout + { + addafter(Control16) + { + part(SampleMyCustomers; "My Customers") + { + ApplicationArea = Basic, Suite; + // Declarative permission gating: the part is removed for users + // without Read permission on Customer. + AccessByPermission = TableData Customer = R; + } + } + } +} diff --git a/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.md b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.md new file mode 100644 index 0000000..e3cd058 --- /dev/null +++ b/microsoft/knowledge/ui/rolecenter-permission-gating-must-use-accessbypermission.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: ui +keywords: [accessbypermission, rolecenter, role-center, pageextension, client-expression, permission, al0569, al0573, al0378] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Gate Role Center content by permission with AccessByPermission + +## Description + +A page of type `RoleCenter` cannot have triggers (AL0378, error) or procedures (AL0569, warning that will become an error), and the AL compiler applies both rules to a pageextension whose target is a Role Center. That removes the usual way to show a control conditionally: compute a global Boolean in `OnOpenPage` and bind `Visible` to it. An easy-looking remediation of AL0378 is to delete the trigger and bind `Visible` or `Enabled` directly to a local procedure such as `CanSeeMyCustomers()`. That still builds, but with two future errors: AL0573 for the procedure call in a client expression and AL0569 for the procedure itself. Neither message names the alternative. + +When the condition is "the user has permission to this object", the declarative alternative is the `AccessByPermission` property on the part, action, or field. It takes `TableData = R|I|M|D` (any combination; having any one of the listed permissions is enough) or `X` for `Table`, `Page`, `Report`, `Codeunit`, `XmlPort`, or `Query`. Its applies-to list covers page fields, parts, system parts, chart parts, actions, and whole pages and reports; it does not include groups or cue groups. The element is removed for users without the permission, not disabled. That per-user removal requires the UI Elements Removal setting `LicenseFileAndUserPermissions`; under `LicenseFile` removal follows the license, not the user's permission sets. + +## Best Practice + +Set `AccessByPermission` on the Role Center part, action, or field that should appear only for users with the permission, naming the table or object that the part actually depends on. The base application does this on its own Role Centers, for example `AccessByPermission = TableData "Activities Cue" = I` on the activities part of the Business Manager Role Center and `TableData "Report Inbox" = IMD` on the Report Inbox part (`Control96`) of the same Role Center and of the Accountant Role Center. + +Two limits apply. The property takes effect only when the server's UI Elements Removal setting is `LicenseFile` or `LicenseFileAndUserPermissions`. It is UI removal, not a security boundary: the part's source data must still be protected by real permissions. When the condition is not a permission check, such as a setup value or a feature flag, `AccessByPermission` is the wrong tool. Put the condition inside the part page, which is a normal `CardPart` or `ListPart` that can have triggers. The part cannot remove itself from the Role Center, but it can hide or empty its own controls. See sample: [`rolecenter-permission-gating-must-use-accessbypermission.good.al`](rolecenter-permission-gating-must-use-accessbypermission.good.al). + +## Anti Pattern + +A `RoleCenter` page, or a pageextension whose target is a Role Center, binds `Visible` or `Enabled` on a part, action, or field to a procedure call whose body checks `ReadPermission`, `WritePermission`, or a similar permission test, and declares that procedure. The compiler reports AL0573 and AL0569 as warnings, and both will become errors. Reviewer signal: a pageextension declares a procedure and AL0569 appears in its build output. The extended page's name is not reliable evidence of its type, so confirm the target is a Role Center from its `PageType` or from that diagnostic. Do not flag setup- or feature-based gating implemented inside the part page itself; that is the correct location for non-permission conditions. See sample: [`rolecenter-permission-gating-must-use-accessbypermission.bad.al`](rolecenter-permission-gating-must-use-accessbypermission.bad.al). + +## References + +- [AccessByPermission property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-accessbypermission-property): applies-to list, permission values, any-one-of semantics, and the UI Elements Removal requirement ([Hide UI elements](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/administration/hide-ui-elements)). +- [Compiler error AL0378](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al378), [compiler warning AL0569](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al569), and [compiler warning AL0573](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al573). AL compiler 30.0 reports all three on a pageextension of a Role Center, as it does on the Role Center page itself. +- Base application usage: [BusinessManagerRoleCenter.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Finance/RoleCenters/BusinessManagerRoleCenter.Page.al) (`Control96`, lines 124-127) and [AccountantRoleCenter.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Finance/RoleCenters/AccountantRoleCenter.Page.al). No Role Center page in BCApps declares a trigger or procedure. diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md index f54c0c9..e5f237f 100644 --- a/microsoft/skills/review/al-ui-review.md +++ b/microsoft/skills/review/al-ui-review.md @@ -42,7 +42,7 @@ Narrow the relevant files to the subset that applies to the changes under review - **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files. - A new or changed page's name/suffix, primary-key handling, `CardPageID`, `SubPageLink`, `AutoSplitKey`, or `UsageCategory` doesn't match the conventions of its own declared `PageType` — `page-design-must-match-bc-page-type-conventions.md`. A Card page over a composite-key table that supplements a master record, or a supporting/subpage/dialog page intended only to be reached through another workflow and correctly omitting `UsageCategory`, is not this anti-pattern on its own; check whether the page is actually mixing conventions or is meant as a searchable entry point before flagging. - For each relevant knowledge file, compute overlap against changed page declarations and control add-in files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, promoted action definitions, field importance, page background tasks, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL. -- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `fieldgroups`, `DropDown`, `UpdatePropagation`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions, `CardPageID`, `AutoSplitKey`, `PageType = Card`, `PageType = List`, `PageType = Worksheet`, `PageType = Document`). +- 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`, `PageType = RoleCenter`, `Enabled`, `Visible`, `Editable`, `in [`, `AccessByPermission`, `ReadPermission`, `WritePermission`). 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 page element. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -50,6 +50,8 @@ Apply these high-signal mappings before fuzzy topic ranking: - A tableextension adds a field to `DropDown` while the corresponding lookup-page control remains `Visible = false` — `dropdown-fieldgroup-respects-lookup-page-visibility`. - An editable page part affects a total, FlowField, or FactBox on the parent but does not set `UpdatePropagation = Both` — `updatepropagation-both-refreshes-main-page`. +- A page or pageextension `Enabled`, `Visible`, `Editable`, or `StyleExpr` value contains an `in [...]` list (compiler: "InListExpression is not valid for client expressions", AL0573 or AL0322), or replaces one with a procedure call — `page-client-expression-must-not-use-in-list`. Plain `=`/`<>` comparisons joined with `and`/`or`, and `in [...]` inside a trigger or procedure body, are valid; do not flag them. +- A `RoleCenter` page, or a pageextension whose target is a Role Center, gates a part, action, or field by binding `Visible` or `Enabled` to a procedure that tests a permission, or declares a procedure (AL0569 "A page of type Role Center cannot have procedures", AL0573) — `rolecenter-permission-gating-must-use-accessbypermission`. The target page name is not reliable evidence of its type; confirm it is a Role Center from its `PageType` or the AL0569 diagnostic. Setup- or feature-flag gating inside the part page is not this pattern. Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. From 206c5feff4160593c56edc501b52504d06b9ed6d Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:27:27 +0200 Subject: [PATCH 42/51] Validate composed reviews against the expected leaf worklist (#204) * Validate composed reviews against the expected leaf worklist * Bind composed leaf reports to host-accepted results * Preserve literal strings in composed report acceptance --- docs/contributing.md | 3 +- docs/standalone-runner.md | 69 +++- microsoft/skills/review/al-code-review.md | 22 +- skills/do.md | 56 ++- tools/Test-ReviewContract.ps1 | 406 +++++++++++++++++++++- tools/Validate-FindingsReport.ps1 | 254 +++++++++++++- 6 files changed, 784 insertions(+), 26 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 51f6d0f..e0a51c3 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -154,7 +154,8 @@ not a deployable or compiled application. ## Before opening a PR From your BCQuality checkout, use the existing validators. The Python -validator needs Python and PyYAML; the fixture harness needs PowerShell 7. +validator needs Python and PyYAML; the fixture harness needs PowerShell 7.5 or later +so findings-report parsing preserves timestamp-shaped JSON strings verbatim. If PyYAML is not installed in your development environment, install it with `python -m pip install pyyaml`. diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index e5e2a0c..8980b03 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -53,7 +53,9 @@ only result. with `tools/Resolve-SkillWorklist.ps1`, passing the enabled layers and disabled skill paths from the task context. Execute every resolved leaf as a discrete invocation. Leaves are independent and may be scheduled serially - or concurrently. + or concurrently. Before dispatch, preserve the final ordered selection and + legitimate configuration/input-compatibility exclusions as a private expected + composition artifact; see [composition acceptance](#composition-acceptance). 5. Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in private artifacts or host logs. Before the full DO acceptance gate, create a normalized candidate only for DO's @@ -61,7 +63,7 @@ only result. telemetry, and accept the candidate only if the entire copy passes the unchanged strict gate. Use `tools/Validate-FindingsReport.ps1`, passing the exact source paths and fully retrieved article paths; pass `-SkillKind super` - for the final rolled-up report. The accepted report contains no undeclared + and `-ExpectedCompositionPath` for the final rolled-up report. The accepted report contains no undeclared telemetry fields. 6. Collect each accepted findings-report into `sub-results` in the declared `sub-skills` order, not completion order. Run the super-skill self-review @@ -74,6 +76,67 @@ The runner must never inspect the diff to skip a review domain. A leaf decides its own task-level applicability and reports `not-applicable` or `no-knowledge`. +## Composition acceptance + +The findings-report validator requires PowerShell 7.5 or later to preserve +literal JSON strings with `ConvertFrom-Json -DateKind String`. + +A report can be internally consistent while omitting a selected review. Bind +the final acceptance gate to the host's selection, not just the returned +reports. The private JSON input to `-ExpectedCompositionPath` follows the +[DO consumer acceptance contract](../skills/do.md#consumer-acceptance-gate). +For example, if style is selected and security was disabled: + +```json +{ + "superSkill": { "id": "al-code-review", "version": 1 }, + "subSkills": [{ "id": "al-style-review", "version": 1 }], + "skipped": [{ "id": "al-security-review", "version": 1, "reason": "configuration" }], + "acceptedResults": [] +} +``` + +Build this artifact from `Resolve-SkillWorklist.ps1` and the input-compatibility +decision before dispatch. Resolver `skipped` entries have no version: enrich +them from the declared skill's indexed version. Move an input-incompatible +selected slot to `skipped` with its selected version and `not-applicable` +reason; never use source content or later model output to make that decision. +Keep paths, layers, and other resolver metadata if useful for private audit. +Do not add the artifact to the findings-report or overwrite it to hide an +unfinished invocation. Preserve it with the run's raw payloads. + +Initialize `acceptedResults` before dispatch. After accepting a leaf, save its +exact accepted copy (including any permitted normalization) in an immutable +host-owned file outside worker/composer write access. Append only its capture +to `acceptedResults`, for example: + +```json +{"id": "al-style-review", "version": 1, "reportPath": "accepted/style.json"} +``` + +`reportPath` may be absolute or relative to the composition artifact's directory. +Capture a host-created failed validation report the same way. Never construct +these captures from the composed `sub-results` or permit the composing model +to supply or alter them. Keep the original selection and exclusions unchanged. + +The gate rejects repeated or unexpected leaf IDs, wrong selected versions, +reordered results, fabricated or missing exclusions, and uncaptured or altered +leaf content. JSON property order is immaterial; array order, field presence, +types, and values must match. Every captured leaf must appear in `sub-results`. +If selected leaves remain unfinished, the report must be `partial` with a non-failed returned +report, otherwise `failed`; name every unfinished leaf ID exactly in +`outcome-reason`. Top-level `from-sub-skill: "agent"` findings are rejected +while selected leaf results are missing. +Do not fabricate leaf reports or use configuration skips for budget exhaustion. +Wait for started invocations to finish; omit the self-review if the selected +composition remains incomplete. Coverage still sums the non-failed leaf +knowledge worklists, not the number of selected leaf slots. + +Calls without the expected artifact remain supported for structural/semantic +validation, but cannot certify that a composed review covered its selection. +They also cannot bind nested leaves to the host's accepted outputs. +They still reject duplicate leaf IDs and returned-and-skipped conflicts. + ## Runner-owned choices Keep these settings and behaviors outside BCQuality: @@ -113,6 +176,8 @@ A compatible runner: only part of the review is reliable; - orders `sub-results` by the declared worklist and orders rendered findings deterministically; +- validates composition against the host-owned selection, and reports selected + leaves left unfinished as incomplete rather than clean or configured away; - calculates top-level severity counts from deduplicated top-level findings, not by summing leaf counts; - preserves knowledge paths verbatim and verifies references before publishing; diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index 7a3d0c6..30bf659 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -71,6 +71,22 @@ Sub-skills that fail either check are not invoked and are recorded in `skipped-s The worklist is the list of sub-skills judged relevant by the previous step. Every sub-skill in the worklist will be invoked in the Action step. +Before dispatch, preserve that selection in a private host-owned expected +composition artifact using DO's input contract. Start from the resolver's +ordered selection; enrich configuration skips with the declared indexed +skill's version, and record any input-incompatible exclusions with +`reason: "not-applicable"`. Do not derive this artifact from leaf reports or +change it merely because execution later runs out of budget. Pass its path as +`-ExpectedCompositionPath` for final super-skill validation. +Initialize `acceptedResults` to `[]`. After each leaf's acceptance gate, the +host saves its exact accepted copy (or host-created failed validation result) +in an immutable private file and appends its `id`, `version`, and `reportPath` +to that array. The host alone owns these captures; neither workers nor the +composing model may write them. Never derive them from composed `sub-results`. +Keep the pre-dispatch selection and exclusions unchanged. Final validation +requires every nested leaf to match its captured JSON content exactly and +every captured result to be included. Property order is immaterial. + ## Action ### Execution discipline (mandatory) @@ -85,7 +101,7 @@ The Action step consists of **discrete leaf invocations**, not one combined gene - Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches). - The agent self-review pass is its own final iteration. Begin it only after every sub-skill in the worklist has completed and its sub-result is recorded. - Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this — `sub-results` carries one entry per sub-skill, each a complete findings-report, in the frontmatter `sub-skills` order regardless of completion order. -- When isolated calls are unavailable and the current model cannot finish every leaf within its budget, return `partial` with completed `sub-results` and name the first unevaluated sub-skill in `outcome-reason`. Never silently mark the remaining leaves clean. +- When the execution budget prevents invoking every selected leaf, wait for started invocations to finish and preserve their accepted `sub-results`. Return `partial` if any returned report is non-`failed`, otherwise `failed`, and name the unfinished leaf IDs in `outcome-reason`. Do not run the self-review on incomplete composition, invent sub-results, or record budget exhaustion as a configured/input-incompatible skip. Never silently mark the remaining leaves clean. ### Roll up sub-skill findings @@ -138,7 +154,9 @@ Calculate `summary.counts` from the final top-level `findings[]`, after failed s Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."* Before emitting the rollup, apply DO's consumer acceptance gate to every nested -and top-level finding. A leaf's nested report is its accepted exact return or +and top-level finding, and validate the final report with +`-SkillKind super -ExpectedCompositionPath ` against the +selection preserved before dispatch. A leaf's nested report is its accepted exact return or its accepted normalized candidate copy; its exact Task return remains the separate immutable raw audit payload. Treat an invalid sub-result as failed and exclude all of its findings from the top-level rollup. Never reconstruct it diff --git a/skills/do.md b/skills/do.md index db0d634..e5aef5b 100644 --- a/skills/do.md +++ b/skills/do.md @@ -224,7 +224,36 @@ as a findings-report, a coordinator or host MUST validate it deterministically: Hosts SHOULD execute `tools/Validate-FindingsReport.ps1` with the exact source scope and the leaf's recorded set of fully retrieved article paths. Pass -`-SkillKind super` when validating a super-skill's rolled-up report. Pass +`-SkillKind super -ExpectedCompositionPath ` when validating +a super-skill's rolled-up report. Prepare that private artifact before leaf +dispatch, after layer resolution and input compatibility checks. It contains +`superSkill` (`id`, `version`), ordered selected `subSkills` (each with `id`, +`version`), `skipped` (each with `id`, `version`, `reason`), and an initially +empty `acceptedResults` array. Reasons are `configuration` or `not-applicable`; +budget exhaustion is not a skip reason. +Additional resolver metadata may be retained in the artifact, not the report. +After each leaf passes its acceptance gate, the host saves the exact accepted +copy in a private immutable file and appends an `acceptedResults` entry with +`id`, `version`, and `reportPath`. Capture host-created failed validation +results the same way. Paths may be absolute or relative to the composition +artifact's directory. Capture the normalized accepted copy when normalization +was permitted, not the invalid raw return. Do not expose these files or write +access to the artifact to leaf workers or the composing model. Only the host +may append captures; the pre-dispatch selection and exclusions remain fixed. +The validator binds the super-skill and leaf identities and versions, checks +selected order, and requires exact agreement on exclusions. Each nested leaf +must exactly match its host-captured accepted JSON content, ignoring object +property order but preserving array order, types, values, and field presence. +Every captured leaf must be included; uncaptured or altered leaves are invalid. +Every returned leaf must be unique; a selected leaf cannot be reclassified as skipped by the +report. When selected leaves are missing, `outcome-reason` must name every +missing ID exactly and top-level `from-sub-skill: "agent"` findings are forbidden. +Without the artifact, validation remains structural and semantic but +cannot prove composition completeness, selected versions, order, or legitimate +exclusions, nor bind leaves to accepted host outputs. Duplicate or both +returned-and-skipped leaf IDs are invalid even without the artifact. Never +derive the expected composition from model output. +Pass `-AllowBoundedNormalization` only when the host preserves the immutable raw payload and records `removedRanges` in private telemetry as required above. @@ -330,7 +359,7 @@ Omit `suggested-code` only when the appropriate fix depends on context the skill - `reference` — the suppressed file (same object shape as `findings[].references`). - `reason` — `layer-precedence` when another layer won under READ's precedence rules; `configuration` when the consumer disabled the file's layer. -**`sub-results`** — super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Entries MUST appear in the worklist's declared order, regardless of invocation or completion order. Leaf skills MUST NOT emit `sub-results`. +**`sub-results`** — super-skills only. Array of complete findings-reports, one per invoked sub-skill, with no duplicate skill IDs. Each entry MUST itself conform to this output contract. Entries MUST appear in the worklist's declared order, regardless of invocation or completion order. A selected leaf left uninvoked by budget exhaustion has no fabricated sub-result and is not a configured or input-incompatible skip; its absence requires the incomplete-composition outcome below. Leaf skills MUST NOT emit `sub-results`. **`skipped-sub-skills`** — super-skills only. Array of sub-skills that were declared in frontmatter but not invoked. `reason` is `configuration` when the orchestrator disabled the sub-skill, or `not-applicable` when the super-skill's Relevance step ruled it out. @@ -356,10 +385,14 @@ orchestrator. Each leaf invocation MUST remain a discrete evaluation with its own complete findings-report. An orchestrator MAY execute independent leaves serially or -concurrently, but MUST invoke every worklisted leaf, preserve `sub-results` in -the declared worklist order, and wait for every invocation to finish before -performing any super-skill self-review or final rollup. Scheduling MUST NOT -change relevance, coverage, failure, reference-integrity, or output semantics. +concurrently, but MUST attempt every worklisted leaf, preserve `sub-results` +in the declared worklist order, and wait for every started invocation to +finish before final rollup. If its execution budget prevents dispatching +remaining leaves, preserve the unfinished selection in the host-owned expected +composition and use the incomplete-composition outcome below. Do not perform +the super-skill self-review until every selected leaf has returned. Scheduling +MUST NOT change relevance, coverage, failure, reference-integrity, or output +semantics. Orchestrators SHOULD generate `skill-index.json` with `tools/Build-SkillIndex.ps1` instead of parsing Markdown. Each declared @@ -390,7 +423,16 @@ The five required sections still apply. Their meaning shifts from knowledge file ### Outcome rollup -A super-skill's `outcome` is derived from its sub-skills' outcomes. Let S be the multiset of sub-skill outcomes for sub-skills in the worklist (skipped sub-skills do not contribute): +A super-skill's `outcome` is derived from its selected worklist and returned +sub-skills' outcomes. If selected leaves have no returned report, the outcome +is `partial` when at least one returned report is non-`failed`, or `failed` +when no non-`failed` report is available. It MUST NOT be `completed`, +`not-applicable`, or `no-knowledge`. Name unfinished leaf IDs in +`outcome-reason`, preserve the valid returned reports, and never invent +successful or failed invocations for leaves that were not invoked. + +When every selected leaf has returned, let S be the multiset of their outcomes +(skipped sub-skills do not contribute): - `failed` — every element of S is `failed`. - `partial` — S contains at least one `partial`, OR S contains at least one `failed` alongside at least one non-`failed` outcome. diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1 index adacb36..db1c1b1 100644 --- a/tools/Test-ReviewContract.ps1 +++ b/tools/Test-ReviewContract.ps1 @@ -269,6 +269,8 @@ try { & $validator -ReportPath $reportPath -BCQualityRoot $Root } + $completedSecurityLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $completedSecurityLeaf.skill.id = 'al-security-review' $validSuperReport = [ordered]@{ skill = [ordered]@{ id = 'al-code-review'; version = 1 } outcome = 'completed' @@ -278,12 +280,410 @@ try { } findings = @() suppressed = @() - 'sub-results' = @($completedLeaf, $completedLeaf) + 'sub-results' = @($completedLeaf, $completedSecurityLeaf) } Set-Content -LiteralPath $reportPath -Value ($validSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM $acceptedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super Assert-True (-not $acceptedSuper.normalized) 'valid super-skill report is accepted' + $duplicateLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $duplicateLeafReport.'sub-results' = @($completedLeaf, $completedLeaf) + Set-Content -LiteralPath $reportPath -Value ($duplicateLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_DUPLICATE_SUB_RESULT*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super + } + + $compositionPath = Join-Path $tmp 'composition.json' + function Save-AcceptedLeafReports { + param([object[]] $LeafReports) + + foreach ($leafReport in $LeafReports) { + $leafPath = Join-Path $tmp "$([guid]::NewGuid()).json" + Set-Content -LiteralPath $leafPath -Value ($leafReport | ConvertTo-Json -Depth 100) -Encoding utf8NoBOM + $accepted = & $validator -ReportPath $leafPath -BCQualityRoot $Root + Assert-True (-not $accepted.normalized) 'host captures a validated leaf report' + @{ id = $leafReport.skill.id; version = $leafReport.skill.version; reportPath = $leafPath } + } + } + + $expectedComposition = [ordered]@{ + superSkill = @{ id = 'al-code-review'; version = 1 } + subSkills = @( + @{ id = 'al-style-review'; version = 1 } + @{ id = 'al-security-review'; version = 1 } + ) + skipped = @() + acceptedResults = @(Save-AcceptedLeafReports @($completedLeaf, $completedSecurityLeaf)) + } + Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Set-Content -LiteralPath $reportPath -Value ($validSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedBoundSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -ExpectedCompositionPath $compositionPath + Assert-True (-not $acceptedBoundSuper.normalized) 'complete composition matches the expected worklist' + + $incompleteComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $incompleteComposition.acceptedResults = @($incompleteComposition.acceptedResults[0]) + Set-Content -LiteralPath $compositionPath -Value ($incompleteComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $missingLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $missingLeafReport.'sub-results' = @($completedLeaf) + $missingLeafReport.summary.coverage.'worklist-size' = 1 + $missingLeafReport.summary.coverage.'items-evaluated' = 1 + Set-Content -LiteralPath $reportPath -Value ($missingLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_OUTCOME_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super -ExpectedCompositionPath $compositionPath + } + $missingLeafReport.outcome = 'partial' + $missingLeafReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'al-security-review was not evaluated before the budget expired.' + Set-Content -LiteralPath $reportPath -Value ($missingLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedIncompleteSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -ExpectedCompositionPath $compositionPath + Assert-True ($acceptedIncompleteSuper.report.outcome -ceq 'partial') 'unfinished selected leaves require a truthful partial outcome' + + function Assert-CompositionReport { + param([object] $Candidate, [string] $ErrorPattern) + + Set-Content -LiteralPath $reportPath -Value ($Candidate | ConvertTo-Json -Depth 30) -Encoding utf8NoBOM + if ($ErrorPattern) { + Assert-ThrowsLike -Pattern $ErrorPattern -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -ExpectedCompositionPath $compositionPath -AllowBoundedNormalization + } + } + else { + $accepted = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -ExpectedCompositionPath $compositionPath + Assert-True (-not $accepted.normalized) 'valid expected composition is accepted without repairs' + } + } + + $genericMissingReason = $missingLeafReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $genericMissingReason.'outcome-reason' = 'Budget expired.' + Assert-CompositionReport $genericMissingReason '*SUPER_MISSING_LEAF_REASON*' + $genericMissingReason.'outcome-reason' = 'prefix-al-security-review-suffix was not evaluated.' + Assert-CompositionReport $genericMissingReason '*SUPER_MISSING_LEAF_REASON*' + + foreach ($fabricatedOutcome in 'completed', 'not-applicable', 'no-knowledge') { + $fabricatedLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $fabricatedLeafReport.'sub-results'[1].outcome = $fabricatedOutcome + if ($fabricatedOutcome -cne 'completed') { + $fabricatedLeafReport.'sub-results'[1].summary.coverage.'worklist-size' = 0 + $fabricatedLeafReport.'sub-results'[1].summary.coverage.'items-evaluated' = 0 + $fabricatedLeafReport.summary.coverage.'worklist-size' = 1 + $fabricatedLeafReport.summary.coverage.'items-evaluated' = 1 + } + Assert-CompositionReport $fabricatedLeafReport '*SUPER_LEAF_NOT_ACCEPTED*' + } + Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-CompositionReport $missingLeafReport '*SUPER_ACCEPTED_LEAF_MISSING*' + $alteredLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $alteredLeafReport.'sub-results'[1].summary.coverage.'worklist-size' = 2 + $alteredLeafReport.'sub-results'[1].summary.coverage.'items-evaluated' = 2 + $alteredLeafReport.summary.coverage.'worklist-size' = 3 + $alteredLeafReport.summary.coverage.'items-evaluated' = 3 + Assert-CompositionReport $alteredLeafReport '*SUPER_LEAF_CONTENT_MISMATCH*' + $reorderedProperties = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $reorderedProperties.'sub-results'[0].skill = [pscustomobject]@{ version = 1; id = 'al-style-review' } + Assert-CompositionReport $reorderedProperties + foreach ($alteredOutcome in 'not-applicable', 'no-knowledge') { + $alteredOutcomeReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $alteredOutcomeReport.'sub-results'[1].outcome = $alteredOutcome + $alteredOutcomeReport.'sub-results'[1].summary.coverage.'worklist-size' = 0 + $alteredOutcomeReport.'sub-results'[1].summary.coverage.'items-evaluated' = 0 + $alteredOutcomeReport.summary.coverage.'worklist-size' = 1 + $alteredOutcomeReport.summary.coverage.'items-evaluated' = 1 + Assert-CompositionReport $alteredOutcomeReport '*SUPER_LEAF_CONTENT_MISMATCH*' + } + $relativeCaptureComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + foreach ($capture in $relativeCaptureComposition.acceptedResults) { + $capture.reportPath = Split-Path -Leaf $capture.reportPath + } + Set-Content -LiteralPath $compositionPath -Value ($relativeCaptureComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-CompositionReport $validSuperReport + $uncapturedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $uncapturedComposition.PSObject.Properties.Remove('acceptedResults') + Set-Content -LiteralPath $compositionPath -Value ($uncapturedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-CompositionReport $validSuperReport '*Invalid expected composition*' + $duplicateCaptureComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $duplicateCaptureComposition.acceptedResults = @($duplicateCaptureComposition.acceptedResults[0], $duplicateCaptureComposition.acceptedResults[0]) + Set-Content -LiteralPath $compositionPath -Value ($duplicateCaptureComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-CompositionReport $validSuperReport '*Invalid expected composition*' + + $capturedFindingLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $capturedFindingLeaf.findings = @(@{ + id = 'agent:leaf-issue' + severity = 'minor' + confidence = 'medium' + message = 'Preserve this accepted leaf finding.' + references = @() + }) + $secondCapturedFinding = $capturedFindingLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $secondCapturedFinding.id = 'agent:second-leaf-issue' + $capturedFindingLeaf.findings = @($capturedFindingLeaf.findings[0], $secondCapturedFinding) + $capturedFindingLeaf.summary.counts.minor = 2 + $findingComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $findingComposition.acceptedResults = @(Save-AcceptedLeafReports @($capturedFindingLeaf, $completedSecurityLeaf)) + Set-Content -LiteralPath $compositionPath -Value ($findingComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $capturedFindingReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $capturedFindingReport.'sub-results'[0] = $capturedFindingLeaf + $capturedFindingReport.findings = @($capturedFindingLeaf.findings | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + foreach ($finding in $capturedFindingReport.findings) { + $finding.id = "al-style-review:$($finding.id)" + $finding | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-style-review' + } + $capturedFindingReport.summary.counts.minor = 2 + Assert-CompositionReport $capturedFindingReport + $reorderedFindingReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $reorderedFindingReport.'sub-results'[0].findings = @( + $reorderedFindingReport.'sub-results'[0].findings[1] + $reorderedFindingReport.'sub-results'[0].findings[0] + ) + Assert-CompositionReport $reorderedFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*' + $addedLeafFieldReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $addedLeafFieldReport.'sub-results'[0] | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'A composer-added field.' + Assert-CompositionReport $addedLeafFieldReport '*SUPER_LEAF_CONTENT_MISMATCH*' + $alteredFindingReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $alteredFindingReport.'sub-results'[0].findings[0].message = 'A fabricated replacement message.' + $alteredFindingReport.findings[0].message = 'A fabricated replacement message.' + Assert-CompositionReport $alteredFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*' + $removedFindingReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $removedFindingReport.'sub-results'[0].findings = @() + $removedFindingReport.'sub-results'[0].summary.counts.minor = 0 + $removedFindingReport.findings = @() + $removedFindingReport.summary.counts.minor = 0 + Assert-CompositionReport $removedFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*' + + $capturedCorrectionLeaf = $capturedFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $capturedCorrectionLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'exit(1);' + $correctionComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $correctionComposition.acceptedResults = @(Save-AcceptedLeafReports @($capturedCorrectionLeaf, $completedSecurityLeaf)) + Set-Content -LiteralPath $compositionPath -Value ($correctionComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $capturedCorrectionReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $capturedCorrectionReport.'sub-results'[0] = $capturedCorrectionLeaf + $capturedCorrectionReport.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'exit(1);' + Assert-CompositionReport $capturedCorrectionReport + $correctionCapturePath = $correctionComposition.acceptedResults[0].reportPath + $immutableCorrectionCapture = [IO.File]::ReadAllText($correctionCapturePath) + foreach ($codePoint in @(0x0000, 0x00AD, 0x200B, 0xFEFF)) { + $alteredCorrectionReport = $capturedCorrectionReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $alteredCorrection = 'ex' + [char]$codePoint + 'it(1);' + $alteredCorrectionReport.'sub-results'[0].findings[0].'suggested-code' = $alteredCorrection + $alteredCorrectionReport.findings[0].'suggested-code' = $alteredCorrection + Assert-CompositionReport $alteredCorrectionReport '*SUPER_LEAF_CONTENT_MISMATCH*' + $rolledOnlyCorrectionReport = $capturedCorrectionReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $rolledOnlyCorrectionReport.findings[0].'suggested-code' = $alteredCorrection + Assert-CompositionReport $rolledOnlyCorrectionReport '*SUPER_FINDING_MISMATCH*' + Assert-True ([string]::Equals([IO.File]::ReadAllText($correctionCapturePath), $immutableCorrectionCapture, [StringComparison]::Ordinal)) ` + 'rejecting altered corrections leaves the immutable host capture unchanged' + } + $timestampReason = '2026-10-02T09:00:00Z' + $timestampLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $timestampLeaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue $timestampReason + $timestampComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $timestampComposition.acceptedResults = @(Save-AcceptedLeafReports @($timestampLeaf, $completedSecurityLeaf)) + Set-Content -LiteralPath $compositionPath -Value ($timestampComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $timestampReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $timestampReport.'sub-results'[0] = $timestampLeaf + foreach ($alteredTimestamp in @('2026-10-02T09:00:00.000Z', '2026-10-02T09:00:00+00:00')) { + $timestampLeaf.'outcome-reason' = $alteredTimestamp + Assert-CompositionReport $timestampReport '*SUPER_LEAF_CONTENT_MISMATCH*' + } + $timestampLeaf.'outcome-reason' = $timestampReason + Set-Content -LiteralPath $reportPath -Value ($timestampReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedTimestampReport = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -ExpectedCompositionPath $compositionPath + $acceptedReason = $acceptedTimestampReport.report.'sub-results'[0].'outcome-reason' + Assert-True ($acceptedReason -is [string] -and [string]::Equals($acceptedReason, $timestampReason, [StringComparison]::Ordinal)) ` + 'accepted timestamp-shaped JSON text remains the original literal string' + $normalizedTimestampReport = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $normalizedTimestampReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue '2026-10-02T09:00:00.000Z' + $normalizedTimestampReport.findings[0].location.range.'start-line' = 1 + Set-Content -LiteralPath $reportPath -Value ($normalizedTimestampReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedNormalizedTimestamp = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath -AllowBoundedNormalization + Assert-True $acceptedNormalizedTimestamp.normalized 'bounded normalization still applies to an eligible range' + Assert-True ($acceptedNormalizedTimestamp.report.'outcome-reason' -is [string] -and + [string]::Equals($acceptedNormalizedTimestamp.report.'outcome-reason', $normalizedTimestampReport.'outcome-reason', [StringComparison]::Ordinal)) ` + 'bounded normalization preserves unrelated timestamp-shaped text exactly' + Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + + foreach ($case in @( + @{ Pattern = '*SUPER_IDENTITY_MISMATCH*'; Change = { param($candidate) $candidate.skill.id = 'al-other-review' } } + @{ Pattern = '*SUPER_IDENTITY_MISMATCH*'; Change = { param($candidate) $candidate.skill.version = 2 } } + @{ Pattern = '*SUPER_LEAF_VERSION_MISMATCH*'; Change = { param($candidate) $candidate.'sub-results'[0].skill.version = 2 } } + @{ Pattern = '*SUPER_UNEXPECTED_SUB_RESULT*'; Change = { param($candidate) $candidate.'sub-results'[0].skill.id = 'al-other-review' } } + @{ Pattern = '*SUPER_SUB_RESULT_ORDER*'; Change = { param($candidate) $candidate.'sub-results' = @($candidate.'sub-results'[1], $candidate.'sub-results'[0]) } } + @{ Pattern = '*SUPER_DUPLICATE_SUB_RESULT*'; Change = { param($candidate) $candidate.'sub-results' = @($candidate.'sub-results'[0], $candidate.'sub-results'[0]) } } + )) { + $candidate = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + & $case.Change $candidate + Assert-CompositionReport $candidate $case.Pattern + } + + $fabricatedSkip = $missingLeafReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $fabricatedSkip | Add-Member -NotePropertyName 'skipped-sub-skills' -NotePropertyValue @( + @{ skill = @{ id = 'al-security-review'; version = 1 }; reason = 'configuration' } + ) + Assert-CompositionReport $fabricatedSkip '*SUPER_UNEXPECTED_SKIP*' + + $emptyAcceptedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $emptyAcceptedComposition.acceptedResults = @() + Set-Content -LiteralPath $compositionPath -Value ($emptyAcceptedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $noResults = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $noResults.'sub-results' = @() + $noResults.summary.coverage.'worklist-size' = 0 + $noResults.summary.coverage.'items-evaluated' = 0 + $noResults.outcome = 'not-applicable' + Assert-CompositionReport $noResults '*SUPER_OUTCOME_MISMATCH*' + $noResults.outcome = 'failed' + $noResults | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'al-style-review and al-security-review could not be evaluated.' + Assert-CompositionReport $noResults + + $oneMissingId = $noResults | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $oneMissingId.'outcome-reason' = 'al-security-review could not be evaluated.' + Assert-CompositionReport $oneMissingId '*SUPER_MISSING_LEAF_REASON*' + foreach ($baseReport in @($missingLeafReport, $noResults, $validSuperReport)) { + $capturedComposition = if ($baseReport.outcome -ceq 'completed') { $expectedComposition } else { $incompleteComposition } + Set-Content -LiteralPath $compositionPath -Value ($capturedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $selfReviewReport = $baseReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $selfReviewReport.findings = @(@{ + id = 'agent:cross-domain-gap' + domain = 'Agent' + severity = 'minor' + confidence = 'medium' + message = 'A cross-domain issue needs attention.' + references = @() + 'from-sub-skill' = 'agent' + }) + $selfReviewReport.summary.counts.minor = 1 + if ($baseReport.outcome -ceq 'completed') { + Assert-CompositionReport $selfReviewReport + } + elseif ($baseReport.outcome -ceq 'failed') { + Assert-CompositionReport $selfReviewReport '*Invalid findings-report JSON or schema*' + } + else { + Assert-CompositionReport $selfReviewReport '*SUPER_AGENT_REVIEW_INCOMPLETE*' + } + } + + $allFailed = $noResults | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $allFailed.'sub-results' = @($completedLeaf, $completedSecurityLeaf) | ConvertTo-Json -Depth 20 | ConvertFrom-Json + foreach ($leaf in $allFailed.'sub-results') { + $leaf.outcome = 'failed' + $leaf.summary.coverage.'items-evaluated' = 0 + $leaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'Invocation failed.' + } + $failedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $failedComposition.acceptedResults = @(Save-AcceptedLeafReports $allFailed.'sub-results') + Set-Content -LiteralPath $compositionPath -Value ($failedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-CompositionReport $allFailed + + $skipComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $skipComposition.subSkills = @($skipComposition.subSkills[0]) + $skipComposition.skipped = @(@{ id = 'al-security-review'; version = 1; reason = 'not-applicable' }) + $skipComposition.acceptedResults = @($skipComposition.acceptedResults[0]) + Set-Content -LiteralPath $compositionPath -Value ($skipComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $validSkippedReport = $fabricatedSkip | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $validSkippedReport.outcome = 'completed' + $validSkippedReport.PSObject.Properties.Remove('outcome-reason') + $validSkippedReport.'skipped-sub-skills'[0].reason = 'not-applicable' + Assert-CompositionReport $validSkippedReport + Assert-CompositionReport $missingLeafReport '*SUPER_SKIP_MISSING*' + $wrongSkip = $validSkippedReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $wrongSkip.'skipped-sub-skills'[0].reason = 'configuration' + Assert-CompositionReport $wrongSkip '*SUPER_SKIP_MISMATCH*' + $wrongSkip.'skipped-sub-skills'[0].reason = 'not-applicable' + $wrongSkip.'skipped-sub-skills'[0].skill.version = 2 + Assert-CompositionReport $wrongSkip '*SUPER_SKIP_MISMATCH*' + $duplicateSkip = $validSkippedReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $duplicateSkip.'skipped-sub-skills' = @($duplicateSkip.'skipped-sub-skills'[0], $duplicateSkip.'skipped-sub-skills'[0]) + Assert-CompositionReport $duplicateSkip '*SUPER_SKIP_CONFLICT*' + $returnedAndSkipped = $validSkippedReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $returnedAndSkipped.'skipped-sub-skills'[0].skill.id = 'al-style-review' + Assert-CompositionReport $returnedAndSkipped '*SUPER_SKIP_CONFLICT*' + + $allSkippedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $allSkippedComposition.skipped = @($allSkippedComposition.subSkills | ForEach-Object { + @{ id = $_.id; version = $_.version; reason = 'configuration' } + }) + $allSkippedComposition.subSkills = @() + $allSkippedComposition.acceptedResults = @() + Set-Content -LiteralPath $compositionPath -Value ($allSkippedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $allSkippedReport = $noResults | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $allSkippedReport.outcome = 'not-applicable' + $allSkippedReport.PSObject.Properties.Remove('outcome-reason') + $allSkippedReport | Add-Member -NotePropertyName 'skipped-sub-skills' -NotePropertyValue @( + $allSkippedComposition.skipped | ForEach-Object { @{ skill = @{ id = $_.id; version = $_.version }; reason = $_.reason } } + ) + Assert-CompositionReport $allSkippedReport + + $invalidComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $invalidComposition.skipped = @(@{ id = 'al-style-review'; version = 1; reason = 'configuration' }) + Set-Content -LiteralPath $compositionPath -Value ($invalidComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-CompositionReport $validSuperReport '*Invalid expected composition*' + $invalidComposition.skipped = @() + $invalidComposition.subSkills[0].version = '1' + Set-Content -LiteralPath $compositionPath -Value ($invalidComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-CompositionReport $validSuperReport '*Invalid expected composition*' + Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + + $layerFixtureRoot = Join-Path $tmp 'layered-skills' + foreach ($layer in 'microsoft', 'community', 'custom') { + $layerDirectory = Join-Path $layerFixtureRoot $layer + New-Item -ItemType Directory -Path $layerDirectory -Force | Out-Null + $sourceSkills = Join-Path $Root "$layer/skills" + if (Test-Path -LiteralPath $sourceSkills -PathType Container) { + Copy-Item -LiteralPath $sourceSkills -Destination (Join-Path $layerDirectory 'skills') -Recurse + } + } + $customSkillDirectory = Join-Path $layerFixtureRoot 'custom/skills/review' + New-Item -ItemType Directory -Path $customSkillDirectory -Force | Out-Null + $customSkillPath = 'custom/skills/review/company-style-review.md' + $styleSkillText = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-style-review.md') -Raw + Set-Content -LiteralPath (Join-Path $layerFixtureRoot $customSkillPath) ` + -Value ($styleSkillText -replace '(?m)^version: 1\r?$', 'version: 7') -Encoding utf8NoBOM + $fixtureIndexPath = Join-Path $tmp 'layered-skill-index.json' + & (Join-Path $Root 'tools/Build-SkillIndex.ps1') -BCQualityRoot $layerFixtureRoot -IndexPath $fixtureIndexPath | Out-Null + $fixtureIndex = Get-Content -LiteralPath $fixtureIndexPath -Raw | ConvertFrom-Json + foreach ($selection in @( + @{ Disabled = @(); ExpectedLayer = 'custom'; ExpectedVersion = 7 } + @{ Disabled = @($customSkillPath); ExpectedLayer = 'microsoft'; ExpectedVersion = 1 } + @{ Disabled = @($customSkillPath, 'microsoft/skills/review/al-style-review.md'); ExpectedLayer = $null } + )) { + $resolved = & (Join-Path $Root 'tools/Resolve-SkillWorklist.ps1') -BCQualityRoot $layerFixtureRoot ` + -IndexPath $fixtureIndexPath -SuperSkillPath 'microsoft/skills/review/al-code-review.md' ` + -DisabledSkills $selection.Disabled + $styleSlots = @($resolved.subSkills | Where-Object id -CEQ 'al-style-review') + if ($selection.ExpectedLayer) { + Assert-True ($styleSlots.Count -eq 1 -and $styleSlots[0].layer -ceq $selection.ExpectedLayer -and + $styleSlots[0].version -eq $selection.ExpectedVersion) 'resolver-selected override or fallback is authoritative' + } + else { + Assert-True ($styleSlots.Count -eq 0) 'fully disabled slot is not selected' + } + $resolved.skipped = @($resolved.skipped | ForEach-Object { + $declaredPath = $_.declaredPath + $declaredSkill = @($fixtureIndex.skills | Where-Object path -CEQ $declaredPath)[0] + @{ id = $_.id; version = $declaredSkill.version; reason = $_.reason; declaredPath = $declaredPath } + }) + $resolvedReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $resolvedReport.'sub-results' = @($resolved.subSkills | ForEach-Object { + $leafReport = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $leafReport.skill.id = $_.id + $leafReport.skill.version = $_.version + $leafReport + }) + $resolvedReport.summary.coverage.'worklist-size' = $resolved.subSkills.Count + $resolvedReport.summary.coverage.'items-evaluated' = $resolved.subSkills.Count + $resolvedReport | Add-Member -NotePropertyName 'skipped-sub-skills' -NotePropertyValue @( + $resolved.skipped | ForEach-Object { @{ skill = @{ id = $_.id; version = $_.version }; reason = $_.reason } } + ) + $resolved | Add-Member -NotePropertyName 'acceptedResults' -NotePropertyValue @(Save-AcceptedLeafReports $resolvedReport.'sub-results') + Set-Content -LiteralPath $compositionPath -Value ($resolved | ConvertTo-Json -Depth 30) -Encoding utf8NoBOM + Assert-CompositionReport $resolvedReport + } + Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $styleFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json $securityFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json $securityFindingLeaf.skill.id = 'al-security-review' @@ -446,6 +846,10 @@ try { Set-Content -LiteralPath $reportPath -Value ($partialSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM $acceptedPartialSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super Assert-True (-not $acceptedPartialSuper.normalized) 'partial super-skill excludes failed coverage from its rollup' + $partialComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $partialComposition.acceptedResults = @(Save-AcceptedLeafReports @($completedLeaf, $failedLeaf)) + Set-Content -LiteralPath $compositionPath -Value ($partialComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-CompositionReport $partialSuperReport $failedLeafLeakage = $partialSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json $failedLeafLeakage.summary.counts.minor = 1 diff --git a/tools/Validate-FindingsReport.ps1 b/tools/Validate-FindingsReport.ps1 index d3442d3..b3c41b3 100644 --- a/tools/Validate-FindingsReport.ps1 +++ b/tools/Validate-FindingsReport.ps1 @@ -1,3 +1,4 @@ +#Requires -Version 7.5 <# .SYNOPSIS Validates a BCQuality findings-report against its structural and semantic contract. @@ -12,6 +13,7 @@ param( [string[]] $RetrievedArticlePaths = @(), [ValidateSet('leaf', 'super')] [string] $SkillKind = 'leaf', + [string] $ExpectedCompositionPath, [switch] $AllowBoundedNormalization ) @@ -28,12 +30,105 @@ try { if (-not ($raw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) { throw 'Report does not satisfy schemas/findings-report.schema.json.' } - $report = $raw | ConvertFrom-Json -Depth 100 + $report = $raw | ConvertFrom-Json -Depth 100 -DateKind String } catch { throw "Invalid findings-report JSON or schema: $($_.Exception.Message)" } +$expectedComposition = $null +$expectedLeaves = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +$expectedSkips = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +$acceptedLeaves = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +if ($ExpectedCompositionPath) { + if ($SkillKind -cne 'super') { + throw 'Expected composition is supported only for super-skill reports.' + } + $contractSchema = Get-Content -LiteralPath $schemaPath -Raw | ConvertFrom-Json -AsHashtable + $identitySchema = @{ + type = 'object' + required = @('id', 'version') + properties = $contractSchema.definitions.skillReference.properties + } + $skipProperties = @{ + id = $identitySchema.properties.id + version = $identitySchema.properties.version + reason = @{ enum = @('configuration', 'not-applicable') } + } + $compositionSchema = @{ + type = 'object' + required = @('superSkill', 'subSkills', 'skipped', 'acceptedResults') + properties = @{ + superSkill = $identitySchema + subSkills = @{ type = 'array'; items = $identitySchema } + acceptedResults = @{ + type = 'array' + items = @{ + type = 'object' + required = @('id', 'version', 'reportPath') + properties = @{ + id = $identitySchema.properties.id + version = $identitySchema.properties.version + reportPath = @{ type = 'string'; minLength = 1 } + } + } + } + skipped = @{ + type = 'array' + items = @{ type = 'object'; required = @('id', 'version', 'reason'); properties = $skipProperties } + } + } + } | ConvertTo-Json -Depth 20 + try { + $compositionRaw = Get-Content -LiteralPath $ExpectedCompositionPath -Raw + if (-not ($compositionRaw | Test-Json -Schema $compositionSchema -ErrorAction Stop)) { + throw 'Expected composition does not satisfy its input contract.' + } + $expectedComposition = $compositionRaw | ConvertFrom-Json -Depth 100 -DateKind String + $expectedIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($leaf in @($expectedComposition.subSkills)) { + if (-not $expectedIds.Add([string]$leaf.id)) { + throw "Duplicate expected skill id '$($leaf.id)'." + } + $expectedLeaves.Add([string]$leaf.id, $leaf) + } + foreach ($skip in @($expectedComposition.skipped)) { + if (-not $expectedIds.Add([string]$skip.id)) { + throw "Duplicate or selected-and-skipped expected skill id '$($skip.id)'." + } + $expectedSkips.Add([string]$skip.id, $skip) + } + $compositionDirectory = Split-Path -Parent (Resolve-Path -LiteralPath $ExpectedCompositionPath).Path + foreach ($accepted in @($expectedComposition.acceptedResults)) { + if (-not $expectedLeaves.ContainsKey([string]$accepted.id) -or + $accepted.version -ne $expectedLeaves[$accepted.id].version -or + $acceptedLeaves.ContainsKey([string]$accepted.id)) { + throw "Accepted result '$($accepted.id)' must uniquely match a selected leaf and version." + } + $acceptedPath = if ([IO.Path]::IsPathRooted($accepted.reportPath)) { + $accepted.reportPath + } + else { + Join-Path $compositionDirectory $accepted.reportPath + } + $acceptedRaw = Get-Content -LiteralPath $acceptedPath -Raw + if (-not ($acceptedRaw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) { + throw "Accepted result '$($accepted.id)' does not satisfy the report schema." + } + $acceptedReport = $acceptedRaw | ConvertFrom-Json -Depth 100 -DateKind String + if ($acceptedReport.skill.id -cne $accepted.id -or $acceptedReport.skill.version -ne $accepted.version -or + $acceptedReport.PSObject.Properties.Name -ccontains 'sub-results' -or + $acceptedReport.PSObject.Properties.Name -ccontains 'skipped-sub-skills') { + throw "Accepted result '$($accepted.id)' must be a leaf report with the captured identity." + } + $acceptedLeaves.Add([string]$accepted.id, $acceptedReport) + } + } + catch { + throw "Invalid expected composition: $($_.Exception.Message)" + } +} + $retrieved = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) foreach ($path in $RetrievedArticlePaths) { $retrieved.Add($path) | Out-Null @@ -49,6 +144,43 @@ function Test-HasProperty { return $null -ne $Object -and $Object.PSObject.Properties.Name -ccontains $Name } +function Test-JsonContentEqual { + param([object] $First, [object] $Second) + + if ($null -eq $First -or $null -eq $Second) { + return $null -eq $First -and $null -eq $Second + } + if ($First -is [pscustomobject] -or $Second -is [pscustomobject]) { + if ($First -isnot [pscustomobject] -or $Second -isnot [pscustomobject] -or + @($First.PSObject.Properties).Count -ne @($Second.PSObject.Properties).Count) { + return $false + } + foreach ($property in $First.PSObject.Properties) { + if (-not (Test-HasProperty $Second $property.Name) -or + -not (Test-JsonContentEqual $property.Value $Second.PSObject.Properties[$property.Name].Value)) { + return $false + } + } + return $true + } + if ($First -is [array] -or $Second -is [array]) { + if ($First -isnot [array] -or $Second -isnot [array] -or $First.Count -ne $Second.Count) { + return $false + } + for ($index = 0; $index -lt $First.Count; $index++) { + if (-not (Test-JsonContentEqual $First[$index] $Second[$index])) { + return $false + } + } + return $true + } + if ($First -is [string] -or $Second -is [string]) { + return $First -is [string] -and $Second -is [string] -and + [string]::Equals($First, $Second, [StringComparison]::Ordinal) + } + return $First.GetType() -eq $Second.GetType() -and $First -ceq $Second +} + function Get-SourceLineCount { param([string] $Path) @@ -79,8 +211,14 @@ function Get-SemanticErrors { } function Get-DerivedSuperOutcome { - param([object[]] $SubResults) + param([object[]] $SubResults, [int] $MissingResults = 0) + if ($MissingResults -gt 0) { + if (@($SubResults | Where-Object outcome -CNE 'failed').Count) { + return 'partial' + } + return 'failed' + } if (-not $SubResults.Count) { return 'not-applicable' } @@ -138,9 +276,10 @@ function Get-SemanticErrors { $firstHasCode = Test-HasProperty $First 'suggested-code' $secondHasCode = Test-HasProperty $Second 'suggested-code' if ($firstHasCode -or $secondHasCode) { - return $firstHasCode -and $secondHasCode -and $First.'suggested-code' -ceq $Second.'suggested-code' + return $firstHasCode -and $secondHasCode -and + [string]::Equals($First.'suggested-code', $Second.'suggested-code', [StringComparison]::Ordinal) } - return $First.message -ceq $Second.message + return [string]::Equals($First.message, $Second.message, [StringComparison]::Ordinal) } function Test-ReferencesInclude { @@ -181,7 +320,7 @@ function Get-SemanticErrors { $RolledFinding.id -cne $expectedId -or $RolledFinding.severity -cne $LeafFinding.severity -or $RolledFinding.confidence -cne $LeafFinding.confidence -or - $RolledFinding.message -cne $LeafFinding.message -or + -not [string]::Equals($RolledFinding.message, $LeafFinding.message, [StringComparison]::Ordinal) -or $rolledReferences.Count -ne $leafReferences.Count -or -not (Test-ReferencesInclude $rolledReferences $leafReferences)) { return $false @@ -190,7 +329,7 @@ function Get-SemanticErrors { $rolledHasProperty = Test-HasProperty $RolledFinding $name $leafHasProperty = Test-HasProperty $LeafFinding $name if ($rolledHasProperty -ne $leafHasProperty -or - ($rolledHasProperty -and $RolledFinding.$name -cne $LeafFinding.$name)) { + ($rolledHasProperty -and -not [string]::Equals($RolledFinding.$name, $LeafFinding.$name, [StringComparison]::Ordinal))) { return $false } } @@ -206,7 +345,7 @@ function Get-SemanticErrors { $sameCorrection = Test-SameCorrection $RolledFinding $LeafFinding $correctionsConflict = (Test-HasProperty $RolledFinding 'suggested-code') -and (Test-HasProperty $LeafFinding 'suggested-code') -and - $RolledFinding.'suggested-code' -cne $LeafFinding.'suggested-code' + -not [string]::Equals($RolledFinding.'suggested-code', $LeafFinding.'suggested-code', [StringComparison]::Ordinal) $explicitCrossRuleMerge = $leafReferences.Count -and $rolledReferences.Count -gt $leafReferences.Count -and -not $correctionsConflict -and @@ -342,20 +481,105 @@ function Get-SemanticErrors { if ($CurrentSkillKind -ceq 'super' -and $hasSubResults) { $subResults = @($Current.'sub-results') + $producerIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) for ($index = 0; $index -lt $subResults.Count; $index++) { + if (-not $producerIds.Add([string]$subResults[$index].skill.id)) { + Add-Error 'SUPER_DUPLICATE_SUB_RESULT' "$ReportPathPrefix.sub-results[$index].skill.id" ` + 'A leaf may appear only once in sub-results.' + } Test-Report $subResults[$index] "$ReportPathPrefix.sub-results[$index]" 'leaf' } - $expectedOutcome = Get-DerivedSuperOutcome $subResults + $skippedIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $skips = if ($hasSkippedSubSkills) { @($Current.'skipped-sub-skills') } else { @() } + foreach ($skip in $skips) { + if (-not $skippedIds.Add([string]$skip.skill.id) -or $producerIds.Contains([string]$skip.skill.id)) { + Add-Error 'SUPER_SKIP_CONFLICT' "$ReportPathPrefix.skipped-sub-skills" ` + "Skill '$($skip.skill.id)' is duplicated or both returned and skipped." + } + } + + $missingResults = 0 + if ($null -ne $expectedComposition) { + if ($Current.skill.id -cne $expectedComposition.superSkill.id -or + $Current.skill.version -ne $expectedComposition.superSkill.version) { + Add-Error 'SUPER_IDENTITY_MISMATCH' "$ReportPathPrefix.skill" 'Super-skill identity differs from expected composition.' + } + $previousSlot = -1 + $orderedIds = @($expectedComposition.subSkills | ForEach-Object { $_.id }) + for ($index = 0; $index -lt $subResults.Count; $index++) { + $identity = $subResults[$index].skill + if (-not $expectedLeaves.ContainsKey([string]$identity.id)) { + Add-Error 'SUPER_UNEXPECTED_SUB_RESULT' "$ReportPathPrefix.sub-results[$index].skill" ` + "Skill '$($identity.id)' was not selected." + continue + } + if ($identity.version -ne $expectedLeaves[$identity.id].version) { + Add-Error 'SUPER_LEAF_VERSION_MISMATCH' "$ReportPathPrefix.sub-results[$index].skill.version" ` + "Unexpected version for '$($identity.id)'." + } + if (-not $acceptedLeaves.ContainsKey([string]$identity.id)) { + Add-Error 'SUPER_LEAF_NOT_ACCEPTED' "$ReportPathPrefix.sub-results[$index]" ` + "Leaf '$($identity.id)' has no host-captured accepted result." + } + elseif (-not (Test-JsonContentEqual $subResults[$index] $acceptedLeaves[$identity.id])) { + Add-Error 'SUPER_LEAF_CONTENT_MISMATCH' "$ReportPathPrefix.sub-results[$index]" ` + "Leaf '$($identity.id)' differs from its host-captured accepted result." + } + $slot = [Array]::IndexOf($orderedIds, $identity.id) + if ($slot -le $previousSlot) { + Add-Error 'SUPER_SUB_RESULT_ORDER' "$ReportPathPrefix.sub-results[$index].skill" ` + 'Sub-results must preserve the selected worklist order.' + } + $previousSlot = $slot + } + foreach ($leaf in @($expectedComposition.subSkills)) { + if (-not $producerIds.Contains([string]$leaf.id)) { + $missingResults++ + if ($acceptedLeaves.ContainsKey([string]$leaf.id)) { + Add-Error 'SUPER_ACCEPTED_LEAF_MISSING' "$ReportPathPrefix.sub-results" ` + "Host-captured accepted leaf '$($leaf.id)' must be included." + } + $reason = if (Test-HasProperty $Current 'outcome-reason') { $Current.'outcome-reason' } else { '' } + $idPattern = '(? Date: Fri, 2 Oct 2026 11:35:11 +0200 Subject: [PATCH 43/51] knowledge(upgrade): upgrade code must not use ChangeCompany (#210) * knowledge(upgrade): upgrade code must not use ChangeCompany Addresses ADO bug 651092. Upgrade and feature data update code must run in the context of the company being upgraded; ChangeCompany leaves triggers, events and upgrade tags in the calling company and races the target company's own upgrade. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address review: self-contained samples, fixture registration, scoped feature routing - Declare the sample table in both companions so each compiles on its own. - Register no-changecompany-in-upgrade and changecompany-runs-triggers-in-the-calling-company in review-fixtures.json. - Limit the Feature Data Update rule to UpdateData/AfterUpdate and their reachable helpers; read-only IsDataUpdateRequired/ReviewData preflight is permitted and shown as a clean control. - Add feature data update execution to al-upgrade-review applicability and not-applicable scope. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Clarify cross-company upgrade sequencing without race claims Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- evaluation/review-fixtures.json | 8 +- ...ny-runs-triggers-in-the-calling-company.md | 2 +- .../no-changecompany-in-upgrade.bad.al | 41 ++++++++ .../no-changecompany-in-upgrade.good.al | 96 +++++++++++++++++++ .../upgrade/no-changecompany-in-upgrade.md | 36 +++++++ microsoft/skills/review/al-upgrade-review.md | 11 ++- 6 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 microsoft/knowledge/upgrade/no-changecompany-in-upgrade.bad.al create mode 100644 microsoft/knowledge/upgrade/no-changecompany-in-upgrade.good.al create mode 100644 microsoft/knowledge/upgrade/no-changecompany-in-upgrade.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 974175f..307613f 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -37,7 +37,10 @@ ] }, "events": { - "article": "reset-ishandled-only-when-the-value-can-carry-over" + "articles": [ + "reset-ishandled-only-when-the-value-can-carry-over", + "changecompany-runs-triggers-in-the-calling-company" + ] }, "finance": { "articles": [ @@ -167,7 +170,8 @@ "upgrade": { "articles": [ "initvalue-does-not-update-existing-rows", - "upgrade-tag-logic-must-not-nest-deeply" + "upgrade-tag-logic-must-not-nest-deeply", + "no-changecompany-in-upgrade" ], "context": "The extended table existed in the previous app version and already contains rows." }, diff --git a/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md index 4a524f4..783fe81 100644 --- a/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md +++ b/microsoft/knowledge/events/changecompany-runs-triggers-in-the-calling-company.md @@ -13,7 +13,7 @@ application-area: [all] ## Description -`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`. +`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`. Upgrade code must not use `ChangeCompany` at all, see `no-changecompany-in-upgrade`. ## Best Practice diff --git a/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.bad.al b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.bad.al new file mode 100644 index 0000000..44781a7 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.bad.al @@ -0,0 +1,41 @@ +table 50263 "Sales Order Ext" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + field(2; "Shipping Agent Code"; Code[10]) { TableRelation = "Shipping Agent"; } + field(3; "Legacy Carrier Code"; Code[10]) { } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +codeunit 50261 "Upgrade All Companies" +{ + Subtype = Upgrade; + + trigger OnUpgradePerDatabase() + var + Company: Record Company; + SalesOrderExt: Record "Sales Order Ext"; + begin + // Reaches into every company from one session. Each company also has its own + // upgrade session, triggers and subscribers run in the calling context, and a + // data error in any company aborts the whole upgrade. + if Company.FindSet() then + repeat + SalesOrderExt.ChangeCompany(Company.Name); + SalesOrderExt.SetRange("Shipping Agent Code", ''); + if SalesOrderExt.FindSet(true) then + repeat + SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code"); + SalesOrderExt.Modify(true); + until SalesOrderExt.Next() = 0; + until Company.Next() = 0; + end; +} diff --git a/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.good.al b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.good.al new file mode 100644 index 0000000..c6374fc --- /dev/null +++ b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.good.al @@ -0,0 +1,96 @@ +table 50262 "Sales Order Ext" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { } + field(2; "Shipping Agent Code"; Code[10]) { TableRelation = "Shipping Agent"; } + field(3; "Legacy Carrier Code"; Code[10]) { } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +codeunit 50260 "Upgrade Current Company" +{ + Subtype = Upgrade; + + // The platform runs this trigger once per company, in that company's own session. + trigger OnUpgradePerCompany() + begin + UpgradeShippingAgentCodes(); + end; + + local procedure UpgradeShippingAgentCodes() + var + SalesOrderExt: Record "Sales Order Ext"; + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(ShippingAgentUpgradeTag()) then + exit; + + // Only the current company's rows; triggers, events, and the tag all apply here. + SalesOrderExt.SetRange("Shipping Agent Code", ''); + if SalesOrderExt.FindSet(true) then + repeat + SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code"); + SalesOrderExt.Modify(true); + until SalesOrderExt.Next() = 0; + + UpgradeTag.SetUpgradeTag(ShippingAgentUpgradeTag()); + end; + + local procedure ShippingAgentUpgradeTag(): Code[250] + begin + exit('CONTOSO-1001-ShippingAgentCode-20260101'); + end; +} + +codeunit 50264 "Shipping Agent Feat. Data Upd." implements "Feature Data Update" +{ + // Read-only preflight: counting rows across companies to report scope is allowed. + procedure IsDataUpdateRequired(): Boolean + var + Company: Record Company; + SalesOrderExt: Record "Sales Order Ext"; + begin + if Company.FindSet() then + repeat + SalesOrderExt.ChangeCompany(Company.Name); + SalesOrderExt.SetRange("Shipping Agent Code", ''); + if not SalesOrderExt.IsEmpty() then + exit(true); + until Company.Next() = 0; + exit(false); + end; + + procedure ReviewData() + begin + end; + + // Feature Management runs this once per company, in that company. + procedure UpdateData(FeatureDataUpdateStatus: Record "Feature Data Update Status") + var + SalesOrderExt: Record "Sales Order Ext"; + begin + SalesOrderExt.SetRange("Shipping Agent Code", ''); + if SalesOrderExt.FindSet(true) then + repeat + SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code"); + SalesOrderExt.Modify(true); + until SalesOrderExt.Next() = 0; + end; + + procedure AfterUpdate(FeatureDataUpdateStatus: Record "Feature Data Update Status") + begin + end; + + procedure GetTaskDescription(): Text + begin + exit('Copies legacy carrier codes to the shipping agent code.'); + end; +} diff --git a/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.md b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.md new file mode 100644 index 0000000..3295bba --- /dev/null +++ b/microsoft/knowledge/upgrade/no-changecompany-in-upgrade.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [changecompany, cross-company, onupgradepercompany, onupgradeperdatabase, feature-data-update, taskscheduler, multi-company, company-context] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Upgrade code must not use ChangeCompany + +## Description + +The platform upgrades each company in its own context: `OnUpgradePerCompany` (like the other `PerCompany` upgrade and install triggers) runs once per company, in a separate system session opened for that company, while `PerDatabase` triggers run in a session that opens no company. Calling `ChangeCompany()` in upgrade code reaches from the company being upgraded into another company. Cross-company work bypasses the platform's per-company execution boundary, making execution order and migration ownership difficult to reason about and potentially repeating work. A cross-company read cannot assume that the other company's migration has already run, so it can observe pre-upgrade or post-upgrade data. Per-company upgrade tags are set for the current company only, so a tag set after writing to company B is recorded for company A, which can cause B's own session to repeat the migration. Execution context does not move with `ChangeCompany`: table triggers and trigger-event subscribers still run in the calling company (see `changecompany-runs-triggers-in-the-calling-company`). Data and side effects then land in different companies, and an error in another company's data aborts the current company's upgrade. The same applies to the migration path of a feature-switch data update: the `UpdateData` and `AfterUpdate` methods of a `Feature Data Update` implementation. Feature Management runs them for one company's status row, either as a task scheduled in that company or in the current session. The interface's preflight methods, `IsDataUpdateRequired` and `ReviewData`, are a separate phase that reports scope before any update is scheduled. + +## Best Practice + +Upgrade code operates only on the company it is running in. Put per-company data migration in `OnUpgradePerCompany` (or a helper reachable only from it), guard it with a per-company upgrade tag, and let the platform invoke it for every company. Reserve `OnUpgradePerDatabase` for tables with `DataPerCompany = false` and other database-wide state; it needs no `ChangeCompany`. A feature data update implements `UpdateData` and `AfterUpdate` against the current company only. Its read-only preflight may use `ChangeCompany` to count or inspect rows in other companies, because it migrates nothing. When code outside the upgrade pipeline must start work in other companies, schedule it in each company with `TaskScheduler.CreateTask` and the company name, as Feature Management does, rather than writing there through `ChangeCompany`. The scheduled task runs in the target company, so triggers, events, permissions, and upgrade tags all use that company. + +See sample: [`no-changecompany-in-upgrade.good.al`](no-changecompany-in-upgrade.good.al). + +## Anti Pattern + +A loop over the `Company` table in `OnUpgradePerDatabase` or `OnUpgradePerCompany` that calls `ChangeCompany(Company.Name)` on a record or `RecordRef` and then reads, inserts, modifies, or deletes data. Another form is a `Feature Data Update` implementation whose `UpdateData` or `AfterUpdate` does the same to update every company from one task. On these paths reads are not exempt: they observe another company whose upgrade state is unknown. + +Detection signal: `Record.ChangeCompany()` or `RecordRef.ChangeCompany()` with a company-name argument in a codeunit with `Subtype = Upgrade` or in any procedure transitively reachable from its upgrade triggers, or in a `Feature Data Update` implementation's `UpdateData` or `AfterUpdate` or any procedure transitively reachable from them. Do not flag `ChangeCompany` in `IsDataUpdateRequired`, `ReviewData`, or helpers reachable only from them; a helper shared with `UpdateData` or `AfterUpdate` is in scope. Do not flag the parameterless `ChangeCompany()`, which only points the variable back at the current company, or `ChangeCompany` in ordinary runtime code; `changecompany-runs-triggers-in-the-calling-company` governs that. + +See sample: [`no-changecompany-in-upgrade.bad.al`](no-changecompany-in-upgrade.bad.al). + +## References + +- Upgrading extensions, Upgrade triggers: `PerCompany` triggers run once per company, each in its own system session for that company — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-upgrading-extensions +- Record.ChangeCompany method, Remarks: triggers still run in the current company — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-changecompany-method +- Interface "Feature Data Update" — https://learn.microsoft.com/en-us/dynamics365/business-central/application/system-application/interface/system.environment.configuration.feature-data-update +- `FeatureManagementImpl.UpdateData` calls the interface's `UpdateData` then `AfterUpdate`; `ReviewData` calls `IsDataUpdateRequired` and `ReviewData` — https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Feature%20Key/src/FeatureManagementImpl.Codeunit.al +- `FeatureManagementImpl.CreateTask` schedules `Update Feature Data` with `TaskScheduler.CreateTask` for the status row's company — https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Feature%20Key/src/FeatureManagementImpl.Codeunit.al diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md index dce1053..f2c8b1d 100644 --- a/microsoft/skills/review/al-upgrade-review.md +++ b/microsoft/skills/review/al-upgrade-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `upgrade` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Upgrade findings are narrow by design — they apply when the review scope contains upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Upgrade findings are narrow by design — they apply when the review scope contains upgrade codeunits, install codeunits, `Feature Data Update` implementations, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply. ## Source @@ -37,13 +37,14 @@ Discard files that are not applicable. Retain conditionally applicable files (an Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: -- The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces. -- The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, and helper procedures transitively reachable from those entry points. -- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `ModuleInfo`, `AppVersion`, `DataVersion`, `NavApp.GetCurrentModuleInfo`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`). +- The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, codeunits implementing `Feature Data Update`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces. +- The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, the `UpdateData`/`AfterUpdate` methods of `Feature Data Update` implementations, and helper procedures transitively reachable from those entry points. +- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `ModuleInfo`, `AppVersion`, `DataVersion`, `NavApp.GetCurrentModuleInfo`, `ExecutionContext`, `ChangeCompany`, `Feature Data Update`, `UpdateData`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`). - For each `OnCheckPreconditions...` and `OnValidateUpgrade...` trigger, build the best available call graph from surrounding unchanged source as well as changed hunks, tracing resolved calls through reachable local or internal helpers. Worklist the check-only rule when a database write occurs either directly in the trigger or in any helper procedure reachable from it. Writes include `Insert`, `Modify`, `ModifyAll`, `Delete`, `DeleteAll`, `Rename`, and `DataTransfer`. Also perform the reverse check when a PR changes a writing helper body: worklist the rule when that helper is invoked directly or transitively by an unchanged check or validation trigger. - Treat a direct write or a fully resolved call chain as high-confidence evidence. When cross-object dispatch, unavailable declarations, or an incomplete call graph prevents proving the complete chain, cap confidence at `medium`, name the unresolved edge in the finding, and do not claim a violation without a resolved path from a check or validation trigger to a write. - 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 `no-changecompany-in-upgrade.md` when `ChangeCompany` with a company-name argument appears in an upgrade codeunit, in a helper reachable from its upgrade triggers, or in the `UpdateData` or `AfterUpdate` method of a `Feature Data Update` implementation or a helper reachable from them. Trace that reachability with the same call-graph and confidence rules as the check-only rule. Do not worklist it for `ChangeCompany` reachable only from `IsDataUpdateRequired` or `ReviewData`; that read-only preflight is permitted. - 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's existence check (`HasUpgradeTag`) is nested inside another tag's guarded body, or one tagged procedure performs two or more functionally unrelated migrations (different tables, fields, or concerns) under a single tag, or one procedure mixes the gated logic for more than one distinct upgrade tag — `upgrade-tag-logic-must-not-nest-deeply.md`. Do not flag record loops or business-data safety guards (corruption checks, redundant-write checks, or other conditions) that serve the single migration the tag represents, however many `if` levels they take — that is the compliant shape the article explicitly permits. @@ -77,7 +78,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable upgrade knowledge survived filtering. -- `not-applicable` — the diff touches no upgrade, install, schema, or enum surface. +- `not-applicable` — the diff touches no upgrade, install, feature data update, schema, or enum surface. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. From ac249ba4c95eaef14edbe9818fcdd65561bf6a2f Mon Sep 17 00:00:00 2001 From: Stefano Demiliani <33155438+demiliani@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:07:19 +0200 Subject: [PATCH 44/51] Preserve business filters when recommending Record.Get (#205) --- evaluation/review-fixtures.json | 1 + ...ad-of-findfirst-on-full-primary-key.bad.al | 9 ++++++++ ...d-of-findfirst-on-full-primary-key.good.al | 21 +++++++++++++++++++ ...nstead-of-findfirst-on-full-primary-key.md | 18 +++++++++++----- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 307613f..da172ff 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -62,6 +62,7 @@ "performance": { "articles": [ "use-isempty-for-existence-check", + "use-get-instead-of-findfirst-on-full-primary-key", "job-queue-category-code-serializes-conflicting-jobs", "job-queue-external-effects-must-be-idempotent", "job-queue-handlers-must-not-require-ui", diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al index 34f4ec3..4b48ef9 100644 --- a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al @@ -8,4 +8,13 @@ codeunit 50211 "Perf Sample GetByPK Bad" if Customer.FindFirst() then Message(Customer.Name); end; + + procedure ShowUnblockedName(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + Customer.SetRange(Blocked, Customer.Blocked::" "); + if Customer.Get(CustomerNo) then + Message(Customer.Name); + end; } diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al index d625150..3cdf03f 100644 --- a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al @@ -7,4 +7,25 @@ codeunit 50210 "Perf Sample GetByPK Good" if Customer.Get(CustomerNo) then Message(Customer.Name); end; + + procedure ShowUnblockedName(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + Customer.SetRange("No.", CustomerNo); + Customer.SetRange(Blocked, Customer.Blocked::" "); + if Customer.FindFirst() then + Message(Customer.Name); + end; + + procedure ShowUnblockedNameByKey(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then + exit; + if Customer.Blocked <> Customer.Blocked::" " then + exit; + Message(Customer.Name); + end; } diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md index e74614b..154e01d 100644 --- a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md @@ -1,26 +1,34 @@ --- bc-version: [all] domain: performance -keywords: [get, findfirst, primary-key, setrange, lookup] +keywords: [get, findfirst, primary-key, setrange, lookup, filters, blocked] technologies: [al] countries: [w1] application-area: [all] --- -# Use Get when the full primary key is known; FindFirst is the wrong tool +# Use Get for primary-key lookups without losing filter conditions ## Description -`Get(...)` is the direct primary-key lookup. `FindFirst()` walks an index — even when narrowed by `SetRange` on every primary-key field. The upstream review guidance treats `Customer.SetRange("No.", CustomerNo); if Customer.FindFirst() then ...` as a bad pattern and `if Customer.Get(CustomerNo) then ...` as the correction. The two reach the same record; only `Get` expresses the lookup as a primary-key seek. +`Get(...)` retrieves a record by primary key, but ignores normal record filters. Replacing a `FindFirst()` filtered only by the full primary key with `Get` expresses the lookup directly. The same replacement is not equivalent when additional filters enforce business conditions, such as requiring an unblocked customer. Security filters are a separate mechanism: their effect on `Get` depends on Security Filter Mode. ## Best Practice -When all primary-key fields are available at the call site, call `Get` (or `GetBySystemId`) with them. Reserve `FindFirst` for cases where the filter is on something other than the full primary key — a unique secondary field, a partial composite key, a sort that the caller cares about. +When all primary-key fields are available and no additional normal filter constrains the result, call `Get` with them. Reserve `FindFirst` for filtered searches, including partial keys, secondary fields, or additional business conditions. + +Before recommending a replacement, inspect the effective filters at the call site, including filters set by callers or helpers. If additional conditions matter, retain the filtered `FindFirst` or explicitly enforce equivalent conditions after a successful `Get`, before using the record. Do not flag `FindFirst` merely because all primary-key fields are filtered when a non-key filter must also hold. A `SetRange` before `Get` does not enforce that condition. See sample: [`use-get-instead-of-findfirst-on-full-primary-key.good.al`](use-get-instead-of-findfirst-on-full-primary-key.good.al). ## Anti Pattern -Composing `SetRange` calls that exactly cover the primary key and then calling `FindFirst`. The result is correct but the call site reads as "search the table" rather than "look up by key", which obscures both the intent and the access pattern from later reviewers. +Composing `SetRange` calls that cover only the full primary key and then calling `FindFirst` obscures a direct key lookup. Do not extend this finding to a lookup with additional business filters unless the proposed correction preserves them. + +Replacing a filtered lookup with `Get` while assuming a normal filter still excludes records is a correctness defect: a blocked or otherwise ineligible record can pass the lookup. Recommending that replacement without preserving the condition is also an incorrect review finding. See sample: [`use-get-instead-of-findfirst-on-full-primary-key.bad.al`](use-get-instead-of-findfirst-on-full-primary-key.bad.al). + +## References + +- [Record.Get remarks: primary-key lookup and filter semantics](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-get-method). From b503249751e2594b10627db77eba3a3e2c94a176 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:42:22 +0200 Subject: [PATCH 45/51] knowledge(style): a new procedure that changes the page's current record should take it as var Record (#216) Adds mutating-procedure-for-a-page-caller-takes-var-record with good/bad samples, a worklist cue in al-style-review, and registration in the style review-fixtures override. Co-authored-by: Claude Opus 5.5 --- evaluation/review-fixtures.json | 3 +- ...-for-a-page-caller-takes-var-record.bad.al | 68 +++++++++++++++++++ ...for-a-page-caller-takes-var-record.good.al | 64 +++++++++++++++++ ...dure-for-a-page-caller-takes-var-record.md | 57 ++++++++++++++++ microsoft/skills/review/al-style-review.md | 1 + 5 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.bad.al create mode 100644 microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.good.al create mode 100644 microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index da172ff..737868c 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -138,7 +138,8 @@ "label-comment-explains-placeholders", "dateformula-evaluate-needs-language-independent-literals", "al-comments-must-not-restate-what-code-already-shows", - "pages-must-not-contain-business-logic" + "pages-must-not-contain-business-logic", + "mutating-procedure-for-a-page-caller-takes-var-record" ] }, "telemetry": { diff --git a/microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.bad.al b/microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.bad.al new file mode 100644 index 0000000..2eda9f0 --- /dev/null +++ b/microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.bad.al @@ -0,0 +1,68 @@ +table 50140 "Sample Document Header" +{ + fields + { + field(1; "No."; Code[20]) { } + field(2; Status; Option) { OptionMembers = Open,Archived; } + field(3; "Archived By"; Code[50]) { } + } + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +codeunit 50140 "Sample Document Mgt." +{ + // Takes only the key and mutates its own local copy of the record. + procedure Archive(DocumentNo: Code[20]) + var + DocumentHeader: Record "Sample Document Header"; + begin + DocumentHeader.Get(DocumentNo); + DocumentHeader.TestField(Status, DocumentHeader.Status::Open); + DocumentHeader.Status := DocumentHeader.Status::Archived; + DocumentHeader."Archived By" := CopyStr(UserId(), 1, MaxStrLen(DocumentHeader."Archived By")); + DocumentHeader.Modify(true); + end; +} + +page 50140 "Sample Document Card" +{ + PageType = Card; + SourceTable = "Sample Document Header"; + + layout + { + area(Content) + { + group(General) + { + field("No."; Rec."No.") { } + field(Status; Rec.Status) { } + field("Archived By"; Rec."Archived By") { } + } + } + } + + actions + { + area(Processing) + { + action(Archive) + { + trigger OnAction() + var + DocumentMgt: Codeunit "Sample Document Mgt."; + begin + DocumentMgt.Archive(Rec."No."); + // Rec still holds the pre-call values: "Archived By" is read from the stale buffer. + Message(ArchivedMsg, Rec."No.", Rec."Archived By"); + end; + } + } + } + + var + ArchivedMsg: Label 'Document %1 was archived by %2.', Comment = '%1 = document number, %2 = user who archived the document'; +} diff --git a/microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.good.al b/microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.good.al new file mode 100644 index 0000000..817b624 --- /dev/null +++ b/microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.good.al @@ -0,0 +1,64 @@ +table 50140 "Sample Document Header" +{ + fields + { + field(1; "No."; Code[20]) { } + field(2; Status; Option) { OptionMembers = Open,Archived; } + field(3; "Archived By"; Code[50]) { } + } + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +codeunit 50140 "Sample Document Mgt." +{ + // Takes the caller's record by reference, so Modify updates the caller's buffer. + procedure Archive(var DocumentHeader: Record "Sample Document Header") + begin + DocumentHeader.TestField(Status, DocumentHeader.Status::Open); + DocumentHeader.Status := DocumentHeader.Status::Archived; + DocumentHeader."Archived By" := CopyStr(UserId(), 1, MaxStrLen(DocumentHeader."Archived By")); + DocumentHeader.Modify(true); + end; +} + +page 50140 "Sample Document Card" +{ + PageType = Card; + SourceTable = "Sample Document Header"; + + layout + { + area(Content) + { + group(General) + { + field("No."; Rec."No.") { } + field(Status; Rec.Status) { } + field("Archived By"; Rec."Archived By") { } + } + } + } + + actions + { + area(Processing) + { + action(Archive) + { + trigger OnAction() + var + DocumentMgt: Codeunit "Sample Document Mgt."; + begin + DocumentMgt.Archive(Rec); + Message(ArchivedMsg, Rec."No.", Rec."Archived By"); + end; + } + } + } + + var + ArchivedMsg: Label 'Document %1 was archived by %2.', Comment = '%1 = document number, %2 = user who archived the document'; +} diff --git a/microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.md b/microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.md new file mode 100644 index 0000000..29844ca --- /dev/null +++ b/microsoft/knowledge/style/mutating-procedure-for-a-page-caller-takes-var-record.md @@ -0,0 +1,57 @@ +--- +bc-version: [all] +domain: style +keywords: [var-record, by-reference, pass-by-value, record-parameter, page-action, re-fetch, stale-buffer, codeunit, modify] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A new procedure that changes the page's current record should take it as `var Record` + +## Description + +AL passes parameters by value unless they are declared `var`, and a by-value `Record` parameter is a copy: changes the procedure makes "affect only the copy, not the variable itself" ([Working with AL methods](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-al-methods#parameters)). Suppose a page/pageextension trigger hands a codeunit only a key (`Rec."No."`) or a by-value `Rec`, and the procedure `Get`s or modifies its own variable. The trigger's `Rec` then keeps the field values it had before the call. Any code later in the same trigger that reads `Rec` or writes from it works on stale data. Examples are a message, a `TestField`, or a follow-up assignment and `Modify`. + +The page display is not the problem. After an action runs, the page refreshes its current record by itself, because `OnAfterGetRecord` runs (or `OnFindRecord` when the record left the filter) ([Actions at runtime](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-actions-overview#actions-at-runtime)). The harm is confined to code that uses `Rec` after the call in the same trigger, or to a non-page caller that keeps holding the record. + +## Best Practice + +When you design a new procedure whose job is to change the record a page/pageextension trigger already holds, declare that record as `var Record` and mutate it in place. The procedure's `Modify` then updates the trigger's `Rec`. The Learn page above shows the same shape: `CurrPage.SetSelectionFilter(Rec); codeunit.Run(50000, Rec);`, through `Codeunit.Run(Number, var Record)`. Base Application's Sales Order Reopen action calls `ReleaseSalesDoc.PerformManualReopen(Rec)`, and `Reopen(var SalesHeader)` sets `Status` and calls `Modify(true)` on that parameter. A procedure that needs a locked read can call `LockTable`/`ReadIsolation` and `Find` on the `var` parameter. That refreshes the caller's buffer too. + +A `var Record` parameter has a cost: the callee receives the page's live record, including its filters and current key. It must not change filters or keys on the parameter. If it needs its own filtering, it should copy the record or call `SetRecFilter` on a copy first. + +Treat this as design guidance (`minor`). Do not change an already-shipped public procedure from by-value to `var` in place. Adding or removing `var` there is a breaking change (AppSourceCop [AS0078](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/appsourcecop-as0078); see `breaking-changes/do-not-change-published-procedure-signatures`). Add a new procedure instead. A `var Record` overload can sit beside a key-typed one, but an overload that differs only by `var` is rejected with [AL0440](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al440) (verified with AL compiler 30.0). + +See sample: [`mutating-procedure-for-a-page-caller-takes-var-record.good.al`](mutating-procedure-for-a-page-caller-takes-var-record.good.al). + +## Anti Pattern + +All four of these must hold: +- A new codeunit procedure takes a key (`Code`, `Integer`, `Guid`) or a non-`var` `Record`. +- It `Get`s or uses its own copy and calls `Modify` on that same table's record. +- A page/pageextension trigger calls it with `Rec` or a key field of `Rec`. +- Later in the same trigger, code reads `Rec` fields the procedure changed, or writes from `Rec`. + +A `Rec.Get` right after the call is context only, not evidence. Re-reading is a common, benign Base Application idiom, used even after `var` calls. + +Do not flag these legitimate key-based or by-value shapes: +- Background and scheduled entry points. Job queue codeunits resolve `"Record ID to Process"`; `TaskScheduler.CreateTask` takes a `RecordId`; page background tasks pass text parameters. +- API page actions over a buffer or entity table that locate the real record by `SystemId`. +- Generic, table-agnostic APIs that take `RecordId`, `RecordRef`, or `Variant`, such as `Approvals Mgmt.ApproveRecordApprovalRequest(RecordId)`. +- Key-based procedures whose change happens inside a platform or System API. +- Procedures that change a different table or only read, and temporary records. +- A by-value record modified inside a `FindSet` loop, which `performance/avoid-cloning-records-before-modify-delete-in-loops` covers. +- In-place changes to published procedures, which the breaking-changes article above covers. + +See also `style/pages-must-not-contain-business-logic` for where the mutation itself belongs. + +See sample: [`mutating-procedure-for-a-page-caller-takes-var-record.bad.al`](mutating-procedure-for-a-page-caller-takes-var-record.bad.al). + +## References + +- [Working with AL methods, Parameters](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-al-methods#parameters): by value is the default, and "a *copy of the variable* is passed to the method". +- [Actions overview, Actions at runtime](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-actions-overview#actions-at-runtime): the page refresh after an action, and the `SetSelectionFilter` + `codeunit.Run(50000, Rec)` example. [Codeunit.Run](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/codeunit/codeunit-run-method): `Codeunit.Run(Number: Integer [, var Record: Record])`. +- BCApps at [`837ef80`](https://github.com/microsoft/BCApps/tree/837ef802485ee457e52310d2ecaa08b93d0122fd), Base Application examples: + - `var` idiom: [`SalesOrder.Page.al#L1610`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Sales/Document/SalesOrder.Page.al#L1610) and [`ReleaseSalesDocument.Codeunit.al#L223-L240`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Sales/Document/ReleaseSalesDocument.Codeunit.al#L223-L240). + - Carve-outs: job queue [`SalesPostviaJobQueue.Codeunit.al#L20-L33`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Sales/Posting/SalesPostviaJobQueue.Codeunit.al#L20-L33); `RecordId` API [`ApprovalsMgmt.Codeunit.al#L251`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/OtherCapabilities/Approvals/ApprovalsMgmt.Codeunit.al#L251); `SystemId` API action [`APIV2SalesOrders.Page.al#L797-L801`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Apps/W1/APIV2/app/src/pages/APIV2SalesOrders.Page.al#L797-L801); System API [`SOAReplyRetryMgt.Codeunit.al#L27-L42`](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyRetryMgt.Codeunit.al#L27-L42). diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md index 2aaf902..d569bcf 100644 --- a/microsoft/skills/review/al-style-review.md +++ b/microsoft/skills/review/al-style-review.md @@ -61,6 +61,7 @@ Apply these high-signal mappings before fuzzy topic ranking: - A field or variable is typed `Integer` with the meaning of each value tracked only in a comment, or an `Enum` with more than two members is proposed as `Boolean`-like — `fixed-choice-set-must-use-enum-not-integer.md`. Do not flag a genuinely two-state `Enum`/`Option` for having "too few" members; that overlaps `binary-choice-must-be-boolean.md` instead when the domain is a true/false predicate. - A new `using` directive is added for an existing AL object without the diff also showing that object's own `namespace` declaration or a symbol-package lookup backing the choice — `namespace-must-be-verified-from-source.md`. Require repository/dependency context; a single new `using` line cannot itself prove whether the namespace was verified or guessed. - An intrinsic/built-in AL function call (`MESSAGE`, `ERROR`, `CONFIRM`, `STRSUBSTNO`, etc.) is written in ALL-CAPS or another non-PascalCase form — `intrinsic-al-functions-must-use-modern-casing.md`. +- A new codeunit procedure takes a key (`Code`/`Integer`/`Guid`) or a non-`var` `Record`, then `Get`s or uses its own copy and calls `Modify` on that same table's record; a `page`/`pageextension` trigger in the diff calls it with `Rec` or a key field of `Rec`, and later in the same trigger reads the changed `Rec` fields or writes from `Rec` — `mutating-procedure-for-a-page-caller-takes-var-record.md`. Severity at most `minor`. The page refreshes its current record after an action, so stale display alone is not this pattern, and a `Rec.Get` after the call is not evidence by itself. Do not flag job-queue/`TaskScheduler`/page-background-task entry points, API page actions resolving the real record by `SystemId`, generic `RecordId`/`RecordRef`/`Variant` APIs, key-based procedures whose change happens inside a platform/System API, procedures that change a different table or only read, temporary records, by-value records modified inside a `FindSet` loop (owned by `performance/avoid-cloning-records-before-modify-delete-in-loops`), or an in-place signature change to an already-published procedure (owned by `breaking-changes/do-not-change-published-procedure-signatures`). - A procedure call passes a literal or a computed expression (not a caller-scope variable) to a parameter position the callee declares `var` — `var-parameters-require-an-addressable-variable.md`. This is a compile-time-guaranteed shape; flag it only when the callee's declared signature is visible in the diff or resolvable from context. Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions. From 462765e414986b0bd83996d8b8d788495944230c Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:46:27 +0200 Subject: [PATCH 46/51] 3 AL/BC patterns: Insert/Delete trigger defaults on master data and declined Confirm in OnValidate (#209) * Add trigger-default and declined-Confirm knowledge with review cues Three Microsoft-layer articles with good/bad samples: - error-handling/declined-confirm-must-abort-not-partially-apply - data-modeling/delete-master-data-with-trigger - data-modeling/master-data-must-be-inserted-with-trigger Wire targeted worklist cues into al-error-handling-review and al-data-modeling-review and register the samples in review-fixtures.json. Co-Authored-By: Claude Opus 5.5 * Tighten declined-confirm, delete and insert trigger articles after review Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- evaluation/review-fixtures.json | 3 ++ .../delete-master-data-with-trigger.bad.al | 12 +++++ .../delete-master-data-with-trigger.good.al | 12 +++++ .../delete-master-data-with-trigger.md | 39 ++++++++++++++ ...-data-must-be-inserted-with-trigger.bad.al | 15 ++++++ ...data-must-be-inserted-with-trigger.good.al | 16 ++++++ ...ster-data-must-be-inserted-with-trigger.md | 39 ++++++++++++++ ...firm-must-abort-not-partially-apply.bad.al | 51 +++++++++++++++++++ ...irm-must-abort-not-partially-apply.good.al | 51 +++++++++++++++++++ ...-confirm-must-abort-not-partially-apply.md | 44 ++++++++++++++++ .../skills/review/al-data-modeling-review.md | 8 +-- .../skills/review/al-error-handling-review.md | 3 +- 12 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 microsoft/knowledge/data-modeling/delete-master-data-with-trigger.bad.al create mode 100644 microsoft/knowledge/data-modeling/delete-master-data-with-trigger.good.al create mode 100644 microsoft/knowledge/data-modeling/delete-master-data-with-trigger.md create mode 100644 microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.bad.al create mode 100644 microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.good.al create mode 100644 microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.md create mode 100644 microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.bad.al create mode 100644 microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.good.al create mode 100644 microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 737868c..14f7153 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -17,11 +17,13 @@ "check-blocked-in-referencing-code-not-in-master", "code-must-not-change-workdate", "custom-document-dispatch-must-not-bypass-report-selections", + "delete-master-data-with-trigger", "document-line-prices-follow-prices-including-vat", "document-print-and-email-actions-call-report-selections-directly", "extend-find-entries-navigate-for-new-document-types", "extend-price-source-type-must-sync-document-subset-enum", "extend-report-selection-usage-for-new-document-types", + "master-data-must-be-inserted-with-trigger", "new-price-source-must-add-candidate-and-trigger-recalculation", "pictures-must-use-media-not-blob", "report-barcodes-must-use-barcode-module-and-production-font-name", @@ -32,6 +34,7 @@ "error-handling": { "articles": [ "collect-validation-errors-with-errorbehavior", + "declined-confirm-must-abort-not-partially-apply", "defensive-vs-offensive-code-must-match-blast-radius", "log-writes-must-survive-rollback" ] diff --git a/microsoft/knowledge/data-modeling/delete-master-data-with-trigger.bad.al b/microsoft/knowledge/data-modeling/delete-master-data-with-trigger.bad.al new file mode 100644 index 0000000..f5dc85e --- /dev/null +++ b/microsoft/knowledge/data-modeling/delete-master-data-with-trigger.bad.al @@ -0,0 +1,12 @@ +codeunit 50100 "Sample Currency Cleanup" +{ + procedure DeleteRetiredCurrencies(CurrencyFilter: Text) + var + Currency: Record Currency; + begin + Currency.SetFilter(Code, CurrencyFilter); + // RunTrigger defaults to false: Currency.OnDelete never runs, so the + // open-entry guard is skipped and exchange rates are left orphaned. + Currency.DeleteAll(); + end; +} diff --git a/microsoft/knowledge/data-modeling/delete-master-data-with-trigger.good.al b/microsoft/knowledge/data-modeling/delete-master-data-with-trigger.good.al new file mode 100644 index 0000000..65423ad --- /dev/null +++ b/microsoft/knowledge/data-modeling/delete-master-data-with-trigger.good.al @@ -0,0 +1,12 @@ +codeunit 50100 "Sample Currency Cleanup" +{ + procedure DeleteRetiredCurrencies(CurrencyFilter: Text) + var + Currency: Record Currency; + begin + Currency.SetFilter(Code, CurrencyFilter); + // DeleteAll(true) runs Currency.OnDelete for each record: it errors while + // open ledger entries use the code and removes the exchange rates itself. + Currency.DeleteAll(true); + end; +} diff --git a/microsoft/knowledge/data-modeling/delete-master-data-with-trigger.md b/microsoft/knowledge/data-modeling/delete-master-data-with-trigger.md new file mode 100644 index 0000000..6e3467c --- /dev/null +++ b/microsoft/knowledge/data-modeling/delete-master-data-with-trigger.md @@ -0,0 +1,39 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [delete, deleteall, runtrigger, ondelete, master-data, currency, cleanup, data-migration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Delete master and reference data with `Delete(true)` so the owning table's `OnDelete` decides + +## Description + +`Record.Delete()` and `Record.DeleteAll()` do not run `OnDelete` unless `RunTrigger` is `true`; the default is `false`. On a master or reference table, `OnDelete` is where Business Central decides whether the delete is safe and removes what the record owns. `Currency.OnDelete` refuses the delete while any **open** customer, vendor, or employee ledger entry uses the code, then deletes the currency's `Currency Exchange Rate` rows itself. `Customer.OnDelete` refuses when a job bills the customer, calls codeunit 361 `MoveEntries` (which refuses while ledger entries fall in an unclosed fiscal year or are still open, and otherwise detaches the closed history), and removes default dimensions, related data, and the contact link. + +A cleanup, migration, or "remove obsolete codes" routine that calls `Delete()`/`DeleteAll()` without `true` on such a table skips all of it (only `OnBeforeDelete`/`OnAfterDelete` triggers in table extensions still run): it can remove a currency that open entries still use, and leaves exchange rates and other dependents orphaned. That a record *looks* obsolete — a superseded currency nobody posts in any more — is no evidence the guard would pass, and keeping a record that closed history still refers to is often the better choice. Where the master has a `Blocked` field, blocking it is an alternative to deleting it; `Currency` has none. + +## Best Practice + +Outside the owning table's own triggers, delete master and reference records with `Delete(true)`/`DeleteAll(true)` and let `OnDelete` raise its error, as BCApps does when it removes items (`CatalogItemManagement`, `NewItem.Delete(true)`). This is the "trigger does work the caller depends on" case of [`pass-false-to-insert-when-trigger-not-needed`](../performance/pass-false-to-insert-when-trigger-not-needed.md); a hand-written reference check is no substitute for the guard. + +Legitimate `false` deletes, not in scope: the owning table's own `OnDelete` cascade removing its dependents (`Currency.OnDelete` itself calls `CurrExchRate.DeleteAll()`; see [`owning-table-must-delete-dependents-in-ondelete`](owning-table-must-delete-dependents-in-ondelete.md)); temporary records and buffers; deleting and immediately re-inserting the same primary key to restore or recreate a record, where dependents stay valid (`JobArchiveManagement` restoring a project from its archive; codeunit 1812 recreating each `"Customer Posting Group"` with the same `Code`); and a data-migration or setup reset in a company with no posted entries that removes master rows and their setup references as one rebuild, such as codeunit 1812 `"Data Migration Del G/L Account"`, whose `DeleteAll()` bypasses `"G/L Account".OnDelete`'s `MoveGLEntries` guard — acceptable only because no postings exist yet. Posted ledger entries are not master data; see [`do-not-modify-or-delete-posted-ledger-entries`](../finance/do-not-modify-or-delete-posted-ledger-entries.md). + +See sample: [`delete-master-data-with-trigger.good.al`](delete-master-data-with-trigger.good.al). + +## Anti Pattern + +Code outside the owning table's `OnDelete` calls `Delete()`/`DeleteAll()` (or `false`) on a non-temporary master or reference table — `Currency`, `Customer`, `Vendor`, `Item`, `G/L Account`, or a custom equivalent with an `OnDelete` guard or cascade — typically from a filter on codes judged obsolete, outside a same-key delete-and-reinsert or a no-postings migration/setup rebuild. + +See sample: [`delete-master-data-with-trigger.bad.al`](delete-master-data-with-trigger.bad.al). + +## References + +- [Record.Delete method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-delete-method) and [Record.DeleteAll method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-deleteall-method): `RunTrigger` "The default value is false."; the DeleteAll note adds that setting it to false "only affects the OnDelete trigger" — table-extension `OnBeforeDelete`/`OnAfterDelete` still run. +- BCApps `src/Layers/W1/BaseApp/Finance/Currency/Currency.Table.al`, `OnDelete`, lines 797-820. +- BCApps `src/Layers/W1/BaseApp/Sales/Customer/Customer.Table.al`, `OnDelete`, lines 2401-2430; `src/Layers/W1/BaseApp/Utilities/MoveEntries.Codeunit.al`, `MoveCustEntries`, lines 126-167. +- BCApps `src/Layers/W1/BaseApp/Inventory/Item/Catalog/CatalogItemManagement.Codeunit.al`, line 419. +- BCApps `src/Layers/W1/BaseApp/System/DataMigration/DataMigrationDelGLAccount.Codeunit.al`, `OnRun` lines 18-32 and `DeleteGLAccounts` lines 34-42 (rebuild); lines 53-56 (same-key delete and re-insert); `src/Layers/W1/BaseApp/Finance/GeneralLedger/Account/GLAccount.Table.al`, `OnDelete`, line 1144 (`MoveGLEntries`). +- BCApps `src/Layers/W1/BaseApp/Projects/Project/Archive/JobArchiveManagement.Codeunit.al`, lines 212-219 (restore: `Job.Delete()`, then re-insert the same `No.`). diff --git a/microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.bad.al b/microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.bad.al new file mode 100644 index 0000000..cc9d5d4 --- /dev/null +++ b/microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.bad.al @@ -0,0 +1,15 @@ +codeunit 50100 "Sample Customer Import" +{ + procedure CreateCustomer(ExternalName: Text[100]; ExternalCountry: Code[10]): Code[20] + var + Customer: Record Customer; + begin + Customer.Init(); + Customer.Validate(Name, ExternalName); + Customer.Validate("Country/Region Code", ExternalCountry); + // RunTrigger defaults to false: OnInsert never runs, so "No." stays + // blank and no contact, salesperson, or timestamps are set. + Customer.Insert(); + exit(Customer."No."); + end; +} diff --git a/microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.good.al b/microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.good.al new file mode 100644 index 0000000..5917127 --- /dev/null +++ b/microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.good.al @@ -0,0 +1,16 @@ +codeunit 50100 "Sample Customer Import" +{ + procedure CreateCustomer(ExternalName: Text[100]; ExternalCountry: Code[10]): Code[20] + var + Customer: Record Customer; + begin + Customer.Init(); + // Insert(true) runs Customer.OnInsert: "No." from the number series, + // contact and salesperson defaults (when set up), global dimensions, timestamps. + Customer.Insert(true); + Customer.Validate(Name, ExternalName); + Customer.Validate("Country/Region Code", ExternalCountry); + Customer.Modify(true); + exit(Customer."No."); + end; +} diff --git a/microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.md b/microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.md new file mode 100644 index 0000000..2e2c3a1 --- /dev/null +++ b/microsoft/knowledge/data-modeling/master-data-must-be-inserted-with-trigger.md @@ -0,0 +1,39 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [insert, runtrigger, oninsert, master-data, no-series, customer, item, import] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Create master records with `Insert(true)` unless the caller does the trigger's work itself + +## Description + +`Record.Insert()` does not run `OnInsert`: `RunTrigger` defaults to `false`. On a standard master table that trigger initializes the record. Unless an `OnBeforeInsert` subscriber sets `IsHandled`, `Customer.OnInsert` assigns `No.` and `No. Series` from Sales & Receivables Setup when `No.` is blank, then defaults `Invoice Disc. Code`, defaults a blank salesperson from the user's User Setup `"Salespers./Purch. Code"` when one is set, creates the contact when Marketing Setup has a `"Bus. Rel. Code for Customers"` (unless the insert comes from a contact or from a template with a contact), overwrites `Global Dimension 1/2 Code` from the customer's Default Dimension rows and clears them when there are none (`DimMgt.UpdateDefaultDim` creates no default dimensions), calls `UpdateReferencedIds`, and sets the last-modified timestamps. `Item.OnInsert` assigns `No.`, `No. Series`, and `Costing Method` when `No.` is blank and, blank or not, runs the same global-dimension update and `UpdateReferencedIds`. + +A bare `Insert()` produces a row that looks complete but lacks what downstream code assumes: with a blank `No.` the key stays blank; with a supplied `No.` the contact, defaults, and timestamps are silently missing. This is the caller-side counterpart of [`master-table-no-from-number-series-in-oninsert`](master-table-no-from-number-series-in-oninsert.md): that design only works when callers run the trigger. + +## Best Practice + +When code creates a record in `Customer`, `Vendor`, `Item`, `G/L Account`, `Contact`, or a custom master with initializing `OnInsert` logic, call `Insert(true)`, then validate fields and `Modify(true)`. Importing from an external source is not an exception: BCApps' data-migration facades (`CustomerDataMigrationFacade`, `ItemDataMigrationFacade`, `GLAccDataMigrationFacade`) use `Insert(true)`. This is the "trigger does work the caller depends on" case of [`pass-false-to-insert-when-trigger-not-needed`](../performance/pass-false-to-insert-when-trigger-not-needed.md); the decision stays per call. + +Legitimate `Insert()` calls, not in scope: temporary records and buffer or staging tables; a caller that visibly assigns what the trigger would and then applies a template (`CatalogItemManagement.CreateNewItem` sets `No.` and `Costing Method` before `Item.Insert()`) or copies from a source record (`CopyItem` transfers the source item's fields and assigns the target `No.` before `TargetItem.Insert()`); and an XMLport that round-trips complete rows exported from Business Central (`ExportItemData`). `Modify()` without the trigger is routine on masters for technical fields and is not covered. Upgrade code that bypasses triggers is covered by [`datatransfer-skips-triggers-and-subscribers`](../upgrade/datatransfer-skips-triggers-and-subscribers.md). + +See sample: [`master-data-must-be-inserted-with-trigger.good.al`](master-data-must-be-inserted-with-trigger.good.al). + +## Anti Pattern + +Code creates a non-temporary master record with `Init`, field assignments or `Validate` calls, and `Insert()`/`Insert(false)`, without itself assigning the number and the other fields `OnInsert` would set. + +See sample: [`master-data-must-be-inserted-with-trigger.bad.al`](master-data-must-be-inserted-with-trigger.bad.al). + +## References + +- [Record.Insert(Boolean) method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-insert-boolean-method): "If this parameter is false, the code in the OnInsert trigger is not executed. The default value is false." +- BCApps `src/Layers/W1/BaseApp/Sales/Customer/Customer.Table.al`, `OnInsert`, lines 2432-2472; `src/Layers/W1/BaseApp/Inventory/Item/Item.Table.al`, `OnInsert`, from line 2587. +- BCApps `src/Layers/W1/BaseApp/Sales/Customer/Customer.Table.al`, `SetDefaultSalesperson`, lines 3848-3863; `src/Layers/W1/BaseApp/CRM/BusinessRelation/CustContUpdate.Codeunit.al`, `OnInsert`, lines 26-40. +- BCApps `src/Layers/W1/BaseApp/Finance/Dimension/DimensionManagement.Codeunit.al`, `UpdateDefaultDim`, lines 894-913. +- BCApps `src/Layers/W1/BaseApp/Inventory/Item/Catalog/CatalogItemManagement.Codeunit.al`, `CreateNewItem`, lines 545-565; `src/Layers/W1/BaseApp/Inventory/Item/CopyItem.Codeunit.al`, `InitTargetItem` and `CopyItem`, lines 107-133; `src/Layers/W1/BaseApp/Inventory/Item/ExportItemData.XmlPort.al`, line 405. +- BCApps `src/Layers/W1/BaseApp/System/DataMigration/`: `CustomerDataMigrationFacade.Codeunit.al` line 67, `ItemDataMigrationFacade.Codeunit.al` line 76, `GLAccDataMigrationFacade.Codeunit.al` line 77. diff --git a/microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.bad.al b/microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.bad.al new file mode 100644 index 0000000..1b48efc --- /dev/null +++ b/microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.bad.al @@ -0,0 +1,51 @@ +table 50100 "Sample Mailbox Watch" +{ + fields + { + field(1; "Code"; Code[20]) + { + } + field(2; "Watched Email"; Text[250]) + { + trigger OnValidate() + var + StopWatchingQst: Label 'Email %1 is being watched. Stop watching it?', Comment = '%1 = previous email address'; + begin + if "Watched Email" = xRec."Watched Email" then + exit; + if xRec."Watched Email" <> '' then + if Confirm(StopWatchingQst, false, xRec."Watched Email") then begin + Unsubscribe("Subscription ID"); + Clear("Subscription ID"); + end; + // On "no" the old subscription is never removed, yet the new value is + // kept and the only field that tracked it is overwritten: it is orphaned. + if "Watched Email" <> '' then + "Subscription ID" := Subscribe("Watched Email"); + end; + } + field(3; "Subscription ID"; Guid) + { + Editable = false; + } + } + + keys + { + key(PK; "Code") + { + Clustered = true; + } + } + + local procedure Subscribe(EmailAddress: Text[250]): Guid + begin + // Registers EmailAddress with the external watch service and returns its subscription. + exit(CreateGuid()); + end; + + local procedure Unsubscribe(SubscriptionId: Guid) + begin + // Removes the subscription from the external watch service. + end; +} diff --git a/microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.good.al b/microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.good.al new file mode 100644 index 0000000..6d3d3d4 --- /dev/null +++ b/microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.good.al @@ -0,0 +1,51 @@ +table 50100 "Sample Mailbox Watch" +{ + fields + { + field(1; "Code"; Code[20]) + { + } + field(2; "Watched Email"; Text[250]) + { + trigger OnValidate() + var + StopWatchingQst: Label 'Email %1 is being watched. Stop watching it?', Comment = '%1 = previous email address'; + begin + if "Watched Email" = xRec."Watched Email" then + exit; + if xRec."Watched Email" <> '' then begin + // Declining cancels the whole change: the field keeps its old value. + if not Confirm(StopWatchingQst, false, xRec."Watched Email") then + Error(''); + Unsubscribe("Subscription ID"); + Clear("Subscription ID"); + end; + if "Watched Email" <> '' then + "Subscription ID" := Subscribe("Watched Email"); + end; + } + field(3; "Subscription ID"; Guid) + { + Editable = false; + } + } + + keys + { + key(PK; "Code") + { + Clustered = true; + } + } + + local procedure Subscribe(EmailAddress: Text[250]): Guid + begin + // Registers EmailAddress with the external watch service and returns its subscription. + exit(CreateGuid()); + end; + + local procedure Unsubscribe(SubscriptionId: Guid) + begin + // Removes the subscription from the external watch service. + end; +} diff --git a/microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.md b/microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.md new file mode 100644 index 0000000..54efeb2 --- /dev/null +++ b/microsoft/knowledge/error-handling/declined-confirm-must-abort-not-partially-apply.md @@ -0,0 +1,44 @@ +--- +bc-version: [all] +domain: error-handling +keywords: [confirm, onvalidate, xrec, abort, revert, side-effect, consistency] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A declined `Confirm` in `OnValidate` must abort or revert, not skip a required side effect + +## Description + +By the time a field's `OnValidate` body runs, the field already holds its new value. When the trigger asks the user to confirm a side effect that the new value makes **required for consistency** — releasing or replacing state that is tied to the old value and that nothing will reference any more once the change commits — the shape `if Confirm(...) then ;` with nothing on the `false` branch lets the new value commit while silently skipping that effect. No error is raised, the user sees no indication that anything was declined, and the record is left inconsistent with its own dependents. + +Not every confirmed follow-up is required. When the confirmed effect is a convenience the user may legitimately decline, skipping it is correct: `"Sales Header"`'s `UpdateSalesLinesByFieldNo` asks whether to update the lines after a header field changes and, on "no", simply `exit`s — the header keeps its new value and the lines stay as they were, by design. A `Confirm` at the top of an action procedure, before anything has been written, may also just `exit` on "no" (for example the delete action in `"Test Input Groups"`). Neither shape is this anti-pattern. Nor is a declined update whose old state stays valid: `Opportunity`'s `"Campaign No."` `OnValidate` asks before moving open tasks filtered on `xRec."Campaign No."` to the new campaign and does nothing on "no" — the tasks keep pointing at a campaign that still exists. + +## Best Practice + +Make the declined branch match what "no" means: + +- **Cancel the whole change** — raise an error before the side effect. The usual BCApps form inside `OnValidate` is the silent abort `if not Confirm(...) then Error('');` (for example `"Bank Account"`, field `"Disable Bank Rec. Optimization"`, and `"Interaction Template"`, field `"Language Code (Default)"`, which ends `if Confirm(...) then begin ... end else Error('');`). The field trigger documentation states that in case of an error "the user entry is not written to the database." +- **Keep the old value but let the rest of the edit continue** — assign the field back in code. In the `"To-do"` table, field `"Team Code"`, declining the reassignment runs `"Team Code" := xRec."Team Code"`; on a page, `"Upload And Deploy Extension"` resets its sync-mode value to `Add` when the user declines `Force Sync`. + +Ask before the side effect runs, and before taking locks the prompt would hold open (see [`avoid-user-prompts-inside-transactions`](../performance/avoid-user-prompts-inside-transactions.md)). When the same validation can run without a UI, a required confirmation must not be silently skipped behind `GuiAllowed`; decide the non-interactive outcome explicitly (see [`job-queue-handlers-must-not-require-ui`](../performance/job-queue-handlers-must-not-require-ui.md)). + +See sample: [`declined-confirm-must-abort-not-partially-apply.good.al`](declined-confirm-must-abort-not-partially-apply.good.al). + +## Anti Pattern + +Inside a field `OnValidate` (or a procedure it calls), a `Confirm` gates a side effect that releases, cancels, or replaces state belonging to the old value (`xRec`), the `false` branch neither errors nor restores the field, and code after it proceeds as if the change were accepted — for example overwriting the only field that tracks the old state. Flag it only when, after the change, nothing references the old state any more, so declining leaves it orphaned. Do not flag declined updates whose old state remains valid and referenced, optional follow-ups whose skipping leaves every record consistent, or `exit` on "no" in an action before any write. + +See sample: [`declined-confirm-must-abort-not-partially-apply.bad.al`](declined-confirm-must-abort-not-partially-apply.bad.al). + +## References + +- [OnValidate (Field) trigger](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/field/devenv-onvalidate-field-trigger). +- BCApps `src/Layers/W1/BaseApp/Bank/BankAccount/BankAccount.Table.al`, lines 980-988 (silent abort in `OnValidate`). +- BCApps `src/Layers/W1/BaseApp/CRM/Interaction/InteractionTemplate.Table.al`, lines 157-165 (`else Error('')` in `OnValidate`). +- BCApps `src/Layers/W1/BaseApp/CRM/Task/Todo.Table.al`, lines 80-90 (table-field revert to `xRec`). +- BCApps `src/System Application/App/Extension Management/src/UploadAndDeployExtension.Page.al`, lines 78-83 (explicit revert on a page). +- BCApps `src/Layers/W1/BaseApp/CRM/Opportunity/Opportunity.Table.al`, lines 142-157 (declined update; old campaign still valid). +- BCApps `src/Layers/W1/BaseApp/Sales/Document/SalesHeader.Table.al`, `UpdateSalesLinesByFieldNo`, lines 4997-5014 (optional follow-up; `end else exit`). +- BCApps `src/Tools/Test Framework/Test Runner/src/DataDrivenTest/DataInputs/TestInputGroups.Page.al`, lines 85-86 (`exit` before any write). diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 789db91..22dc8d3 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, document print/email/Post-and-Send actions, `Navigate` page subscribers, Report Selection registration or dispatch, price-calculation/price-source extensibility, code that reads or writes sales/purchase/service line price and amount fields, `TransferFields`-based posting-cascade field mirroring, barcode/report-layout font-provider usage, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, document print/email/Post-and-Send actions, `Navigate` page subscribers, Report Selection registration or dispatch, price-calculation/price-source extensibility, code that reads or writes sales/purchase/service line price and amount fields, `TransferFields`-based posting-cascade field mirroring, barcode/report-layout font-provider usage, dimension wiring, journal-based posting-routine structure, Item Ledger Entry document-number lookups after a combined sales post, or code that inserts or deletes master/reference records. The skill returns `not-applicable` when none of those apply. ## Source @@ -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 `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, document or journal lines that reference a master, document pages/codeunits exposing print/email/Post-and-Send actions, codeunits subscribing to `Navigate`, enumextensions to `"Report Selection Usage"`/`"Price Calculation Handler"`/`"Price Source Type"`, and report objects that render barcodes. - The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, posting validation, and posting-cascade `TransferFields` calls. -- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Prices Including VAT`, `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Prepmt. Line Amount`, `Amount Including VAT`, `CalculateOutstandingAmountExclTax`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`). +- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Prices Including VAT`, `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Prepmt. Line Amount`, `Amount Including VAT`, `CalculateOutstandingAmountExclTax`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`, `Insert`, `Delete`, `DeleteAll`). 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 data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -51,6 +51,8 @@ The following targeted checks cover every current `data-modeling` article. Treat - A new or extended table's name, fields, or usage positively establish it as one of Business Central's nine business-record types — a name ending `Ledger Entry`/`Register`/`Journal Line`/`Header`/`Line`/`Setup`, an auto-generated `Entry No.`/`No.` key posted from elsewhere, a `Template Name`+`Batch Name`+`Line No.` key, or a singleton `Primary Key` field — `table-design-must-match-bc-table-type-conventions`. Do not worklist it from a bare `keys` block or primary-key declaration alone: a temporary/buffer table, a work queue, a log, a cross-reference/mapping table, or a process-local staging table is not one of the nine types and is out of this rule's scope entirely, not an unresolved case. - Code reads `Item Ledger Entry."Document No."` (or `"Last Shipping No."`/`"Last Posting No."`) after a combined Ship+Invoice **sales** post — `item-ledger-entry-document-no-follows-last-shipping-no`. This is a sales-specific rule: purchase combined posting is Receive+Invoice and uses receiving fields such as `"Last Receiving No."`, not the shipment/document-number behavior this article describes. Do not worklist it from purchase posting code. - 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`. +- Code outside the table creates a non-temporary master record (`Customer`, `Vendor`, `Item`, `G/L Account`, `Contact`, or a custom master with initializing `OnInsert` logic) with `Insert()`/`Insert(false)` and does not itself assign the number and the fields `OnInsert` would set — `master-data-must-be-inserted-with-trigger`. Do not flag temporary records, buffer/staging tables, a caller that visibly assigns the trigger's fields before applying a template, or an XMLport round-tripping rows exported from Business Central; `Modify()` without the trigger is not this rule. +- Code outside the owning table's own `OnDelete` calls `Delete()`/`DeleteAll()` (or passes `false`) on a non-temporary master or reference table with an `OnDelete` guard or cascade (`Currency`, `Customer`, `Vendor`, `Item`, `G/L Account`, or a custom equivalent) — `delete-master-data-with-trigger`. Do not flag an owning table's `OnDelete` deleting its own dependents, temporary/buffer records, deleting and immediately re-inserting the same primary key (restore/recreate) where dependents stay valid, or a data-migration/setup reset in a company with no posted entries that removes master rows and their setup references as one rebuild. - 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`. - A master gains or changes `Blocked`, or a document line, journal line, reference-field `OnValidate`, or posting routine uses that master without `TestField(Blocked, false)` at the point of use; also cue when the check is placed only in the master's own triggers — `check-blocked-in-referencing-code-not-in-master`. - A master table adds or changes `Last Date Modified`, `OnModify`, or `OnRename`, but the non-editable field is not assigned `Today()` in both triggers — `set-last-date-modified-in-onmodify-and-onrename`. @@ -101,7 +103,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable data-modeling knowledge survived filtering. -- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface, and no document print/email/Post-and-Send action, `Navigate` subscriber, Report Selection registration/dispatch, price-calculation/price-source extensibility point, sales/purchase/service line price or amount read/write, posting-cascade `TransferFields` mirroring, barcode/report-font-provider usage, dimension wiring, posting-routine structure, or Item-Ledger-Entry-document-number surface. +- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface, and no document print/email/Post-and-Send action, `Navigate` subscriber, Report Selection registration/dispatch, price-calculation/price-source extensibility point, sales/purchase/service line price or amount read/write, posting-cascade `TransferFields` mirroring, barcode/report-font-provider usage, dimension wiring, posting-routine structure, Item-Ledger-Entry-document-number surface, or master/reference-record insert/delete call. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 3962363..2af0cf4 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -46,7 +46,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially codeunits that post or validate, tables and table extensions with `OnValidate` triggers, and any procedure that raises errors or orchestrates a batch over records. - The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]` or `[TryFunction]`. -- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `FieldError`, `TestField`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, Boolean assignment). +- Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `FieldError`, `TestField`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, `Confirm`, `xRec`, Boolean assignment). - For the outbound HTTP call paths identified in Source, include `HttpClient`, `Get`, `Post`, `HttpResponseMessage`, response use, and caller failure handling (including `[TryFunction]` call sites) in keyword and topic matching. - Resolve changed standalone call targets; when the target declaration has `[TryFunction]`, worklist the ignored-return rule even if the declaration itself is unchanged. Only assignment and conditional use activate try semantics. @@ -54,6 +54,7 @@ A file enters the candidate worklist when its `keywords` intersect the extracted The following targeted checks cover every current `error-handling` article: +- A field `OnValidate` (or a procedure it calls) uses `Confirm` to gate a side effect that releases, cancels, or replaces state tied to the old (`xRec`) value, after the change nothing references that old state any more (it is orphaned), and the declined branch neither raises an error nor restores the field while the change proceeds — `declined-confirm-must-abort-not-partially-apply`. Do not flag declined updates whose old state stays valid (such as leaving tasks on a still-existing old campaign), optional follow-ups whose skipping leaves every record consistent (such as declining to update document lines after a header change), or `exit` on a declined `Confirm` in an action before anything is written. - `[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 inserts an error/duration log record around a failed `TryFunction`/`GetLastErrorText`/`GetLastErrorCode` path and then raises, propagates, or rethrows the error — `log-writes-must-survive-rollback`. Do not worklist it when the log insert already happens inside a `Session.StartSession`-targeted codeunit's `OnRun`; that is the compliant shape, not the signal to flag. - 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. From ef121b38d761e089ce11ebb1c19a908e3e96f701 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Mon, 5 Oct 2026 14:56:10 +0200 Subject: [PATCH 47/51] Add JesperSchulz to every CODEOWNERS rule (#219) Co-authored-by: Jesper Schulz-Wedde Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89dba8c8-6529-4b60-956f-875a59be499d --- CODEOWNERS | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 084e70a..def2bb3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -6,13 +6,13 @@ /.github/ @jesperschulz # Domain experts — required reviewers for coding rules -/microsoft/knowledge/events/ @AleksandricMarko @pchriste-microsoft-com -/microsoft/knowledge/performance/ @BardurKnudsen @pchriste-microsoft-com -/microsoft/knowledge/privacy/ @haoranpb @pchriste-microsoft-com -/microsoft/knowledge/security/ @darjoo @WaelAbuSeada @Aleyenda @pchriste-microsoft-com +/microsoft/knowledge/events/ @AleksandricMarko @pchriste-microsoft-com @jesperschulz +/microsoft/knowledge/performance/ @BardurKnudsen @pchriste-microsoft-com @jesperschulz +/microsoft/knowledge/privacy/ @haoranpb @pchriste-microsoft-com @jesperschulz +/microsoft/knowledge/security/ @darjoo @WaelAbuSeada @Aleyenda @pchriste-microsoft-com @jesperschulz /microsoft/knowledge/style/ @nikolakukrika @jesperschulz @pchriste-microsoft-com -/microsoft/knowledge/testing/ @nikolakukrika @ventselartur @pchriste-microsoft-com -/microsoft/knowledge/upgrade/ @nikolakukrika @pchriste-microsoft-com +/microsoft/knowledge/testing/ @nikolakukrika @ventselartur @pchriste-microsoft-com @jesperschulz +/microsoft/knowledge/upgrade/ @nikolakukrika @pchriste-microsoft-com @jesperschulz # Community content — open to broader review # /community/ reviewers are added as the contributor base grows From 4405c97c579f2d8c04b0d87f4cc0df25de161a96 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:59:15 +0200 Subject: [PATCH 48/51] knowledge(performance): grouped query (Count + ColumnFilter = HAVING) for distinct values and duplicates (#215) Adds use-grouped-query-for-distinct-values-and-duplicates with good/bad samples, a worklist cue in al-performance-review, and registration in the performance review-fixtures override. Co-authored-by: Claude Opus 5.5 --- evaluation/review-fixtures.json | 3 +- ...-for-distinct-values-and-duplicates.bad.al | 21 +++++++++ ...for-distinct-values-and-duplicates.good.al | 47 +++++++++++++++++++ ...uery-for-distinct-values-and-duplicates.md | 39 +++++++++++++++ .../skills/review/al-performance-review.md | 1 + 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.bad.al create mode 100644 microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.good.al create mode 100644 microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 14f7153..4667753 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -88,7 +88,8 @@ "use-setloadfields-for-partial-records", "prefer-modifyall-over-per-row-modify", "al-methods-limited-during-write-transactions", - "avoid-user-prompts-inside-transactions" + "avoid-user-prompts-inside-transactions", + "use-grouped-query-for-distinct-values-and-duplicates" ] }, "privacy": { diff --git a/microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.bad.al b/microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.bad.al new file mode 100644 index 0000000..dbfad2c --- /dev/null +++ b/microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.bad.al @@ -0,0 +1,21 @@ +codeunit 50572 "Customer Name Audit" +{ + // The outer loop is unfiltered: one extra filtered Count() per customer. + // The question is about the whole table, not about any single row. + procedure HasDuplicateCustomerNames(): Boolean + var + Customer: Record Customer; + OtherCustomer: Record Customer; + begin + Customer.SetLoadFields(Name); + if Customer.FindSet() then + repeat + if Customer.Name <> '' then begin + OtherCustomer.SetRange(Name, Customer.Name); + if OtherCustomer.Count() > 1 then + exit(true); + end; + until Customer.Next() = 0; + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.good.al b/microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.good.al new file mode 100644 index 0000000..813fe4a --- /dev/null +++ b/microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.good.al @@ -0,0 +1,47 @@ +// One row per customer name that occurs more than once. +// Name is a plain column, so it is the grouping key; the ColumnFilter on the +// Count column is applied after grouping (HAVING COUNT(*) > 1). +query 50570 "Duplicate Customer Names" +{ + QueryType = Normal; + + elements + { + dataitem(Customer; Customer) + { + column(Name; Name) + { + ColumnFilter = Name = filter(<> ''); + } + column(NameCount) + { + Method = Count; + ColumnFilter = NameCount = filter(> 1); + } + } + } +} + +codeunit 50571 "Customer Name Review" +{ + procedure HasDuplicateCustomerNames(): Boolean + var + DuplicateCustomerNames: Query "Duplicate Customer Names"; + Found: Boolean; + begin + DuplicateCustomerNames.Open(); + Found := DuplicateCustomerNames.Read(); + DuplicateCustomerNames.Close(); + exit(Found); + end; + + procedure GetDuplicateCustomerNames(var DuplicateNames: List of [Text]) + var + DuplicateCustomerNames: Query "Duplicate Customer Names"; + begin + DuplicateCustomerNames.Open(); + while DuplicateCustomerNames.Read() do + DuplicateNames.Add(DuplicateCustomerNames.Name); + DuplicateCustomerNames.Close(); + end; +} diff --git a/microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.md b/microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.md new file mode 100644 index 0000000..6e206ea --- /dev/null +++ b/microsoft/knowledge/performance/use-grouped-query-for-distinct-values-and-duplicates.md @@ -0,0 +1,39 @@ +--- +bc-version: [all] +domain: performance +keywords: [duplicate, distinct, select-distinct, count, having, group-by, columnfilter, method-count, query] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use a grouped query for distinct values and duplicate detection + +## Description + +The AL `Record` type has no `SELECT DISTINCT` or `GROUP BY ... HAVING`. Code that must find which values of a field occur more than once in a table is therefore often written as a loop over the table that, for every row, filters a second record variable on that row's value and calls `Count()`. That sends one extra SQL statement per looped row, so an unfiltered loop costs as many statements as the table has rows, to answer a question about the whole table. A query object answers it in one statement: when any column has an aggregate `Method`, every other `column` becomes an implicit grouping key, so the dataset has one row per distinct combination. A `ColumnFilter` on a non-aggregated column is applied like a `WHERE` clause; a `ColumnFilter` on an aggregated column is applied like a `HAVING` clause, after grouping. A `Method = Count` column with `ColumnFilter = = filter(> 1)` therefore returns only the duplicate groups. This complements [aggregate-before-persisting-intermediate-results](aggregate-before-persisting-intermediate-results.md), which covers grouped totals. + +## Best Practice + +Declare one plain `column` per field of the combination to check, plus a `column` with `Method = Count` and no source field. For a distinct list, read the rows and ignore the count. For duplicates, set `ColumnFilter` on the count column to `filter(> 1)`; one successful `Read()` proves a duplicate exists. Restrict rows with `SetRange`/`SetFilter` on plain columns, or with a `filter` element, which restricts rows but is not included in the dataset. Every extra `column` changes the grouping grain. A runtime `SetFilter` or `SetRange` on the count column replaces its `ColumnFilter` ([setfilter-overwrites-query-columnfilter](../query/setfilter-overwrites-query-columnfilter.md)). Base Application uses this shape to reject duplicate descriptions, for example query 762 "Acc. Sched. Line Desc. Count". + +See sample: [`use-grouped-query-for-distinct-values-and-duplicates.good.al`](use-grouped-query-for-distinct-values-and-duplicates.good.al). + +## Anti Pattern + +A loop over a table (`FindSet` ... `Next`) that, for each row, sets a filter on a second record variable of the same table, over the same rows, to the current row's value and calls `Count()`, `IsEmpty()`, or `FindFirst()` only to learn whether the value occurs more than once. Do not flag: + +- a single uniqueness check for one record, for example in `OnValidate` or before `Insert`; +- an outer loop bounded to one parent, such as the lines of one document, where the statement count does not grow with the table; +- a lookup against a different subset than the one being looped, for example looping quote lines and looking up contract lines; +- a loop that acts on each row it finds, such as marking or updating it, rather than only answering a table-level question; +- a loop where the record being filtered and counted is temporary, which makes no database calls. + +See sample: [`use-grouped-query-for-distinct-values-and-duplicates.bad.al`](use-grouped-query-for-distinct-values-and-duplicates.bad.al). + +## References + +- [Aggregating data in query objects](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-query-totals-grouping): an aggregate `Method` groups the dataset by the other columns; a `Count` column takes only a name; "Using a query to get distinct values". +- [Filtering in query objects](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-query-filters): `ColumnFilter` can be applied to aggregated columns; filters on columns with a totals method correspond to a `HAVING` clause, others to `WHERE`; a filter row is not included in the dataset. +- Base Application: [AccSchedLineDescCount.Query.al](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Finance/FinancialReports/AccSchedLineDescCount.Query.al#L15-L37) (query 762), used by `CheckDuplicateAccScheduleLineDescription` in [AccSchedChartManagement.Codeunit.al](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Finance/FinancialReports/AccSchedChartManagement.Codeunit.al#L390-L398). The same shape appears in [ColmLaytColmHeaderCount.Query.al](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Finance/FinancialReports/ColmLaytColmHeaderCount.Query.al) and [Inventory/Analysis/AnalysisLineDescCount.Query.al](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Inventory/Analysis/AnalysisLineDescCount.Query.al). +- A legitimate per-row lookup that this rule must not flag: [ServiceContractHeader.Table.al](https://github.com/microsoft/BCApps/blob/837ef802485ee457e52310d2ecaa08b93d0122fd/src/Layers/W1/BaseApp/Service/Contract/ServiceContractHeader.Table.al#L2692-L2705) loops one quote's lines, looks up contract lines for each service item, and marks each hit. diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md index 6eae659..1fc1139 100644 --- a/microsoft/skills/review/al-performance-review.md +++ b/microsoft/skills/review/al-performance-review.md @@ -47,6 +47,7 @@ Apply these targeted cues even when simple token overlap would rank the article - Worklist `design-covering-keys-from-read-pattern.md` for a changed secondary key alongside a filtered reader of its table, and `review-overlapping-keys-before-adding-an-index.md` when added or changed keys have overlapping leading fields. A changed key alone is not a finding; read and write workloads determine whether either rule applies. - Worklist `preserve-buffered-inserts-by-separating-target-reads.md` when a loop calls `Insert` and interleaves operations on the insert target or `Commit`. Worklist `aggregate-before-persisting-intermediate-results.md` when repeated grouping calculations and persistent intermediate summaries appear in the same processing path. Do not infer either pattern from `Insert` or `CalcSums` alone. +- Worklist `use-grouped-query-for-distinct-values-and-duplicates.md` when a record loop sets a filter on a second record variable of the same table to the current row's value and calls `Count`, `IsEmpty`, or `FindFirst` only to decide whether that value is duplicated. Do not worklist it for a single uniqueness check on one record (for example in `OnValidate` or before `Insert`), an outer loop bounded to one parent document, a lookup against a different subset than the looped rows, a loop that acts on each row it finds, or a filtered/counted record that is temporary. - Worklist `cache-repeated-filtered-results-with-explicit-scope.md` only when the code or workload establishes repeated **complete** lookup keys (for example, querying the same filtered set in multiple passes, or measured key reuse), or shows a cache that omits result-affecting inputs. A single loop over possibly distinct keys does not establish reuse or justify a cache finding. Worklist `avoid-repeating-unchanged-validation.md` for repeated `Validate` of the same field in one path; do not worklist it from a single validation call. - Worklist `use-setautocalcfields-for-per-row-flowfields.md` when a record loop calls `CalcFields`, or when every row reads the same FlowField for a comparison, branch, or per-record action. Also worklist `calcsums-instead-of-calcfields-in-loop.md` when the loop accumulates one set total: use `CalcSums` directly only for stored source fields, never directly on FlowFields; a FlowField total requires deriving equivalent source filters from its `CalcFormula`. - Worklist `hidden-flowfields-still-calculate-before-bc26-opt-in.md` when a page control directly sources a FlowField and sets `Visible = false` or a visibility expression. Suppress it when the target is known to have BC26's **Calculate only visible FlowFields** feature enabled, or when the FlowField is cheap and intentionally preloaded. From 4cd53eac643c178c192290198821ec2aaedf1375 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:59:53 +0200 Subject: [PATCH 49/51] knowledge(events): database trigger setup flags may only be set to true (#213) * knowledge(events): database trigger setup flags may only be set to true Add an events article with compiled good/bad samples: GetDatabaseTableTriggerSetup (Global Triggers) and OnAfterGetDatabaseTableTriggerSetup (GlobalTriggerManagement) share four var Booleans across all subscribers, which run in no particular order. Assigning false, or a lookup result without or-ing in the current value, clears flags other features set (Dataverse sync, API webhooks, data archive, and, for direct Global Triggers subscribers, the change log). Recommends the codeunit 49 integration events per Learn's guidance on system codeunits 2000000001..2000000010 and handler-side table filtering. Wired into al-events-review tokens and an event-design check, with carve-outs for conditional := true, Flag := Flag or ..., and the no-op "if not Flag then Flag := false" found in BCApps. Registered in the events review-fixtures override. Co-Authored-By: Claude Opus 5.5 * knowledge(events): address review of database trigger setup flags article - Name the BCApps code that clears flags (demo data tool W1/CZ/IN, Backup Management test library) as the mechanism in demo/test-only sessions; carve such code out of the al-events-review check. - Label "events are raised only when the flag is true" as inferred and cite the supporting sources (Learn integration-record refactoring on disabled bulk SQL operations, ChangeLog test cache comment, No Transactions Subscriber); fold the bulk-SQL cost into Best Practice. - Drop the "handler without opt-in" signal from article and check. - Qualify change log protection as normal execution context only. - Drop the unverifiable codeunit ID; describe Global Triggers by its 2000000001..2000000010 range and mark the transition page as v14-era. - Order-dependent wording for flag overwrites; add GP and No Transactions as further direct subscribers; link the references. Samples rechecked with alc 30.0 against Base Application 28.4 symbols. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- evaluation/review-fixtures.json | 3 +- ...setup-flags-may-only-be-set-to-true.bad.al | 62 ++++++++++++++++++ ...etup-flags-may-only-be-set-to-true.good.al | 65 +++++++++++++++++++ ...ger-setup-flags-may-only-be-set-to-true.md | 46 +++++++++++++ microsoft/skills/review/al-events-review.md | 3 +- 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.bad.al create mode 100644 microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.good.al create mode 100644 microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 4667753..9dd1496 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -42,7 +42,8 @@ "events": { "articles": [ "reset-ishandled-only-when-the-value-can-carry-over", - "changecompany-runs-triggers-in-the-calling-company" + "changecompany-runs-triggers-in-the-calling-company", + "database-trigger-setup-flags-may-only-be-set-to-true" ] }, "finance": { diff --git a/microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.bad.al b/microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.bad.al new file mode 100644 index 0000000..0cfdb81 --- /dev/null +++ b/microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.bad.al @@ -0,0 +1,62 @@ +codeunit 50110 "Change Tracking Subscr. Bad" +{ + // Self-contained demonstration of the anti pattern. Not derived from base-app source. + [EventSubscriber(ObjectType::Codeunit, Codeunit::GlobalTriggerManagement, OnAfterGetDatabaseTableTriggerSetup, '', false, false)] + local procedure OptInTrackedTables(TableId: Integer; var OnDatabaseInsert: Boolean; var OnDatabaseModify: Boolean; var OnDatabaseDelete: Boolean; var OnDatabaseRename: Boolean) + var + TrackedTable: Record "Tracked Table Bad"; + IsTracked: Boolean; + begin + IsTracked := TrackedTable.Get(TableId); + // Stores false for every table this feature does not track, clearing flags that + // Dataverse integration, API webhooks, or another app already set for that table. + OnDatabaseModify := IsTracked; + OnDatabaseDelete := IsTracked; + // Opting out of operations this feature does not need clears them for everyone else too. + OnDatabaseInsert := false; + OnDatabaseRename := false; + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::GlobalTriggerManagement, OnAfterOnDatabaseModify, '', false, false)] + local procedure LogModify(RecRef: RecordRef) + var + TrackedChange: Record "Tracked Change Bad"; + begin + TrackedChange.Init(); + TrackedChange."Table No." := RecRef.Number(); + TrackedChange."Record ID" := RecRef.RecordId(); + TrackedChange.Insert(); + end; +} + +table 50110 "Tracked Table Bad" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Table No."; Integer) { } + } + + keys + { + key(PK; "Table No.") { Clustered = true; } + } +} + +table 50111 "Tracked Change Bad" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { AutoIncrement = true; } + field(2; "Table No."; Integer) { } + field(3; "Record ID"; RecordId) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.good.al b/microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.good.al new file mode 100644 index 0000000..1fdac44 --- /dev/null +++ b/microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.good.al @@ -0,0 +1,65 @@ +codeunit 50112 "Change Tracking Subscr. Good" +{ + // Self-contained demonstration of the best practice. Not derived from base-app source. + [EventSubscriber(ObjectType::Codeunit, Codeunit::GlobalTriggerManagement, OnAfterGetDatabaseTableTriggerSetup, '', false, false)] + local procedure OptInTrackedTables(TableId: Integer; var OnDatabaseInsert: Boolean; var OnDatabaseModify: Boolean; var OnDatabaseDelete: Boolean; var OnDatabaseRename: Boolean) + var + TrackedTable: Record "Tracked Table Good"; + begin + // Only ever turn flags on; flags set by other features stay untouched. + if not TrackedTable.Get(TableId) then + exit; + OnDatabaseModify := true; + OnDatabaseDelete := true; + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::GlobalTriggerManagement, OnAfterOnDatabaseModify, '', false, false)] + local procedure LogModify(RecRef: RecordRef) + var + TrackedTable: Record "Tracked Table Good"; + TrackedChange: Record "Tracked Change Good"; + begin + // The event also fires for tables other features opted in. + if RecRef.IsTemporary() then + exit; + if not TrackedTable.Get(RecRef.Number()) then + exit; + + TrackedChange.Init(); + TrackedChange."Table No." := RecRef.Number(); + TrackedChange."Record ID" := RecRef.RecordId(); + TrackedChange.Insert(); + end; +} + +table 50112 "Tracked Table Good" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Table No."; Integer) { } + } + + keys + { + key(PK; "Table No.") { Clustered = true; } + } +} + +table 50113 "Tracked Change Good" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { AutoIncrement = true; } + field(2; "Table No."; Integer) { } + field(3; "Record ID"; RecordId) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.md b/microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.md new file mode 100644 index 0000000..b850a52 --- /dev/null +++ b/microsoft/knowledge/events/database-trigger-setup-flags-may-only-be-set-to-true.md @@ -0,0 +1,46 @@ +--- +bc-version: [15..] +domain: events +keywords: [getdatabasetabletriggersetup, onaftergetdatabasetabletriggersetup, globaltriggermanagement, global-triggers, ondatabaseinsert, ondatabasemodify, ondatabasedelete, ondatabaserename, change-log, var-parameter] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Database trigger setup flags may only be set to true + +## Description + +The `OnDatabaseInsert`, `OnDatabaseModify`, `OnDatabaseDelete`, and `OnDatabaseRename` events let one subscriber react to writes on any table, receiving the record as a `RecordRef`. They are opt-in per table through `GetDatabaseTableTriggerSetup(TableId; var OnDatabaseInsert; var OnDatabaseModify; var OnDatabaseDelete; var OnDatabaseRename)`, raised by the system codeunit `Global Triggers` (in the 2000000001..2000000010 range). Codeunit 49 `GlobalTriggerManagement` subscribes to it, collects the Dataverse integration and API webhook flags, raises its own integration event `OnAfterGetDatabaseTableTriggerSetup` with the same four `var` Booleans, and then, in the normal execution context only, adds the change log flags. + +That the platform raises the database events for a table only when the matching flag ends up `true` is not stated on Microsoft Learn; it is inferred from the sources below. Learn states that subscribing to global triggers disables bulk SQL insert, modify, and delete for that table, so the answer is per table. A BCApps change log test notes that global trigger management may have cached the setup for a table. The `No Transactions Subscriber` test library sets all four flags for every table so that it sees every write. + +The four Booleans are shared by every subscriber in the chain, and subscribers run in no particular order. A subscriber that assigns `false`, or assigns an expression that can be `false` such as `OnDatabaseModify := MySetup.Get(TableId)`, overwrites whatever another feature already set for that table if it runs after that feature. The table may then stop raising the database events, and features that rely on them (the change log, Dataverse synchronization, API webhook notifications, data archiving) stop working for it with no error. `GlobalTriggerManagement` asks the change log last, in the normal execution context only, and its comment says it does not want anyone to disable change log management. That ordering protects only the change log flags, and only against `OnAfterGetDatabaseTableTriggerSetup` subscribers. It does not protect the other features' flags, and it does not protect anything against another direct subscriber to `Global Triggers`. + +## Best Practice + +Prefer table-specific mechanisms when the set of tables is known. Learn advises avoiding global trigger subscribers in general because every opted-in table loses bulk SQL operations. Use the database events only when the set of tables is open-ended or configurable, and turn on only the operations and tables the feature needs. + +Subscribe to `GlobalTriggerManagement`'s integration events, `OnAfterGetDatabaseTableTriggerSetup` to opt in and `OnAfterOnDatabaseInsert`, `OnAfterOnDatabaseModify`, `OnAfterOnDatabaseDelete`, or `OnAfterOnDatabaseRename` to react. Learn does not recommend subscribing directly to the events of system codeunits 2000000001..2000000010. Some Microsoft apps do, for example `Data Archive Db Subscriber`, `GP Collect All Modifications`, and the `No Transactions Subscriber` test library, and those subscriptions still compile and run. + +In the setup subscriber, only turn flags on: `if IsTracked(TableId) then OnDatabaseModify := true;`, or `OnDatabaseModify := OnDatabaseModify or IsTracked(TableId);` as `Change Log Management` does. A statement such as `if not OnDatabaseDelete then OnDatabaseDelete := false;`, which appears in BCApps, cannot clear a flag and is not this anti-pattern. In the handler, check `RecRef.Number` against the feature's own setup and skip temporary records, because the events also fire for every table another feature opted in. + +See sample: [`database-trigger-setup-flags-may-only-be-set-to-true.good.al`](database-trigger-setup-flags-may-only-be-set-to-true.good.al). + +## Anti Pattern + +In a subscriber to `GetDatabaseTableTriggerSetup` (`Global Triggers`) or `OnAfterGetDatabaseTableTriggerSetup` (`GlobalTriggerManagement`), any assignment to one of the four `var` flags that can store `false` when the flag was already `true`. This includes a literal `false`, an assignment from a lookup or Boolean expression without `or` on the flag's current value, and `Clear` on the parameter. + +BCApps has this shape in two places, which shows the mechanism rather than an exception to it. The demo data generator assigns `OnDatabaseInsert := IsTableIDIncludedIntoFullPack(TableId)` while it collects table IDs. The test library `Backup Management` assigns all four flags from its own lookup. Both clear flags set by other features, and both run only in demo-data generation or test sessions where that is accepted. Production and extension code has no such session boundary. + +See sample: [`database-trigger-setup-flags-may-only-be-set-to-true.bad.al`](database-trigger-setup-flags-may-only-be-set-to-true.bad.al). + +## References + +- [Transitioning from codeunit 1 to system codeunits](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/upgrade/transition-from-codeunit1): v14-era upgrade guidance. `GetDatabaseTableTriggerSetup` and `OnDatabase*` moved to codeunit 49 `GlobalTriggerManagement`, and Learn advises against subscribing directly to system codeunits 2000000001..2000000010. +- [How to refactor use of integration records to system fields](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-integration-record-refactoring): subscribers to codeunit 49 hurt performance, and subscribing to global triggers disables bulk SQL insert, modify, and delete for that table. +- [Event types, global events](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-event-types#global-events): the codeunit 49 integration events. [Subscribing to events](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-subscribing-to-events): subscribers run one at a time in no particular order. +- [GlobalTriggerManagement.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/GlobalTriggerManagement.Codeunit.al): the `Global Triggers` setup subscriber and its signature (lines 51-52), the change log ordering and comment in the normal execution context (62-64), `OnAfterGetDatabaseTableTriggerSetup` (173-174), and `OnAfterOnDatabase*` (178-194). +- Inference sources: [ChangeLog.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/Tests/Misc/ChangeLog.Codeunit.al) line 2087 (setup cached per table) and [NoTransactionsSubscriber.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/LibraryNoTransactions/app/NoTransactionsSubscriber.Codeunit.al) lines 4-11 (all four flags on). +- Only-true assignments in BCApps: `ChangeLogManagement.Codeunit.al` lines 85-88 (`or`), `APIWebhookNotificationMgt.Codeunit.al` 224-227, `CRMIntegrationManagement.Codeunit.al` 3748-3753, `MasterDataManagement.Codeunit.al` 1511-1516, `DataArchiveDbSubscriber.Codeunit.al` 27-32, and `GPCollectAllModifications.codeunit.al` 11-21. +- Flag-clearing assignments in demo and test code: [CreateDemonstrationData.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/DemoTool/CreateDemonstrationData.Codeunit.al) lines 343-344 (also the CZ layer line 353 and the IN layer line 357) and [BackupManagement.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/Tests/TestLibraries/BackupManagement.Codeunit.al) lines 470-476. diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md index b257d3d..1a5feea 100644 --- a/microsoft/skills/review/al-events-review.md +++ b/microsoft/skills/review/al-events-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 that publish events or host event subscribers, posting/release/validation routines that should expose extension points, and test codeunits that bind subscribers. - The changed procedures and triggers, weighted toward event publisher methods, methods carrying the `[EventSubscriber(...)]` attribute, routines that raise `OnBefore`/`OnAfter` events, and any procedure that calls `BindSubscription`/`UnbindSubscription`. -- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`, `ChangeCompany`, `StartSession`, `RunTrigger`). +- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`, `ChangeCompany`, `StartSession`, `RunTrigger`, `GetDatabaseTableTriggerSetup`, `OnAfterGetDatabaseTableTriggerSetup`, `GlobalTriggerManagement`, `Global Triggers`, `OnDatabaseInsert`, `OnDatabaseModify`, `OnDatabaseDelete`, `OnDatabaseRename`). 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. @@ -66,6 +66,7 @@ The following targeted checks map diff signals to specific `events` articles. Tr - A `var IsHandled` added to a pre-existing event rather than introduced through a new `OnBefore` publisher — `do-not-add-ishandled-to-an-existing-event`. - An `if IsHandled then exit;` whose skipped body performs posting, ledger-entry creation, number-series consumption, or integrity/permission validation — `do-not-bypass-critical-operations-with-ishandled`. - A record variable that had `ChangeCompany()` called on it and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false` — `changecompany-runs-triggers-in-the-calling-company`. Do not match a read-only use after `ChangeCompany`, a write with `RunTrigger = false` into an extension-owned table whose triggers do not read company data and whose trigger-event subscribers exit on `RunTrigger = false`, or the parameterless `ChangeCompany()` reset. +- A subscriber to `GetDatabaseTableTriggerSetup` (`Global Triggers`) or `OnAfterGetDatabaseTableTriggerSetup` (`GlobalTriggerManagement`) that assigns one of its `var` flags (`OnDatabaseInsert`, `OnDatabaseModify`, `OnDatabaseDelete`, `OnDatabaseRename`) a literal `false` or a lookup/Boolean expression that does not `or` in the flag's current value, or that calls `Clear` on one — `database-trigger-setup-flags-may-only-be-set-to-true`. Do not match `Flag := true` under a condition, `Flag := Flag or `, or `if not Flag then Flag := false;`, none of which can clear a flag. Do not match code that runs only in demo-data generation or test-library sessions (for example the base application's demo data tool or a test library's backup/restore subscriber), where clearing other features' flags is accepted. ## Action From 7726d5d8d8dbcb146489f0e65e76e9cc9b976db0 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:01:04 +0200 Subject: [PATCH 50/51] Harden findings-report producer constraints (#218) Reject fragment-bearing finding IDs and cap uncited confidence and severity in the shared schema. Require final-source verification before emission and cover leaf/root compatibility and fail-closed source bounds. Co-authored-by: wenjiefan Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- schemas/findings-report.schema.json | 13 ++- skills/do.md | 24 +++++- tools/Test-ReviewContract.ps1 | 121 +++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/schemas/findings-report.schema.json b/schemas/findings-report.schema.json index 76712f4..f0cfd35 100644 --- a/schemas/findings-report.schema.json +++ b/schemas/findings-report.schema.json @@ -122,7 +122,7 @@ "additionalProperties": false, "required": ["id", "severity", "message", "references", "confidence"], "properties": { - "id": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1, "pattern": "^[^#]+$" }, "severity": { "enum": ["blocker", "major", "minor", "info"] }, "message": { "type": "string", "minLength": 1 }, "location": { "$ref": "#/definitions/location" }, @@ -135,6 +135,17 @@ "domain": { "type": "string", "minLength": 1, "pattern": "^[^\\r\\n]+$" }, "suggested-code": { "type": "string", "minLength": 1 }, "suggested-code-omission-reason": { "type": "string", "minLength": 1 } + }, + "if": { + "properties": { + "references": { "maxItems": 0 } + } + }, + "then": { + "properties": { + "confidence": { "enum": ["medium", "low"] }, + "severity": { "enum": ["minor", "info"] } + } } }, "suppressed": { diff --git a/skills/do.md b/skills/do.md index e5aef5b..a10e5b6 100644 --- a/skills/do.md +++ b/skills/do.md @@ -128,8 +128,8 @@ source-scope locations, and article-body retrieval. "message": "string", "location": { "file": "string", - "line": 0, - "range": { "start-line": 0, "end-line": 0 } + "line": 1, + "range": { "start-line": 1, "end-line": 1 } }, "references": [ { "path": "string", "sha": "string" } @@ -165,6 +165,26 @@ The emitted document MUST be strict, valid JSON per [RFC 8259](https://www.rfc-e AL source is the common failure case. Quoted identifiers (for example `Rec."No."`) and multi-line snippets routinely appear in `message`, `suggested-code`, and `suggested-code-omission-reason`, and each embedded quote or newline MUST be escaped when placed in a string value. A `suggested-code` payload that spans several lines is a single JSON string with `\n` separators, not a literal multi-line block. Emit the document as one JSON value with no trailing commentary, and do not rely on the consumer to repair unescaped output. +### Producer pre-emission checklist + +Before emitting each leaf report or super-skill rollup: + +1. Copy every citation-based `findings[].id` verbatim from + `references[0].path`, with no `#` fragment or other suffix. Apply the + reference-integrity gate below. +2. For `references: []`, emit only `confidence: "medium"` or `"low"` and + `severity: "minor"` or `"info"`. Preserve the role-specific agent ID + prefixes defined below. +3. Open the final source snapshot for every `location.file`. Verify `line` + and any inclusive range bounds are 1-based final-file line numbers within + that file's length, never diff/patch-relative line numbers. If the source + snapshot cannot be verified, return `outcome: "failed"` with an + `outcome-reason`, not unverified locations. + +Validate the complete document against the schema and semantic rules before +returning it. Consumers MUST NOT strip ID suffixes, downgrade agent findings, +or clamp locations to make an invalid report pass the acceptance gate. + ### Consumer acceptance gate Capture the exact Task return as the immutable raw audit payload and primary diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1 index db1c1b1..a01e283 100644 --- a/tools/Test-ReviewContract.ps1 +++ b/tools/Test-ReviewContract.ps1 @@ -56,6 +56,14 @@ function Assert-ThrowsLike { throw "Expected error like '$Pattern', but no error was thrown." } +function Assert-ReportSchema { + param([object] $Report, [bool] $Expected, [string] $Message) + + $valid = $Report | ConvertTo-Json -Depth 30 | + Test-Json -SchemaFile (Join-Path $Root 'schemas/findings-report.schema.json') -ErrorAction SilentlyContinue + Assert-True ($valid -eq $Expected) $Message +} + function Test-PositiveInteger { param([object] $Value) @@ -116,6 +124,11 @@ foreach ($surface in @( $normalizedDoContract = $doContract -replace '\s+', ' ' foreach ($expected in @( + 'Copy every citation-based `findings[].id` verbatim from `references[0].path`, with no `#` fragment or other suffix.', + 'For `references: []`, emit only `confidence: "medium"` or `"low"` and `severity: "minor"` or `"info"`.', + 'Open the final source snapshot for every `location.file`.', + '1-based final-file line numbers within that file''s length, never diff/patch-relative line numbers.', + 'Consumers MUST NOT strip ID suffixes, downgrade agent findings, or clamp locations', 'positive integers', 'start-line <= line <= end-line', 'does not contain the `suggested-code` field', @@ -286,6 +299,66 @@ try { $acceptedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super Assert-True (-not $acceptedSuper.normalized) 'valid super-skill report is accepted' + foreach ($isAgent in @($false, $true)) { + foreach ($severity in 'blocker', 'major', 'minor', 'info') { + foreach ($confidence in 'high', 'medium', 'low') { + $schemaLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $schemaLeaf.findings[0].severity = $severity + $schemaLeaf.findings[0].confidence = $confidence + $schemaLeaf.summary.counts.minor = 0 + $schemaLeaf.summary.counts.$severity = 1 + if ($isAgent) { + $schemaLeaf.findings[0].id = 'agent:uncited-defect' + $schemaLeaf.findings[0].references = @() + } + $expected = -not $isAgent -or ($severity -in @('minor', 'info') -and $confidence -ne 'high') + $caseName = "agent=$isAgent severity=$severity confidence=$confidence" + Assert-ReportSchema $schemaLeaf $expected "leaf schema: $caseName" + + $schemaSuper = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $schemaSuper.'sub-results'[0] = $schemaLeaf + $schemaSuper.summary.counts = $schemaLeaf.summary.counts + $schemaSuper.findings = @($schemaLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + $schemaSuper.findings[0] | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-style-review' + if ($isAgent) { + $schemaSuper.findings[0].id = "al-style-review:$($schemaLeaf.findings[0].id)" + } + Assert-ReportSchema $schemaSuper $expected "rolled-up schema: $caseName" + + if ($isAgent) { + $schemaSuper.'sub-results'[0] = $completedLeaf + $schemaSuper.findings[0].id = $schemaLeaf.findings[0].id + $schemaSuper.findings[0].'from-sub-skill' = 'agent' + $schemaSuper.findings[0].domain = 'Agent' + Assert-ReportSchema $schemaSuper $expected "root-owned agent schema: $caseName" + + $schemaSuper.findings = @() + $schemaSuper.summary.counts = $completedLeaf.summary.counts + $schemaSuper.'sub-results'[0] = $schemaLeaf + Assert-ReportSchema $schemaSuper $expected "nested leaf schema: $caseName" + } + } + } + } + + $citationSuper = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $citationSuper.'sub-results'[0] = $validReport + $citationSuper.summary.counts = $validReport.summary.counts + $citationSuper.findings = @($validReport.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + $citationSuper.findings[0] | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-style-review' + foreach ($position in 'leaf', 'root', 'nested-leaf') { + $fragmentReport = $(if ($position -eq 'leaf') { $validReport } else { $citationSuper }) | + ConvertTo-Json -Depth 20 | ConvertFrom-Json + $fragmentFinding = if ($position -eq 'nested-leaf') { + $fragmentReport.'sub-results'[0].findings[0] + } + else { + $fragmentReport.findings[0] + } + $fragmentFinding.id = "$articlePath#location" + Assert-ReportSchema $fragmentReport $false "$position citation id cannot append a fragment" + } + $duplicateLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json $duplicateLeafReport.'sub-results' = @($completedLeaf, $completedLeaf) Set-Content -LiteralPath $reportPath -Value ($duplicateLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM @@ -897,8 +970,52 @@ try { $invalidAgent.findings[0].references = @() $invalidAgent.findings[0].confidence = 'high' Set-Content -LiteralPath $reportPath -Value ($invalidAgent | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM - Assert-ThrowsLike -Pattern '*AGENT_CONFIDENCE_INVALID*' -Action { - & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp -SourcePaths $sourcePath + $invalidAgentRaw = [IO.File]::ReadAllText($reportPath) + Assert-ThrowsLike -Pattern '*Invalid findings-report JSON or schema*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp -SourcePaths $sourcePath ` + -AllowBoundedNormalization + } + Assert-True ([IO.File]::ReadAllText($reportPath) -ceq $invalidAgentRaw) 'invalid agent payload is not silently repaired' + + $mismatchedCitation = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $mismatchedCitation.findings[0].id = "${articlePath}:location" + Assert-ReportSchema $mismatchedCitation $true 'cross-field citation equality still requires semantic validation' + Set-Content -LiteralPath $reportPath -Value ($mismatchedCitation | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*PRIMARY_REFERENCE_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath -AllowBoundedNormalization + } + + $finalSourcePath = 'src/final-snapshot.al' + Set-Content -LiteralPath (Join-Path $tmp $finalSourcePath) -Value (1..22 | ForEach-Object { "line $_" }) -Encoding utf8NoBOM + foreach ($bounds in @( + @{ Line = 22; End = 22; Error = $null } + @{ Line = 44; End = $null; Error = '*SOURCE_LINE_INVALID*' } + @{ Line = 22; End = 44; Error = '*SOURCE_RANGE_INVALID*' } + )) { + $locationReport = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $locationReport.findings[0].location = @{ + file = $finalSourcePath + line = $bounds.Line + } + if ($bounds.End) { + $locationReport.findings[0].location.range = @{ 'start-line' = $bounds.Line; 'end-line' = $bounds.End } + } + Assert-ReportSchema $locationReport $true 'schema alone cannot verify final-file line bounds' + Set-Content -LiteralPath $reportPath -Value ($locationReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $locationRaw = [IO.File]::ReadAllText($reportPath) + $validateLocation = { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $finalSourcePath -RetrievedArticlePaths $articlePath -AllowBoundedNormalization + } + if ($bounds.Error) { + Assert-ThrowsLike -Pattern $bounds.Error -Action $validateLocation + } + else { + $acceptedLocation = & $validateLocation + Assert-True (-not $acceptedLocation.normalized) 'the final source line is accepted without normalization' + } + Assert-True ([IO.File]::ReadAllText($reportPath) -ceq $locationRaw) 'source locations are never clamped in the raw payload' } $normalizable = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json From 02e7ab15b040be712ff8b694f65ae82257aac0db Mon Sep 17 00:00:00 2001 From: Jeremy Vyska <35526546+JeremyVyska@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:02:04 +0200 Subject: [PATCH 51/51] Add retention policy knowledge to the privacy domain (#177) * Add retention policy knowledge to the privacy domain Two articles covering retention policies for extension-owned tables, the gap that lets high-volume log tables grow unbounded: - register-owned-log-tables-for-retention-policies: an extension's own log tables must be added to the allowed-tables list from install AND upgrade code, guarded by IsAllowedTable plus an upgrade tag, with a mandatory minimum retention where audit needs one. - ship-a-default-retention-policy-setup: registration only makes a table selectable; nothing is deleted until a Retention Policy Setup record exists, so ship one (disabled by default) as the platform's own Retention Policy Installer does. Each ships good/bad AL samples. Claims verified against the BC admin docs and the Retention Policy module in microsoft/BCApps. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011gvTjm746MtJEVWeRTbG46 * Address review feedback on retention policy knowledge - Scope both articles to bc-version [17..] (retention policies shipped in v17). - Allowed-tables sample: add OnRefreshAllowedTables subscriber with a ForceUpdate path; the upgrade tag now gates one-time setup only. - Default-policy sample: use Retention Policy Setup.FindOrCreateRetentionPeriod instead of a hand-rolled lookup-then-insert that can collide on code. - Anti-pattern now keys on append-only tables rather than table names. - al-privacy-review: add retention-policy tokens and deterministic routing for both articles, with a bounded per-table text search for delete and registration paths. Co-Authored-By: Claude Opus 5.5 * Address second review round on retention policy knowledge - Scope both articles to bc-version [22..]: FindOrCreateRetentionPeriod first appears in the 21.1 System Application and OnRefreshAllowedTables in 22. - Reframe the default-setup article as optional guidance; registration without a setup is valid. The anti-pattern and review routing now cover only false claims that registration alone cleans up data. - Make all four samples self-contained: declare Contoso Activity Log in each, add the Retention Policy Setup permission, and guard the default setup on IsAllowedTable. - Let evaluation overrides list additionalArticles and register both retention pairs as extra privacy cases (38 cases, existing IDs unchanged). Co-Authored-By: Claude Opus 5.5 * Revert evaluation harness change for multiple articles per domain The harness intentionally evaluates one paired article per domain. Keep it as designed; how the retention pairs join privacy evaluation is left to the maintainers. Co-Authored-By: Claude Opus 5.5 * Register retention policy pairs in the privacy evaluation override Use main's articles override so both retention article pairs get positive and clean cases alongside no-pii-in-telemetry-message-string. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Jeremy Vyska Co-authored-by: Claude Opus 5 (1M context) --- evaluation/review-fixtures.json | 6 +- ...d-log-tables-for-retention-policies.bad.al | 33 +++++++ ...-log-tables-for-retention-policies.good.al | 87 +++++++++++++++++++ ...owned-log-tables-for-retention-policies.md | 33 +++++++ ...ip-a-default-retention-policy-setup.bad.al | 82 +++++++++++++++++ ...p-a-default-retention-policy-setup.good.al | 55 ++++++++++++ .../ship-a-default-retention-policy-setup.md | 31 +++++++ microsoft/skills/review/al-privacy-review.md | 8 +- 8 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.bad.al create mode 100644 microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.good.al create mode 100644 microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.md create mode 100644 microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.bad.al create mode 100644 microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.good.al create mode 100644 microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.md diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 9dd1496..1fe06e2 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -94,7 +94,11 @@ ] }, "privacy": { - "article": "no-pii-in-telemetry-message-string" + "articles": [ + "no-pii-in-telemetry-message-string", + "register-owned-log-tables-for-retention-policies", + "ship-a-default-retention-policy-setup" + ] }, "query": { "articles": [ diff --git a/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.bad.al b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.bad.al new file mode 100644 index 0000000..19ccdef --- /dev/null +++ b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.bad.al @@ -0,0 +1,33 @@ +table 50567 "Contoso Activity Log" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { AutoIncrement = true; } + field(2; "Activity"; Text[250]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} + +codeunit 50563 "Contoso Activity Log Cleanup" +{ + Access = Internal; + + // The log table is never added to the allowed tables, so it cannot appear + // on the Retention Policies page. Cleanup is hard-coded here instead: + // the period is not configurable, the deletion is not written to the + // Retention Policy Log, and an administrator cannot switch it off. + trigger OnRun() + var + ContosoActivityLog: Record "Contoso Activity Log"; + begin + ContosoActivityLog.SetFilter( + SystemCreatedAt, '<%1', CreateDateTime(CalcDate('<-30D>', Today()), 0T)); + ContosoActivityLog.DeleteAll(); + end; +} diff --git a/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.good.al b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.good.al new file mode 100644 index 0000000..b9246ec --- /dev/null +++ b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.good.al @@ -0,0 +1,87 @@ +table 50566 "Contoso Activity Log" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { AutoIncrement = true; } + field(2; "Activity"; Text[250]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} + +codeunit 50560 "Contoso Reten. Pol. Setup" +{ + Access = Internal; + + procedure AddAllowedTables() + begin + AddAllowedTables(false); + end; + + // ForceUpdate re-registers even after the upgrade tag is set, so the + // table comes back when an administrator refreshes the allowed tables. + procedure AddAllowedTables(ForceUpdate: Boolean) + var + ContosoActivityLog: Record "Contoso Activity Log"; + RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables"; + UpgradeTag: Codeunit "Upgrade Tag"; + IsInitialSetup: Boolean; + begin + IsInitialSetup := not UpgradeTag.HasUpgradeTag(AllowedTableTag()); + if not (IsInitialSetup or ForceUpdate) then + exit; + + if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then + RetenPolAllowedTables.AddAllowedTable( + Database::"Contoso Activity Log", + ContosoActivityLog.FieldNo(SystemCreatedAt), + 28); // support cases need at least four weeks of log history + + if IsInitialSetup then + UpgradeTag.SetUpgradeTag(AllowedTableTag()); + end; + + local procedure AllowedTableTag(): Code[250] + begin + exit('Contoso-ActivityLogAllowedTable-20260910'); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Reten. Pol. Allowed Tables", OnRefreshAllowedTables, '', false, false)] + local procedure AddAllowedTablesOnRefreshAllowedTables() + begin + AddAllowedTables(true); + end; +} + +codeunit 50561 "Contoso Reten. Pol. Install" +{ + Subtype = Install; + Access = Internal; + + trigger OnInstallAppPerCompany() + var + ContosoRetenPolSetup: Codeunit "Contoso Reten. Pol. Setup"; + begin + ContosoRetenPolSetup.AddAllowedTables(); + end; +} + +codeunit 50562 "Contoso Reten. Pol. Upgrade" +{ + Subtype = Upgrade; + Access = Internal; + + // Install code does not run on upgrade, so tenants that already have the + // app get their registration here. + trigger OnUpgradePerCompany() + var + ContosoRetenPolSetup: Codeunit "Contoso Reten. Pol. Setup"; + begin + ContosoRetenPolSetup.AddAllowedTables(); + end; +} diff --git a/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.md b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.md new file mode 100644 index 0000000..1e3bc92 --- /dev/null +++ b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.md @@ -0,0 +1,33 @@ +--- +bc-version: [22..] +domain: privacy +keywords: [retention-policy, allowed-tables, addallowedtable, reten-pol-allowed-tables, onrefreshallowedtables, append-only-table, log-table-growth, deleteall, mandatory-minimum-retention, install-upgrade-codeunit] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Extension-owned log tables must be registered as retention-policy allowed tables + +## Description + +The retention policy engine only ever deletes from tables that appear in its allowed-tables list, and an extension may register only tables it owns — it cannot add a base application table or a table from another extension. Registration is a call to `Codeunit "Reten. Pol. Allowed Tables".AddAllowedTable`, passing the table ID and the field number of the Date or DateTime field that ages each record (`SystemCreatedAt` is the usual choice). Until that call has run in a company, the table cannot be selected on the **Retention Policies** page at all, so an activity log, integration log, or archive table the extension writes to grows with no supported way for an administrator to trim it. The registration is stored per company and is not part of the table's metadata — it exists only because install or upgrade code put it there. + +## Best Practice + +Register every table the extension owns that accumulates rows over time: activity and audit logs, integration and API request logs, archived documents. Call a shared routine from both the install codeunit (`OnInstallAppPerCompany`) and an upgrade codeunit (`OnUpgradePerCompany`), because install code does not run when an existing installation moves to a new version — registration added only to install code never reaches tenants that already have the app. Guard the routine with `IsAllowedTable` and an upgrade tag so repeated runs are idempotent, but keep a force path past the tag: the **Retention Policies** pages raise `Reten. Pol. Allowed Tables.OnRefreshAllowedTables`, and the platform's own installers (System Application, Base Application, Shopify) subscribe to it and re-run registration with `ForceUpdate`. A routine that exits whenever the tag is set cannot take part in that refresh, so the upgrade tag should gate one-time setup only, not re-registration. Pass `MandatoryMinRetenDays` when the data must survive a minimum period for audit or support reasons; the platform then rejects any shorter period an administrator configures. When only a subset of rows should ever expire, build the filter with `AddTableFilterToJsonArray` and pass it to the `AddAllowedTable` overload that takes a `JsonArray` — a filter added as locked cannot be removed later by the administrator. + +Registering a table only makes it eligible; the policy itself is a separate concern, covered by [`ship-a-default-retention-policy-setup.md`](ship-a-default-retention-policy-setup.md). + +See sample: [`register-owned-log-tables-for-retention-policies.good.al`](register-owned-log-tables-for-retention-policies.good.al). + +## Anti Pattern + +An extension-owned table that only grows — the app inserts into it but never deletes from it — with no `AddAllowedTable` call anywhere in the app. The name is not a reliable signal: an "Incoming Data" buffer-history table grows the same way an "Activity Log" does, and such tables have reached hundreds of gigabytes on customer tenants. A variant is a table cleaned by hand-rolled code — a job queue codeunit or scheduled task running `DeleteAll` against a hard-coded date window: the deletion happens outside the **Retention Policy Log**, the administrator has no page on which to lengthen, shorten, or disable it, and the table is invisible during a data-retention review. The same defect in slower form is registration performed only in the install codeunit: new tenants are covered, every existing tenant stays unregistered after the upgrade. + +See sample: [`register-owned-log-tables-for-retention-policies.bad.al`](register-owned-log-tables-for-retention-policies.bad.al). + +## References + +- [Clean up data with retention policies](https://learn.microsoft.com/en-us/dynamics365/business-central/admin-data-retention-policies), section *Include your extension in a retention policy*. +- [`RetenPolAllowedTables.Codeunit.al`](https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Retention%20Policy/src/Retention%20Policy%20Allowed%20Tables/RetenPolAllowedTables.Codeunit.al) in microsoft/BCApps — the `AddAllowedTable` overloads, `IsAllowedTable`, `AddTableFilterToJsonArray`, and `OnRefreshAllowedTables`. diff --git a/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.bad.al b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.bad.al new file mode 100644 index 0000000..fbd6d5a --- /dev/null +++ b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.bad.al @@ -0,0 +1,82 @@ +table 50569 "Contoso Activity Log" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { AutoIncrement = true; } + field(2; "Activity"; Text[250]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} + +page 50570 "Contoso Activity Log" +{ + PageType = List; + ApplicationArea = All; + UsageCategory = Lists; + SourceTable = "Contoso Activity Log"; + Editable = false; + // Registration only makes the table selectable on the Retention Policies + // page. No Retention Policy Setup exists and nothing is deleted, yet the + // page tells the administrator that cleanup is running. + AboutTitle = 'About the activity log'; + AboutText = 'Entries older than six months are deleted automatically, so the log never needs manual cleanup.'; + + layout + { + area(Content) + { + repeater(Entries) + { + field("Entry No."; Rec."Entry No.") { ToolTip = 'Specifies the entry number.'; } + field(Activity; Rec.Activity) { ToolTip = 'Specifies the logged activity.'; } + } + } + } +} + +codeunit 50565 "Contoso Reten. Pol. Register" +{ + Access = Internal; + + procedure AddAllowedTables() + var + ContosoActivityLog: Record "Contoso Activity Log"; + RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables"; + begin + if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then + RetenPolAllowedTables.AddAllowedTable( + Database::"Contoso Activity Log", ContosoActivityLog.FieldNo(SystemCreatedAt)); + end; +} + +codeunit 50571 "Contoso Reten. Pol. Install" +{ + Subtype = Install; + Access = Internal; + + trigger OnInstallAppPerCompany() + var + ContosoRetenPolRegister: Codeunit "Contoso Reten. Pol. Register"; + begin + ContosoRetenPolRegister.AddAllowedTables(); + end; +} + +codeunit 50572 "Contoso Reten. Pol. Upgrade" +{ + Subtype = Upgrade; + Access = Internal; + + trigger OnUpgradePerCompany() + var + ContosoRetenPolRegister: Codeunit "Contoso Reten. Pol. Register"; + begin + ContosoRetenPolRegister.AddAllowedTables(); + end; +} diff --git a/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.good.al b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.good.al new file mode 100644 index 0000000..d85da2a --- /dev/null +++ b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.good.al @@ -0,0 +1,55 @@ +table 50568 "Contoso Activity Log" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { AutoIncrement = true; } + field(2; "Activity"; Text[250]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} + +codeunit 50564 "Contoso Reten. Pol. Default" +{ + Access = Internal; + Permissions = tabledata "Retention Policy Setup" = ri; + + procedure CreateDefaultPolicy() + var + RetentionPolicySetup: Record "Retention Policy Setup"; + RetentionPolicySetupMgt: Codeunit "Retention Policy Setup"; + RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables"; + UpgradeTag: Codeunit "Upgrade Tag"; + begin + // A setup can only be created for a table that is already registered. + if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then + exit; + + // Created once per company: an administrator who deletes the policy + // does not get it back on the next upgrade. + if UpgradeTag.HasUpgradeTag(DefaultPolicyTag()) then + exit; + + if not RetentionPolicySetup.Get(Database::"Contoso Activity Log") then begin + RetentionPolicySetup.Validate("Table Id", Database::"Contoso Activity Log"); + RetentionPolicySetup.Validate("Apply to all records", true); + RetentionPolicySetup.Validate( + "Retention Period", + RetentionPolicySetupMgt.FindOrCreateRetentionPeriod("Retention Period Enum"::"6 Months")); + RetentionPolicySetup.Validate(Enabled, false); // the administrator opts in to deletion + RetentionPolicySetup.Insert(true); + end; + + UpgradeTag.SetUpgradeTag(DefaultPolicyTag()); + end; + + local procedure DefaultPolicyTag(): Code[250] + begin + exit('Contoso-ActivityLogDefaultPolicy-20260910'); + end; +} diff --git a/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.md b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.md new file mode 100644 index 0000000..b450b6f --- /dev/null +++ b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.md @@ -0,0 +1,31 @@ +--- +bc-version: [22..] +domain: privacy +keywords: [retention-policy, retention-policy-setup, addallowedtable, findorcreateretentionperiod, retention-period, default-policy, unbounded-table-growth, opt-in-deletion, upgrade-tag] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Registering a table does not delete anything — consider shipping a default setup + +## Description + +`AddAllowedTable` only makes a table selectable on the **Retention Policies** page. Nothing is deleted until a `Retention Policy Setup` record exists for that table, names a `Retention Period`, and is enabled. Registration alone is a valid pattern — several Microsoft apps register tables and leave the policy entirely to the administrator — but it means the table keeps growing until someone discovers the page, works out which of the extension's tables are safe to trim, and picks a period. Shipping a default setup is optional; where the extension's author knows a sensible period, it removes that discovery step. Microsoft's `Codeunit 3907 "Retention Policy Installer"` shows the shape — it registers `Retention Policy Log Entry`, then creates a setup record with a six-month period on first install, inserted disabled, guarded by an upgrade tag so a policy the administrator later deleted is not recreated on the next upgrade. + +## Best Practice + +When you ship a default, do it in the same install and upgrade routine that registers the table (see [`register-owned-log-tables-for-retention-policies.md`](register-owned-log-tables-for-retention-policies.md)), and create the `Retention Policy Setup` record: get the period code from `Codeunit "Retention Policy Setup".FindOrCreateRetentionPeriod`, which reuses an existing `Retention Period` with the requested enum value and otherwise creates one without colliding on an existing code (a hand-written lookup-then-insert fails when a period with the chosen code already exists for a different value), then `Validate` `"Table Id"`, `"Apply to all records"` and `"Retention Period"` before inserting. The codeunit that inserts the record needs `tabledata "Retention Policy Setup" = ri`. Gate the creation on an upgrade tag so it happens once per company rather than on every upgrade. Default to inserting with `Enabled` set to false: pre-creating the line puts a reviewed, sensible period in front of the administrator while leaving the decision to delete tenant data with them. Shipping the policy enabled is defensible for rows that are purely diagnostic and documented as transient — state that choice, and the default period, in the app's onboarding material either way. + +See sample: [`ship-a-default-retention-policy-setup.good.al`](ship-a-default-retention-policy-setup.good.al). + +## Anti Pattern + +Registering a table and then claiming, in a message, notification, label, teaching tip, or setup text, that its data is now cleaned up automatically. Registration only makes the table selectable; without an enabled `Retention Policy Setup` nothing is deleted, so the administrator is told a cleanup is running when none is, and the table grows unnoticed. Registration without a default setup is not itself a defect. + +See sample: [`ship-a-default-retention-policy-setup.bad.al`](ship-a-default-retention-policy-setup.bad.al). + +## References + +- [Clean up data with retention policies](https://learn.microsoft.com/en-us/dynamics365/business-central/admin-data-retention-policies) — retention periods, enabling a policy, and the job queue entry that applies it. +- [`RetentionPolicyInstaller.Codeunit.al`](https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Retention%20Policy/src/Install/RetentionPolicyInstaller.Codeunit.al) in microsoft/BCApps — the platform's own register-then-create-disabled-setup pattern. diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 17b4e7b..5024252 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -37,11 +37,17 @@ Discard files that are not applicable. Retain conditionally applicable files (an Narrow the relevant files to the subset that applies to the changes under review. Exclude test codeunits, test libraries, test helper code, files under test/Test/Tests paths, and objects with `Subtype = Test`; test data is synthetic and does not ship to customers. For each relevant file, compute overlap against: -- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, and objects reading or writing `IsolatedStorage`. +- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, objects reading or writing `IsolatedStorage`, and install/upgrade codeunits or new tables relevant to retention policies. - The changed procedures and triggers, weighted toward those that call `Error`, construct `ErrorInfo`, call `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`/`GetLastErrorCallStack`, `FeatureTelemetry.LogUsage`/`LogUptake`/`LogError`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`. - Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `ErrorInfo`, `GetLastErrorText`, `GetLastErrorCallStack`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `ErrorText`, `ErrorCallStack`, `alErrorText`, `alErrorCallStack`, `HybridSL`, `HybridGP`, `HybridBC`). - Treat `ErrorInfo.Message`, `ErrorInfo.DataClassification`, `ErrorInfo.ErrorType`, and `ErrorInfo.DetailedMessage` as qualified member signals: accept a call or assignment only when symbol resolution proves that its receiver expression or variable has type `ErrorInfo`. Normalize those accesses to `errorinfo-message`, `errorinfo-dataclassification`, `errorinfo-errortype`, and `errorinfo-detailedmessage` retrieval tokens. Bare `Message` or `DataClassification` tokens MUST NOT trigger this article; do not emit the qualified tokens for `Message(...)` dialog calls, table or table-field `DataClassification` properties, or similarly named members on other types. Resolve the receiver's declaration from the containing object when it is outside the changed hunk. - Worklist ErrorInfo privacy guidance only from those typed `ErrorInfo` member tokens or from construction of an `ErrorInfo` value. For every `FeatureTelemetry.LogError`, inspect the dedicated error text and call-stack arguments in addition to explicit custom dimensions. +- Retention-policy signals. Emit `addallowedtable` for any call to `Codeunit "Reten. Pol. Allowed Tables".AddAllowedTable`, `retention-policy-setup` for any use of `Record "Retention Policy Setup"`, and `retention-policy` for either. Emit `append-only-table` for an extension-owned table (never a tableextension) that the diff adds, or into which the diff adds a new `Insert` path, when the table also passes this bounded check: search the app's `.al` files for the table's object name once each for `AddAllowedTable`, `Delete`, and `DeleteAll`, and emit the token only when none of the three matches. This is a text search per candidate table — do not read or resolve the rest of the app. Emit `deleteall` when the diff adds a `DeleteAll` on an extension-owned table filtered by a date or datetime field inside a job queue codeunit or other scheduled cleanup and the table has no `AddAllowedTable` call. + +Route retention-policy guidance deterministically: + +- `register-owned-log-tables-for-retention-policies.md` is worklisted by `append-only-table` or `deleteall`, or when an `AddAllowedTable` routine is reached only from an install codeunit, or is guarded by an upgrade tag with no `OnRefreshAllowedTables` subscriber that bypasses the tag. Do not use the table's name as the trigger: an append-only table is a candidate whatever it is called ("Incoming Data" as much as "Activity Log"). A name ending in `Log`, `Entry`, `Archive`, `History`, or `Buffer`, an `AutoIncrement` integer primary key, or inserts from event subscribers, job queue codeunits, or API/web-service handlers raise confidence to `medium`; without any of these the finding stays `low`. Findings from `append-only-table` are advisory (`minor`) because table volume cannot be observed from source. +- `ship-a-default-retention-policy-setup.md` is worklisted by `addallowedtable` or `retention-policy-setup`, but registration without a `Retention Policy Setup` is valid and MUST NOT produce a finding. Report only a false claim: a `Message`, notification, label, page `AboutText`/`InstructionalText`, or setup text in the diff stating that the registered table's data is now cleaned up or deleted automatically when no enabled `Retention Policy Setup` is created for it. 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. Apply the topic-specific gates above after this overlap check; in particular, bare `Message` and `DataClassification` tokens cannot admit ErrorInfo guidance. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.