mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Add error-handling knowledge domain and review leaf skill
Seed the first new AL knowledge domain toward issue #34: a fully wired error-handling domain that the review pipeline actually consumes. - 3 knowledge articles (+ good/bad AL samples): - prefer-errorinfo-for-actionable-errors (ErrorInfo Fix-it/Show-it actions) - collect-validation-errors-with-errorbehavior (ErrorBehavior::Collect) - errortype-internal-vs-client-for-diagnostics (ErrorType Internal vs Client) - New leaf skill al-error-handling-review.md, modeled on al-performance-review - Wire the leaf into al-code-review (sub-skills, Source, description) - README: six -> seven leaf skills Validated: frontmatter validator clean; knowledge-index check passes (deterministic, full coverage, selection inputs intact). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
822cae1b27
commit
8901b83e07
12 changed files with 374 additions and 2 deletions
|
|
@ -0,0 +1,21 @@
|
|||
codeunit 50187 "Collect Errors Bad Sample"
|
||||
{
|
||||
procedure ValidateAllItems()
|
||||
var
|
||||
Item: Record Item;
|
||||
ErrorText: Text;
|
||||
begin
|
||||
// Hand-rolled accumulation: reimplements the platform feature, loses each
|
||||
// error's ErrorInfo structure, and skips telemetry classification.
|
||||
if Item.FindSet() then
|
||||
repeat
|
||||
if Item.Description = '' then
|
||||
ErrorText += StrSubstNo('Item %1 has no description.\', Item."No.");
|
||||
if Item."Unit Cost" <= 0 then
|
||||
ErrorText += StrSubstNo('Item %1 must have a positive unit cost.\', Item."No.");
|
||||
until Item.Next() = 0;
|
||||
|
||||
if ErrorText <> '' then
|
||||
Error(ErrorText);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
codeunit 50185 "Collect Errors Good Sample"
|
||||
{
|
||||
[ErrorBehavior(ErrorBehavior::Collect)]
|
||||
procedure ValidateAllItems()
|
||||
var
|
||||
Item: Record Item;
|
||||
CollectedErrors: List of [ErrorInfo];
|
||||
CollectedError: ErrorInfo;
|
||||
ErrorText: Text;
|
||||
begin
|
||||
if Item.FindSet() then
|
||||
repeat
|
||||
// Run each item in its own context so one failure does not abandon the rest.
|
||||
Codeunit.Run(Codeunit::"Collect Errors Item Check", Item);
|
||||
until Item.Next() = 0;
|
||||
|
||||
if HasCollectedErrors() then begin
|
||||
CollectedErrors := GetCollectedErrors();
|
||||
foreach CollectedError in CollectedErrors do
|
||||
ErrorText += CollectedError.Message() + '\';
|
||||
Message('The following must be fixed before posting:\%1', ErrorText);
|
||||
end;
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50186 "Collect Errors Item Check"
|
||||
{
|
||||
TableNo = Item;
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
if Rec.Description = '' then
|
||||
Error('Item %1 has no description.', Rec."No.");
|
||||
if Rec."Unit Cost" <= 0 then
|
||||
Error('Item %1 must have a positive unit cost.', Rec."No.");
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: error-handling
|
||||
keywords: [collectible-errors, errorbehavior, collect, getcollectederrors, hascollectederrors, validation, batch]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Collect validation errors with ErrorBehavior::Collect and handle the collected list
|
||||
|
||||
## Description
|
||||
|
||||
By default a procedure stops on the first `Error`, so a user fixing ten bad rows must rerun the operation ten times. The collectible-errors feature postpones error handling to the end of the call: a procedure attributed `[ErrorBehavior(ErrorBehavior::Collect)]` keeps running as errors occur and gathers them, so all failures can be presented together. The collected errors are read with `HasCollectedErrors()` and `GetCollectedErrors()` (which returns a `List of [ErrorInfo]`); `ClearCollectedErrors()` empties the buffer. This is a platform mechanism most LLMs are unaware of — they reach for a manually concatenated `Text` buffer or a temporary error table instead.
|
||||
|
||||
## Best Practice
|
||||
|
||||
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()` and surface `GetCollectedErrors()` to the user as a single list. Always handle the collected errors yourself: the platform's own guidance is that any errors still in the collected list when the procedure ends are concatenated into one dialog, which is hard for users to read.
|
||||
|
||||
See sample: `collect-validation-errors-with-errorbehavior.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Two shapes signal trouble. The first is hand-rolled accumulation — appending messages to a `Text` variable and showing them at the end — which reimplements the platform feature, loses each error's `ErrorInfo` structure, and skips telemetry classification. The second is applying `[ErrorBehavior(ErrorBehavior::Collect)]` but never calling `HasCollectedErrors`/`GetCollectedErrors`, so every collected error spills into the platform's concatenated end-of-procedure dialog. Detection: a `Collect` attribute with no matching `GetCollectedErrors` call, or a per-row loop that builds an error string by concatenation.
|
||||
|
||||
See sample: `collect-validation-errors-with-errorbehavior.bad.al`.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
codeunit 50191 "Error Type Bad Sample"
|
||||
{
|
||||
procedure ApplyLedgerBucket(BucketId: Integer)
|
||||
begin
|
||||
// Developer-facing detail shown straight to the user, and no structured telemetry signal.
|
||||
if not BucketInitialized(BucketId) then
|
||||
Error('Unexpected state: ledger bucket %1 not initialized', BucketId);
|
||||
end;
|
||||
|
||||
local procedure BucketInitialized(BucketId: Integer): Boolean
|
||||
begin
|
||||
exit(false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
codeunit 50190 "Error Type Good Sample"
|
||||
{
|
||||
procedure ApplyLedgerBucket(BucketId: Integer)
|
||||
var
|
||||
InternalErr: ErrorInfo;
|
||||
begin
|
||||
if not BucketInitialized(BucketId) then begin
|
||||
InternalErr.ErrorType := ErrorType::Internal;
|
||||
InternalErr.Message := StrSubstNo('Ledger bucket %1 was not initialized before posting.', BucketId);
|
||||
InternalErr.DetailedMessage := 'Internal invariant violated. Inspect the call stack captured in telemetry.';
|
||||
Error(InternalErr);
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure BucketInitialized(BucketId: Integer): Boolean
|
||||
begin
|
||||
exit(false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: error-handling
|
||||
keywords: [errorinfo, errortype, internal, client, telemetry, diagnostics, generic-message]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Set ErrorInfo.ErrorType to Internal for defects you want in telemetry but not in the user's face
|
||||
|
||||
## Description
|
||||
|
||||
`ErrorInfo.ErrorType` controls where an error's message is shown. With `ErrorType::Client` — the behaviour of a normal `Error` — the message is both shown to the user and sent to telemetry. With `ErrorType::Internal` the user sees a generic message while the specific message you set is sent to telemetry only. The distinction matters for *unexpected* failures — a broken invariant, a failed internal assertion, a "this should never happen" branch — where the technical detail helps the partner diagnose the defect but would only confuse the end user. LLMs are unaware `ErrorType` exists, so they expose raw internal-failure text directly to users.
|
||||
|
||||
## Best Practice
|
||||
|
||||
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` and `DetailedMessage` 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`.
|
||||
|
||||
## 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`.
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
table 50182 "Actionable Error Bad Sample"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Qty. to Invoice"; Decimal)
|
||||
{
|
||||
trigger OnValidate()
|
||||
begin
|
||||
// Dead-end error: the code knows the maximum but offers the user no way to apply it.
|
||||
if "Qty. to Invoice" > MaxQtyToInvoice() then
|
||||
Error('You cannot invoice more than %1 units.', MaxQtyToInvoice());
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
local procedure MaxQtyToInvoice(): Decimal
|
||||
begin
|
||||
exit(10);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
table 50180 "Actionable Error Good Sample"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Qty. to Invoice"; Decimal)
|
||||
{
|
||||
trigger OnValidate()
|
||||
var
|
||||
CannotInvoiceErr: ErrorInfo;
|
||||
begin
|
||||
if "Qty. to Invoice" > MaxQtyToInvoice() then begin
|
||||
CannotInvoiceErr.Title := 'Qty. to Invoice isn''t valid';
|
||||
CannotInvoiceErr.Message := StrSubstNo('You cannot invoice more than %1 units.', MaxQtyToInvoice());
|
||||
CannotInvoiceErr.DetailedMessage := 'Reduce the quantity to invoice, or apply the maximum allowed.';
|
||||
CannotInvoiceErr.RecordId := Rec.RecordId();
|
||||
CannotInvoiceErr.AddAction(
|
||||
StrSubstNo('Set value to %1', MaxQtyToInvoice()),
|
||||
Codeunit::"Actionable Error Fixit Sample",
|
||||
'SetQtyToMax');
|
||||
Error(CannotInvoiceErr);
|
||||
end;
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
local procedure MaxQtyToInvoice(): Decimal
|
||||
begin
|
||||
exit(10);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50181 "Actionable Error Fixit Sample"
|
||||
{
|
||||
procedure SetQtyToMax(SourceError: ErrorInfo)
|
||||
var
|
||||
Line: Record "Actionable Error Good Sample";
|
||||
begin
|
||||
if Line.Get(SourceError.RecordId) then begin
|
||||
Line.Validate("Qty. to Invoice", 10);
|
||||
Line.Modify(true);
|
||||
end;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [23..28]
|
||||
domain: error-handling
|
||||
keywords: [errorinfo, actionable-errors, fix-it, show-it, addaction, addnavigationaction, error-dialog]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Prefer ErrorInfo with recommended actions over a plain Error for recoverable failures
|
||||
|
||||
## Description
|
||||
|
||||
A plain `Error('text')` ends the operation with a dead-end dialog: the user reads the message but the system offers no way forward. The `ErrorInfo` data type, combined with the actionable-errors framework added in 2023 release wave 2, lets an error carry a recommended action the user can take to unblock themselves without leaving their task. Two kinds exist: a **Fix-it** action (`AddAction`), used when the code already knows the correct value and can apply it in one step, and a **Show-it** action (`AddNavigationAction` together with `PageNo`), used when the correction lives on a related record the user should be taken to. An error dialog renders at most two recommended actions. LLMs trained on older AL almost always emit a bare `Error(...)` and rarely reach for `ErrorInfo`, so this guidance is remedial.
|
||||
|
||||
## Best Practice
|
||||
|
||||
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`.
|
||||
|
||||
## 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue