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 2d32006..7732e7e 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" @@ -41,11 +42,16 @@ "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" @@ -85,7 +91,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": { @@ -98,14 +111,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/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/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/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/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/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/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/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/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/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/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-get-inside-loop-on-large-table.md b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md index 597d362..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] 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/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/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/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/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. 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-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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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-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/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/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/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..c12815f 100644 --- a/microsoft/skills/review/al-appsource-review.md +++ b/microsoft/skills/review/al-appsource-review.md @@ -52,6 +52,8 @@ 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-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-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-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 3fca8ec..a73f929 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,10 @@ 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`. - 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`. @@ -54,6 +58,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`. @@ -83,7 +91,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-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 56bc0fe..3962363 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. @@ -47,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-performance-review.md b/microsoft/skills/review/al-performance-review.md index 427bd05..6eae659 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 (`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`). +- 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. @@ -61,6 +61,8 @@ 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. 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..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`). +- 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 5b0b6b6..2aaf902 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,17 @@ 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. +- 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..c42fe85 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -39,18 +39,30 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially codeunits with `Subtype = Test`, test runner codeunits with `TestIsolation`, test libraries, and codeunits that define UI handlers. - The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[TestPermissions(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls. -- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Init`, `Insert`). +- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Initialize`, `IsInitialized`, `OnTestInitialize`, `LibrarySetupStorage`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `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. 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`. -- `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 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`. 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. +- 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-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 19d0735..6d7d71a 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,8 +39,13 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially pages declared with `PageType = API`, API page `part` controls, queries declared with `QueryType = API`, and procedures that expose bound actions. - The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`, `SourceTableTemporary`), navigation metadata (`SubPageLink`, `Multiplicity`, and visible singleton or collection semantics), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields. +- Outbound HTTP integration code that constructs or sends requests, captures a client method's optional Boolean result, checks an `HttpResponseMessage`, or reads and parses response content. +- Integration code that converts values to or from exchanged text: `Format` or `Evaluate` on a request URL, body, or file line, and `JsonToken`/`JsonValue` conversions of payload properties. - Webhook subscriber handlers and subscription lifecycle code, especially code that creates or renews subscriptions, handles `validationToken`, schedules from `expirationDateTime`, or targets resources whose eligibility is visible in the diff. -- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `QueryType`, `API`, `api-page`, `page-part`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `SubPageLink`, `subpagelink`, `Multiplicity`, `multiplicity`, `Many`, `ZeroOrOne`, `SourceTableTemporary`, `Job Queue Entry`, `webhook`, `webhookSupportedResources`, `webhook-supported-resources`, `subscriptions`, `notificationUrl`, `validationToken`, `validationtoken`, `expirationDateTime`, `expirationdatetime`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`). +- 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`). 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. @@ -56,7 +61,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: @@ -64,7 +76,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`. @@ -74,7 +86,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