mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Merge upstream/main to resolve conflict with #203/#207
Insert-only conflicts in al-data-modeling-review.md (scope line, token list, not-applicable list) and evaluation/review-fixtures.json: both sides kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
49b14834c4
26 changed files with 1410 additions and 44 deletions
|
|
@ -154,7 +154,8 @@ not a deployable or compiled application.
|
||||||
## Before opening a PR
|
## Before opening a PR
|
||||||
|
|
||||||
From your BCQuality checkout, use the existing validators. The Python
|
From your BCQuality checkout, use the existing validators. The Python
|
||||||
validator needs Python and PyYAML; the fixture harness needs PowerShell 7.
|
validator needs Python and PyYAML; the fixture harness needs PowerShell 7.5 or later
|
||||||
|
so findings-report parsing preserves timestamp-shaped JSON strings verbatim.
|
||||||
If PyYAML is not installed in your development environment, install it with
|
If PyYAML is not installed in your development environment, install it with
|
||||||
`python -m pip install pyyaml`.
|
`python -m pip install pyyaml`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,9 @@ only result.
|
||||||
with `tools/Resolve-SkillWorklist.ps1`, passing the enabled layers and
|
with `tools/Resolve-SkillWorklist.ps1`, passing the enabled layers and
|
||||||
disabled skill paths from the task context. Execute every resolved leaf as
|
disabled skill paths from the task context. Execute every resolved leaf as
|
||||||
a discrete invocation. Leaves are independent and may be scheduled serially
|
a discrete invocation. Leaves are independent and may be scheduled serially
|
||||||
or concurrently.
|
or concurrently. Before dispatch, preserve the final ordered selection and
|
||||||
|
legitimate configuration/input-compatibility exclusions as a private expected
|
||||||
|
composition artifact; see [composition acceptance](#composition-acceptance).
|
||||||
5. Capture the exact Task return as the immutable raw audit payload and primary
|
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
|
transport. Preserve it unchanged in private artifacts or host logs. Before
|
||||||
the full DO acceptance gate, create a normalized candidate only for DO's
|
the full DO acceptance gate, create a normalized candidate only for DO's
|
||||||
|
|
@ -61,7 +63,7 @@ only result.
|
||||||
telemetry, and accept the candidate only if the entire copy passes the
|
telemetry, and accept the candidate only if the entire copy passes the
|
||||||
unchanged strict gate. Use `tools/Validate-FindingsReport.ps1`, passing the
|
unchanged strict gate. Use `tools/Validate-FindingsReport.ps1`, passing the
|
||||||
exact source paths and fully retrieved article paths; pass `-SkillKind super`
|
exact source paths and fully retrieved article paths; pass `-SkillKind super`
|
||||||
for the final rolled-up report. The accepted report contains no undeclared
|
and `-ExpectedCompositionPath` for the final rolled-up report. The accepted report contains no undeclared
|
||||||
telemetry fields.
|
telemetry fields.
|
||||||
6. Collect each accepted findings-report into `sub-results` in the declared
|
6. Collect each accepted findings-report into `sub-results` in the declared
|
||||||
`sub-skills` order, not completion order. Run the super-skill self-review
|
`sub-skills` order, not completion order. Run the super-skill self-review
|
||||||
|
|
@ -74,6 +76,67 @@ The runner must never inspect the diff to skip a review domain. A leaf decides
|
||||||
its own task-level applicability and reports `not-applicable` or
|
its own task-level applicability and reports `not-applicable` or
|
||||||
`no-knowledge`.
|
`no-knowledge`.
|
||||||
|
|
||||||
|
## Composition acceptance
|
||||||
|
|
||||||
|
The findings-report validator requires PowerShell 7.5 or later to preserve
|
||||||
|
literal JSON strings with `ConvertFrom-Json -DateKind String`.
|
||||||
|
|
||||||
|
A report can be internally consistent while omitting a selected review. Bind
|
||||||
|
the final acceptance gate to the host's selection, not just the returned
|
||||||
|
reports. The private JSON input to `-ExpectedCompositionPath` follows the
|
||||||
|
[DO consumer acceptance contract](../skills/do.md#consumer-acceptance-gate).
|
||||||
|
For example, if style is selected and security was disabled:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"superSkill": { "id": "al-code-review", "version": 1 },
|
||||||
|
"subSkills": [{ "id": "al-style-review", "version": 1 }],
|
||||||
|
"skipped": [{ "id": "al-security-review", "version": 1, "reason": "configuration" }],
|
||||||
|
"acceptedResults": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Build this artifact from `Resolve-SkillWorklist.ps1` and the input-compatibility
|
||||||
|
decision before dispatch. Resolver `skipped` entries have no version: enrich
|
||||||
|
them from the declared skill's indexed version. Move an input-incompatible
|
||||||
|
selected slot to `skipped` with its selected version and `not-applicable`
|
||||||
|
reason; never use source content or later model output to make that decision.
|
||||||
|
Keep paths, layers, and other resolver metadata if useful for private audit.
|
||||||
|
Do not add the artifact to the findings-report or overwrite it to hide an
|
||||||
|
unfinished invocation. Preserve it with the run's raw payloads.
|
||||||
|
|
||||||
|
Initialize `acceptedResults` before dispatch. After accepting a leaf, save its
|
||||||
|
exact accepted copy (including any permitted normalization) in an immutable
|
||||||
|
host-owned file outside worker/composer write access. Append only its capture
|
||||||
|
to `acceptedResults`, for example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "al-style-review", "version": 1, "reportPath": "accepted/style.json"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`reportPath` may be absolute or relative to the composition artifact's directory.
|
||||||
|
Capture a host-created failed validation report the same way. Never construct
|
||||||
|
these captures from the composed `sub-results` or permit the composing model
|
||||||
|
to supply or alter them. Keep the original selection and exclusions unchanged.
|
||||||
|
|
||||||
|
The gate rejects repeated or unexpected leaf IDs, wrong selected versions,
|
||||||
|
reordered results, fabricated or missing exclusions, and uncaptured or altered
|
||||||
|
leaf content. JSON property order is immaterial; array order, field presence,
|
||||||
|
types, and values must match. Every captured leaf must appear in `sub-results`.
|
||||||
|
If selected leaves remain unfinished, the report must be `partial` with a non-failed returned
|
||||||
|
report, otherwise `failed`; name every unfinished leaf ID exactly in
|
||||||
|
`outcome-reason`. Top-level `from-sub-skill: "agent"` findings are rejected
|
||||||
|
while selected leaf results are missing.
|
||||||
|
Do not fabricate leaf reports or use configuration skips for budget exhaustion.
|
||||||
|
Wait for started invocations to finish; omit the self-review if the selected
|
||||||
|
composition remains incomplete. Coverage still sums the non-failed leaf
|
||||||
|
knowledge worklists, not the number of selected leaf slots.
|
||||||
|
|
||||||
|
Calls without the expected artifact remain supported for structural/semantic
|
||||||
|
validation, but cannot certify that a composed review covered its selection.
|
||||||
|
They also cannot bind nested leaves to the host's accepted outputs.
|
||||||
|
They still reject duplicate leaf IDs and returned-and-skipped conflicts.
|
||||||
|
|
||||||
## Runner-owned choices
|
## Runner-owned choices
|
||||||
|
|
||||||
Keep these settings and behaviors outside BCQuality:
|
Keep these settings and behaviors outside BCQuality:
|
||||||
|
|
@ -113,6 +176,8 @@ A compatible runner:
|
||||||
only part of the review is reliable;
|
only part of the review is reliable;
|
||||||
- orders `sub-results` by the declared worklist and orders rendered findings
|
- orders `sub-results` by the declared worklist and orders rendered findings
|
||||||
deterministically;
|
deterministically;
|
||||||
|
- validates composition against the host-owned selection, and reports selected
|
||||||
|
leaves left unfinished as incomplete rather than clean or configured away;
|
||||||
- calculates top-level severity counts from deduplicated top-level findings,
|
- calculates top-level severity counts from deduplicated top-level findings,
|
||||||
not by summing leaf counts;
|
not by summing leaf counts;
|
||||||
- preserves knowledge paths verbatim and verifies references before publishing;
|
- preserves knowledge paths verbatim and verifies references before publishing;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"check-blocked-in-referencing-code-not-in-master",
|
"check-blocked-in-referencing-code-not-in-master",
|
||||||
"code-must-not-change-workdate",
|
"code-must-not-change-workdate",
|
||||||
"custom-document-dispatch-must-not-bypass-report-selections",
|
"custom-document-dispatch-must-not-bypass-report-selections",
|
||||||
|
"document-line-prices-follow-prices-including-vat",
|
||||||
"document-print-and-email-actions-call-report-selections-directly",
|
"document-print-and-email-actions-call-report-selections-directly",
|
||||||
"extend-find-entries-navigate-for-new-document-types",
|
"extend-find-entries-navigate-for-new-document-types",
|
||||||
"extend-price-source-type-must-sync-document-subset-enum",
|
"extend-price-source-type-must-sync-document-subset-enum",
|
||||||
|
|
@ -37,7 +38,10 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"article": "reset-ishandled-only-when-the-value-can-carry-over"
|
"articles": [
|
||||||
|
"reset-ishandled-only-when-the-value-can-carry-over",
|
||||||
|
"changecompany-runs-triggers-in-the-calling-company"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"finance": {
|
"finance": {
|
||||||
"articles": [
|
"articles": [
|
||||||
|
|
@ -59,6 +63,7 @@
|
||||||
"performance": {
|
"performance": {
|
||||||
"articles": [
|
"articles": [
|
||||||
"use-isempty-for-existence-check",
|
"use-isempty-for-existence-check",
|
||||||
|
"use-get-instead-of-findfirst-on-full-primary-key",
|
||||||
"job-queue-category-code-serializes-conflicting-jobs",
|
"job-queue-category-code-serializes-conflicting-jobs",
|
||||||
"job-queue-external-effects-must-be-idempotent",
|
"job-queue-external-effects-must-be-idempotent",
|
||||||
"job-queue-handlers-must-not-require-ui",
|
"job-queue-handlers-must-not-require-ui",
|
||||||
|
|
@ -160,13 +165,16 @@
|
||||||
"ui": {
|
"ui": {
|
||||||
"articles": [
|
"articles": [
|
||||||
"default-descending-sort-on-historical-pages",
|
"default-descending-sort-on-historical-pages",
|
||||||
"page-design-must-match-bc-page-type-conventions"
|
"page-design-must-match-bc-page-type-conventions",
|
||||||
|
"page-client-expression-must-not-use-in-list",
|
||||||
|
"rolecenter-permission-gating-must-use-accessbypermission"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"upgrade": {
|
"upgrade": {
|
||||||
"articles": [
|
"articles": [
|
||||||
"initvalue-does-not-update-existing-rows",
|
"initvalue-does-not-update-existing-rows",
|
||||||
"upgrade-tag-logic-must-not-nest-deeply"
|
"upgrade-tag-logic-must-not-nest-deeply",
|
||||||
|
"no-changecompany-in-upgrade"
|
||||||
],
|
],
|
||||||
"context": "The extended table existed in the previous app version and already contains rows."
|
"context": "The extended table existed in the previous app version and already contains rows."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Demonstration only; independently authored, not copied from BaseApp.
|
||||||
|
codeunit 50641 "Sales Doc. VAT Basis Bad"
|
||||||
|
{
|
||||||
|
procedure GetNetAndGrossTotals(SalesHeader: Record "Sales Header"; var NetTotal: Decimal; var GrossTotal: Decimal)
|
||||||
|
var
|
||||||
|
SalesLine: Record "Sales Line";
|
||||||
|
begin
|
||||||
|
SalesLine.SetRange("Document Type", SalesHeader."Document Type");
|
||||||
|
SalesLine.SetRange("Document No.", SalesHeader."No.");
|
||||||
|
if SalesLine.FindSet() then
|
||||||
|
repeat
|
||||||
|
// Wrong: "Line Amount" already includes VAT when the header has Prices Including VAT,
|
||||||
|
// so NetTotal is gross and VAT is added a second time. Invoice discount is also ignored.
|
||||||
|
NetTotal += SalesLine."Line Amount";
|
||||||
|
GrossTotal += SalesLine."Line Amount" * (1 + SalesLine."VAT %" / 100);
|
||||||
|
until SalesLine.Next() = 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure GetOutstandingNetAmount(SalesLine: Record "Sales Line"): Decimal
|
||||||
|
begin
|
||||||
|
// Wrong: despite its name, CalculateOutstandingAmountExclTax is based on "Line Amount"
|
||||||
|
// and therefore includes VAT on a Prices Including VAT document.
|
||||||
|
exit(SalesLine.CalculateOutstandingAmountExclTax());
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure SetUnitPriceFromNetSourcePrice(var SalesLine: Record "Sales Line"; NetSourcePrice: Decimal)
|
||||||
|
begin
|
||||||
|
// Wrong: on a Prices Including VAT document this net price is read as a gross price,
|
||||||
|
// so the net line amount drops to NetSourcePrice / (1 + "VAT %" / 100).
|
||||||
|
SalesLine.Validate("Unit Price", NetSourcePrice);
|
||||||
|
SalesLine.Modify(true);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
// Demonstration only; independently authored, not copied from BaseApp.
|
||||||
|
codeunit 50640 "Sales Doc. VAT Basis Good"
|
||||||
|
{
|
||||||
|
procedure GetNetAndGrossTotals(SalesHeader: Record "Sales Header"; var NetTotal: Decimal; var GrossTotal: Decimal)
|
||||||
|
var
|
||||||
|
SalesLine: Record "Sales Line";
|
||||||
|
begin
|
||||||
|
SalesLine.SetRange("Document Type", SalesHeader."Document Type");
|
||||||
|
SalesLine.SetRange("Document No.", SalesHeader."No.");
|
||||||
|
// Amount is always net and "Amount Including VAT" always gross, after line and
|
||||||
|
// invoice discounts, whatever the header's "Prices Including VAT" says.
|
||||||
|
SalesLine.CalcSums(Amount, "Amount Including VAT");
|
||||||
|
NetTotal := SalesLine.Amount;
|
||||||
|
GrossTotal := SalesLine."Amount Including VAT";
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure GetOutstandingNetAmount(SalesLine: Record "Sales Line"): Decimal
|
||||||
|
var
|
||||||
|
SalesHeader: Record "Sales Header";
|
||||||
|
Currency: Record Currency;
|
||||||
|
begin
|
||||||
|
if SalesLine.Quantity = 0 then
|
||||||
|
exit(0);
|
||||||
|
SalesHeader.Get(SalesLine."Document Type", SalesLine."Document No.");
|
||||||
|
Currency.Initialize(SalesHeader."Currency Code");
|
||||||
|
// Amount is net after line and invoice discounts on every document, so the
|
||||||
|
// uninvoiced share needs no VAT conversion.
|
||||||
|
exit(Round(
|
||||||
|
SalesLine.Amount * (SalesLine.Quantity - SalesLine."Quantity Invoiced") / SalesLine.Quantity,
|
||||||
|
Currency."Amount Rounding Precision"));
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure SetUnitPriceFromNetSourcePrice(var SalesLine: Record "Sales Line"; NetSourcePrice: Decimal)
|
||||||
|
var
|
||||||
|
SalesHeader: Record "Sales Header";
|
||||||
|
Currency: Record Currency;
|
||||||
|
UnitPrice: Decimal;
|
||||||
|
begin
|
||||||
|
SalesHeader.Get(SalesLine."Document Type", SalesLine."Document No.");
|
||||||
|
// Scope of the conversion below: Normal VAT only; Full VAT and Sales Tax need their own handling.
|
||||||
|
SalesLine.TestField("VAT Calculation Type", SalesLine."VAT Calculation Type"::"Normal VAT");
|
||||||
|
Currency.Initialize(SalesHeader."Currency Code");
|
||||||
|
|
||||||
|
UnitPrice := NetSourcePrice;
|
||||||
|
// "Unit Price" is gross on a Prices Including VAT document: convert the net source price into that basis.
|
||||||
|
if SalesHeader."Prices Including VAT" then
|
||||||
|
UnitPrice := Round(NetSourcePrice * (1 + SalesLine."VAT %" / 100), Currency."Unit-Amount Rounding Precision");
|
||||||
|
|
||||||
|
SalesLine.Validate("Unit Price", UnitPrice);
|
||||||
|
SalesLine.Modify(true);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: data-modeling
|
||||||
|
keywords: [prices-including-vat, unit-price, line-amount, direct-unit-cost, prepmt-line-amount, amount-including-vat, sales-line, purchase-line, service-line, net-gross]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sales, purchase, and service line prices follow the header's Prices Including VAT
|
||||||
|
|
||||||
|
> Contributions welcome — open a PR to refine or extend this article.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Line prices are gross or net depending on the header's `Prices Including VAT`. When the flag is set, `Unit Price` (sales, service), `Direct Unit Cost` (purchase), `Line Amount`, `Line Discount Amount`, `Inv. Discount Amount`, and the prepayment fields `Prepmt. Line Amount`, `Prepmt. Amt. Inv.`, `Prepmt Amt to Deduct`, and `Prepmt Amt Deducted` all include VAT. When it is cleared they exclude it. Only `Amount`, `VAT Base Amount`, and `Prepayment Amount` (always net) and `Amount Including VAT`, `Prepmt. Amt. Incl. VAT`, and `Prepmt. Amount Inv. Incl. VAT` (always gross) keep a fixed basis. Code that mixes the two groups without looking at the header flag is wrong for every document whose customer or vendor uses the other setting.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
When code needs a known basis — exports, integrations, KPIs, custom totals, commission or margin calculations — read `Amount` for net and `Amount Including VAT` for gross. Both are already reduced by line and invoice discounts and are maintained by the line's VAT calculation, so no VAT arithmetic is needed.
|
||||||
|
|
||||||
|
When code combines fields, pair fields of the same group. BaseApp's `UpdatePrepmtAmounts` sets `Prepmt. Line Amount` from `Line Amount` minus `Inv. Discount Amount`. This is correct because all three follow the header flag. `Prepmt. Amt. Inv.` pairs with `Prepmt. Line Amount`; `Prepmt. Amount Inv. Incl. VAT` pairs only with other gross values.
|
||||||
|
|
||||||
|
When code writes a price from an external source whose basis is known (an EDI price list, an API payload, a web-shop order), get the document header and convert the source price into the header's basis before validating `Unit Price` or `Direct Unit Cost`, using the line's `VAT %` and the currency's `Unit-Amount Rounding Precision`. Alternatively, set `Prices Including VAT` on the header to match the source before the first line is created. Toggling it later on a sales header with priced lines asks the user to confirm a recalculation. Without a UI session, or when validation dialogs are hidden, BaseApp converts all line prices without asking.
|
||||||
|
|
||||||
|
The standard price calculation already converts a `Price List Line` whose `Price Includes VAT` differs from the document's setting, so a price it returns is in the document's basis and must not be converted a second time.
|
||||||
|
|
||||||
|
On purchase lines, `Unit Cost` and `Unit Cost (LCY)` are derived from `Direct Unit Cost` with VAT removed, so they stay net. Service lines follow the same rule for `Unit Price` and `Line Amount`: they share the caption switch and the `UpdateVATAmounts` split of the sales line.
|
||||||
|
|
||||||
|
See sample: [`document-line-prices-follow-prices-including-vat.good.al`](document-line-prices-follow-prices-including-vat.good.al).
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Treating `Unit Price`, `Direct Unit Cost`, or `Line Amount` as net by default. Typical signals: summing `Line Amount` as a document's net total, computing VAT as `Line Amount * "VAT %" / 100`, putting a known net or gross external price straight into `Unit Price` or `Direct Unit Cost`, or comparing a line price with `Item."Unit Price"` or `Item."Last Direct Cost"` — all without reading the header's `Prices Including VAT`. For a gross-price customer at 19 % VAT, a net total built from `Line Amount` is 19 % too high, and a net price of 100 imported unconverted yields a net revenue of only 84.03.
|
||||||
|
|
||||||
|
Trusting a name instead of the source fields. The public procedure `CalculateOutstandingAmountExclTax` on `Sales Line` and `Purchase Line` returns `Line Amount` minus `Inv. Discount Amount` for the uninvoiced quantity, so its result includes VAT on a `Prices Including VAT` document despite its name. BaseApp only combines it with `Prepmt. Line Amount`, which has the same basis. Extension code that uses it as a net outstanding amount — compared with `Amount`, exported as net, or used to compute VAT — has this defect. The same applies to any variable or procedure named `ExclVAT`, `ExclTax`, or `Net` that is fed from a header-dependent field.
|
||||||
|
|
||||||
|
Do not report code that reads the header flag, uses only fixed-basis fields, or only combines fields of the same group (for example, prepayment amounts derived from `Line Amount`, or values copied between two lines of the same document).
|
||||||
|
|
||||||
|
See sample: [`document-line-prices-follow-prices-including-vat.bad.al`](document-line-prices-follow-prices-including-vat.bad.al).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [BCApps: Sales Header `Prices Including VAT` OnValidate recalculates line prices](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Document/SalesHeader.Table.al).
|
||||||
|
- [BCApps: Sales Line `UpdateVATAmounts`, `UpdatePrepmtAmounts`, and `CalculateOutstandingAmountExclTax`](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al).
|
||||||
|
- [BCApps: `Sales Line CaptionClass Mgmt` switches captions to Incl./Excl. VAT](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Document/SalesLineCaptionClassMgmt.Codeunit.al).
|
||||||
|
- [BCApps: Purchase Line `UpdateUnitCost` removes VAT from `Direct Unit Cost`](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Purchases/Document/PurchaseLine.Table.al).
|
||||||
|
- [BCApps: Service Line `GetCaptionClass` and `UpdateVATAmounts`](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Service/Document/ServiceLine.Table.al).
|
||||||
|
- [BCApps: `Price Calculation Buffer Mgt.` `ConvertAmountByTax`](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Pricing/Calculation/PriceCalculationBufferMgt.Codeunit.al).
|
||||||
|
|
@ -13,7 +13,7 @@ application-area: [all]
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`.
|
`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`. Upgrade code must not use `ChangeCompany` at all, see `no-changecompany-in-upgrade`.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,13 @@ codeunit 50211 "Perf Sample GetByPK Bad"
|
||||||
if Customer.FindFirst() then
|
if Customer.FindFirst() then
|
||||||
Message(Customer.Name);
|
Message(Customer.Name);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
procedure ShowUnblockedName(CustomerNo: Code[20])
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
Customer.SetRange(Blocked, Customer.Blocked::" ");
|
||||||
|
if Customer.Get(CustomerNo) then
|
||||||
|
Message(Customer.Name);
|
||||||
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,25 @@ codeunit 50210 "Perf Sample GetByPK Good"
|
||||||
if Customer.Get(CustomerNo) then
|
if Customer.Get(CustomerNo) then
|
||||||
Message(Customer.Name);
|
Message(Customer.Name);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
procedure ShowUnblockedName(CustomerNo: Code[20])
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
Customer.SetRange("No.", CustomerNo);
|
||||||
|
Customer.SetRange(Blocked, Customer.Blocked::" ");
|
||||||
|
if Customer.FindFirst() then
|
||||||
|
Message(Customer.Name);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure ShowUnblockedNameByKey(CustomerNo: Code[20])
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
if not Customer.Get(CustomerNo) then
|
||||||
|
exit;
|
||||||
|
if Customer.Blocked <> Customer.Blocked::" " then
|
||||||
|
exit;
|
||||||
|
Message(Customer.Name);
|
||||||
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,34 @@
|
||||||
---
|
---
|
||||||
bc-version: [all]
|
bc-version: [all]
|
||||||
domain: performance
|
domain: performance
|
||||||
keywords: [get, findfirst, primary-key, setrange, lookup]
|
keywords: [get, findfirst, primary-key, setrange, lookup, filters, blocked]
|
||||||
technologies: [al]
|
technologies: [al]
|
||||||
countries: [w1]
|
countries: [w1]
|
||||||
application-area: [all]
|
application-area: [all]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Use Get when the full primary key is known; FindFirst is the wrong tool
|
# Use Get for primary-key lookups without losing filter conditions
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
`Get(...)` is the direct primary-key lookup. `FindFirst()` walks an index — even when narrowed by `SetRange` on every primary-key field. The upstream review guidance treats `Customer.SetRange("No.", CustomerNo); if Customer.FindFirst() then ...` as a bad pattern and `if Customer.Get(CustomerNo) then ...` as the correction. The two reach the same record; only `Get` expresses the lookup as a primary-key seek.
|
`Get(...)` retrieves a record by primary key, but ignores normal record filters. Replacing a `FindFirst()` filtered only by the full primary key with `Get` expresses the lookup directly. The same replacement is not equivalent when additional filters enforce business conditions, such as requiring an unblocked customer. Security filters are a separate mechanism: their effect on `Get` depends on Security Filter Mode.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
When all primary-key fields are available at the call site, call `Get` (or `GetBySystemId`) with them. Reserve `FindFirst` for cases where the filter is on something other than the full primary key — a unique secondary field, a partial composite key, a sort that the caller cares about.
|
When all primary-key fields are available and no additional normal filter constrains the result, call `Get` with them. Reserve `FindFirst` for filtered searches, including partial keys, secondary fields, or additional business conditions.
|
||||||
|
|
||||||
|
Before recommending a replacement, inspect the effective filters at the call site, including filters set by callers or helpers. If additional conditions matter, retain the filtered `FindFirst` or explicitly enforce equivalent conditions after a successful `Get`, before using the record. Do not flag `FindFirst` merely because all primary-key fields are filtered when a non-key filter must also hold. A `SetRange` before `Get` does not enforce that condition.
|
||||||
|
|
||||||
See sample: [`use-get-instead-of-findfirst-on-full-primary-key.good.al`](use-get-instead-of-findfirst-on-full-primary-key.good.al).
|
See sample: [`use-get-instead-of-findfirst-on-full-primary-key.good.al`](use-get-instead-of-findfirst-on-full-primary-key.good.al).
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Composing `SetRange` calls that exactly cover the primary key and then calling `FindFirst`. The result is correct but the call site reads as "search the table" rather than "look up by key", which obscures both the intent and the access pattern from later reviewers.
|
Composing `SetRange` calls that cover only the full primary key and then calling `FindFirst` obscures a direct key lookup. Do not extend this finding to a lookup with additional business filters unless the proposed correction preserves them.
|
||||||
|
|
||||||
|
Replacing a filtered lookup with `Get` while assuming a normal filter still excludes records is a correctness defect: a blocked or otherwise ineligible record can pass the lookup. Recommending that replacement without preserving the condition is also an incorrect review finding.
|
||||||
|
|
||||||
See sample: [`use-get-instead-of-findfirst-on-full-primary-key.bad.al`](use-get-instead-of-findfirst-on-full-primary-key.bad.al).
|
See sample: [`use-get-instead-of-findfirst-on-full-primary-key.bad.al`](use-get-instead-of-findfirst-on-full-primary-key.bad.al).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Record.Get remarks: primary-key lookup and filter semantics](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-get-method).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
enum 50700 "Sample Request Status"
|
||||||
|
{
|
||||||
|
Extensible = false;
|
||||||
|
|
||||||
|
value(0; New) { Caption = 'New'; }
|
||||||
|
value(1; "Needs Review") { Caption = 'Needs Review'; }
|
||||||
|
value(2; Approved) { Caption = 'Approved'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
table 50700 "Sample Request"
|
||||||
|
{
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20]) { }
|
||||||
|
field(2; Status; Enum "Sample Request Status") { }
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.") { Clustered = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page 50700 "Sample Request Card"
|
||||||
|
{
|
||||||
|
PageType = Card;
|
||||||
|
SourceTable = "Sample Request";
|
||||||
|
ApplicationArea = All;
|
||||||
|
|
||||||
|
layout
|
||||||
|
{
|
||||||
|
area(Content)
|
||||||
|
{
|
||||||
|
field("No."; Rec."No.") { }
|
||||||
|
field(Status; Rec.Status) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actions
|
||||||
|
{
|
||||||
|
area(Processing)
|
||||||
|
{
|
||||||
|
action(Approve)
|
||||||
|
{
|
||||||
|
Caption = 'Approve';
|
||||||
|
// AL0573: InListExpression is not valid for client expressions.
|
||||||
|
Enabled = Rec.Status in [Rec.Status::New, Rec.Status::"Needs Review"];
|
||||||
|
|
||||||
|
trigger OnAction()
|
||||||
|
begin
|
||||||
|
Rec.Status := Rec.Status::Approved;
|
||||||
|
Rec.Modify(true);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
enum 50700 "Sample Request Status"
|
||||||
|
{
|
||||||
|
Extensible = false;
|
||||||
|
|
||||||
|
value(0; New) { Caption = 'New'; }
|
||||||
|
value(1; "Needs Review") { Caption = 'Needs Review'; }
|
||||||
|
value(2; Approved) { Caption = 'Approved'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
table 50700 "Sample Request"
|
||||||
|
{
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20]) { }
|
||||||
|
field(2; Status; Enum "Sample Request Status") { }
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.") { Clustered = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page 50700 "Sample Request Card"
|
||||||
|
{
|
||||||
|
PageType = Card;
|
||||||
|
SourceTable = "Sample Request";
|
||||||
|
ApplicationArea = All;
|
||||||
|
|
||||||
|
layout
|
||||||
|
{
|
||||||
|
area(Content)
|
||||||
|
{
|
||||||
|
field("No."; Rec."No.")
|
||||||
|
{
|
||||||
|
// A plain field comparison is a valid client expression.
|
||||||
|
Editable = Rec.Status = Rec.Status::New;
|
||||||
|
}
|
||||||
|
field(Status; Rec.Status)
|
||||||
|
{
|
||||||
|
trigger OnValidate()
|
||||||
|
begin
|
||||||
|
UpdateActionStates();
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actions
|
||||||
|
{
|
||||||
|
area(Processing)
|
||||||
|
{
|
||||||
|
action(Approve)
|
||||||
|
{
|
||||||
|
Caption = 'Approve';
|
||||||
|
// The list membership is computed in AL and exposed as a global Boolean.
|
||||||
|
Enabled = ApproveEnabled;
|
||||||
|
|
||||||
|
trigger OnAction()
|
||||||
|
begin
|
||||||
|
Rec.Status := Rec.Status::Approved;
|
||||||
|
Rec.Modify(true);
|
||||||
|
UpdateActionStates();
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var
|
||||||
|
ApproveEnabled: Boolean;
|
||||||
|
|
||||||
|
trigger OnAfterGetCurrRecord()
|
||||||
|
begin
|
||||||
|
UpdateActionStates();
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure UpdateActionStates()
|
||||||
|
begin
|
||||||
|
ApproveEnabled := Rec.Status in [Rec.Status::New, Rec.Status::"Needs Review"];
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: ui
|
||||||
|
keywords: [client-expression, in-list, inlistexpression, al0573, al0322, enabled, visible, editable, dynamic-enable]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Page client-expression properties must not use an `in [...]` list
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`Enabled`, `Visible`, `Editable`, and `StyleExpr` on page controls (the list is not exhaustive; `HideValue` is rejected the same way) can be bound to a client expression instead of a literal. The documented dynamic forms are a global Boolean page variable, a Boolean field, or a Boolean expression over fields such as `"Credit Limit" > "Sales YTD"`; plain `=`/`<>`/`>` comparisons combined with `and`/`or`/`not` are valid. An `in [...]` set-membership test, such as `Rec.Status in [Rec.Status::New, Rec.Status::"Needs Review"]`, is not: the compiler reports it as "InListExpression is not valid for client expressions. Client expressions can only use simple data types and field references."
|
||||||
|
|
||||||
|
The severity depends on the control. On a page field the diagnostic is already an error (AL0322). On an action, group, or part it is AL0573, a warning that "will become an error in a future release", so the code still builds and is easy to ship, suppress in a ruleset, or carry forward. A procedure call in the same property position is rejected by the same diagnostics, so moving the list test into a method called from the property does not fix it.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Express the condition in a form a client expression accepts. For a short list, rewrite the membership as an `or` chain of field comparisons, which keeps the property a live client expression. For a longer or computed condition, evaluate it in AL (an `in [...]` list is fine there), store the result in a global page `Boolean` variable, and bind the property to that variable. Recompute the variable wherever its inputs change: `OnAfterGetCurrRecord` when the current record changes, and the `OnValidate` of each page field the condition reads for in-place edits. Do not use `OnAfterGetRecord` for action, group, or part state: it runs once per row loaded, so on a List or Worksheet page the variable ends up reflecting the last fetched row rather than the selected one (see [OnAfterGetCurrRecord is not per row](../performance/onaftergetcurrrecord-is-not-per-row.md)). `OnAfterGetRecord` is right only for per-row field state inside a repeater.
|
||||||
|
|
||||||
|
For `Visible` on field and action controls, the Visible property documentation requires the variable to be resolved in `OnInit` or `OnOpenPage`; do not rely on per-record recomputation to show and hide those controls. `Enabled` and `Editable` have no such restriction. See sample: [`page-client-expression-must-not-use-in-list.good.al`](page-client-expression-must-not-use-in-list.good.al).
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
A page or pageextension control property `Enabled`, `Visible`, `Editable`, or `StyleExpr` whose value contains `in [`, typically an enum or option field tested against several values. Reviewer signal: the `in [` token appears directly in the property value rather than inside a trigger or procedure body. Replacing it with a call to a procedure that performs the same test is the same defect in a different shape.
|
||||||
|
|
||||||
|
Do not flag plain comparisons joined with `and`/`or`, such as `Enabled = (Rec.Status = Rec.Status::New) or (Rec.Status = Rec.Status::"Needs Review");`; they compile cleanly and Microsoft uses them, for example in BCApps `AccountantExpenseReports.Page.al` and `AgentTaskLogEntry.Page.al`. Do not flag `in [...]` used inside procedures or triggers that assign a Boolean variable. See sample: [`page-client-expression-must-not-use-in-list.bad.al`](page-client-expression-must-not-use-in-list.bad.al).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Enabled property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-enabled-property): dynamic values are a Boolean variable, a Boolean field, or a Boolean expression such as "Credit Limit > Sales YTD"; variables must be global page variables.
|
||||||
|
- [OnAfterGetCurrRecord (Page) trigger](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/page/devenv-onaftergetcurrrecord-page-trigger): in a page with a repeater, called only when the current record is updated, after all `OnAfterGetRecord` calls for the rows.
|
||||||
|
- [Visible property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-visible-property): variables for field and action controls must be resolved by `OnInit` or `OnOpenPage`.
|
||||||
|
- [Compiler warning AL0573](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al573) and [compiler error AL0322](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al322). The Learn pages show only the `{0}` template; the compiler's message for this case is "InListExpression is not valid for client expressions. Client expressions can only use simple data types and field references." (AL compiler 30.0: AL0573 for action, group, and part properties; AL0322 for page field properties, including `HideValue`).
|
||||||
|
- Comparison-based client expressions in BCApps, for example `Enabled = Rec.Status <> Rec.Status::Running;` in [BCPTSetupCard.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Tools/Performance%20Toolkit/App/src/BCPTSetupCard.Page.al). BCApps contains no page client expression that uses an `in [...]` list.
|
||||||
|
- Global Boolean recomputed on current-record change in BCApps: [EDocumentLogs.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/src/Logging/EDocumentLogs.Page.al), a List page, binds `Enabled = IsExportEnabled;` and sets `IsExportEnabled` in `OnAfterGetCurrRecord`.
|
||||||
|
- Or-chain client expressions in BCApps: `Enabled = (Rec.Status = Rec.Status::"Pending Approval") or (Rec.Status = Rec.Status::"Interim Approved");` in [AccountantExpenseReports.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/AccountantExpenseReports.Page.al) and `Visible = (Rec.Type = Rec.Type::"Output Message Draft") or (Rec.Type = Rec.Type::"Output Message");` in [AgentTaskLogEntry.Page.al](https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Agent/Troubleshooting/AgentTaskLogEntry.Page.al).
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
pageextension 50710 "Sample Bus. Mgr. RC Ext" extends "Business Manager Role Center"
|
||||||
|
{
|
||||||
|
layout
|
||||||
|
{
|
||||||
|
addafter(Control16)
|
||||||
|
{
|
||||||
|
part(SampleMyCustomers; "My Customers")
|
||||||
|
{
|
||||||
|
ApplicationArea = Basic, Suite;
|
||||||
|
// AL0573: procedure calls are not valid for client expressions.
|
||||||
|
Visible = CanSeeMyCustomers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AL0569: a page of type Role Center cannot have procedures.
|
||||||
|
local procedure CanSeeMyCustomers(): Boolean
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
exit(Customer.ReadPermission());
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
pageextension 50710 "Sample Bus. Mgr. RC Ext" extends "Business Manager Role Center"
|
||||||
|
{
|
||||||
|
layout
|
||||||
|
{
|
||||||
|
addafter(Control16)
|
||||||
|
{
|
||||||
|
part(SampleMyCustomers; "My Customers")
|
||||||
|
{
|
||||||
|
ApplicationArea = Basic, Suite;
|
||||||
|
// Declarative permission gating: the part is removed for users
|
||||||
|
// without Read permission on Customer.
|
||||||
|
AccessByPermission = TableData Customer = R;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: ui
|
||||||
|
keywords: [accessbypermission, rolecenter, role-center, pageextension, client-expression, permission, al0569, al0573, al0378]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gate Role Center content by permission with AccessByPermission
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A page of type `RoleCenter` cannot have triggers (AL0378, error) or procedures (AL0569, warning that will become an error), and the AL compiler applies both rules to a pageextension whose target is a Role Center. That removes the usual way to show a control conditionally: compute a global Boolean in `OnOpenPage` and bind `Visible` to it. An easy-looking remediation of AL0378 is to delete the trigger and bind `Visible` or `Enabled` directly to a local procedure such as `CanSeeMyCustomers()`. That still builds, but with two future errors: AL0573 for the procedure call in a client expression and AL0569 for the procedure itself. Neither message names the alternative.
|
||||||
|
|
||||||
|
When the condition is "the user has permission to this object", the declarative alternative is the `AccessByPermission` property on the part, action, or field. It takes `TableData <table> = R|I|M|D` (any combination; having any one of the listed permissions is enough) or `X` for `Table`, `Page`, `Report`, `Codeunit`, `XmlPort`, or `Query`. Its applies-to list covers page fields, parts, system parts, chart parts, actions, and whole pages and reports; it does not include groups or cue groups. The element is removed for users without the permission, not disabled. That per-user removal requires the UI Elements Removal setting `LicenseFileAndUserPermissions`; under `LicenseFile` removal follows the license, not the user's permission sets.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Set `AccessByPermission` on the Role Center part, action, or field that should appear only for users with the permission, naming the table or object that the part actually depends on. The base application does this on its own Role Centers, for example `AccessByPermission = TableData "Activities Cue" = I` on the activities part of the Business Manager Role Center and `TableData "Report Inbox" = IMD` on the Report Inbox part (`Control96`) of the same Role Center and of the Accountant Role Center.
|
||||||
|
|
||||||
|
Two limits apply. The property takes effect only when the server's UI Elements Removal setting is `LicenseFile` or `LicenseFileAndUserPermissions`. It is UI removal, not a security boundary: the part's source data must still be protected by real permissions. When the condition is not a permission check, such as a setup value or a feature flag, `AccessByPermission` is the wrong tool. Put the condition inside the part page, which is a normal `CardPart` or `ListPart` that can have triggers. The part cannot remove itself from the Role Center, but it can hide or empty its own controls. See sample: [`rolecenter-permission-gating-must-use-accessbypermission.good.al`](rolecenter-permission-gating-must-use-accessbypermission.good.al).
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
A `RoleCenter` page, or a pageextension whose target is a Role Center, binds `Visible` or `Enabled` on a part, action, or field to a procedure call whose body checks `ReadPermission`, `WritePermission`, or a similar permission test, and declares that procedure. The compiler reports AL0573 and AL0569 as warnings, and both will become errors. Reviewer signal: a pageextension declares a procedure and AL0569 appears in its build output. The extended page's name is not reliable evidence of its type, so confirm the target is a Role Center from its `PageType` or from that diagnostic. Do not flag setup- or feature-based gating implemented inside the part page itself; that is the correct location for non-permission conditions. See sample: [`rolecenter-permission-gating-must-use-accessbypermission.bad.al`](rolecenter-permission-gating-must-use-accessbypermission.bad.al).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [AccessByPermission property](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-accessbypermission-property): applies-to list, permission values, any-one-of semantics, and the UI Elements Removal requirement ([Hide UI elements](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/administration/hide-ui-elements)).
|
||||||
|
- [Compiler error AL0378](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al378), [compiler warning AL0569](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al569), and [compiler warning AL0573](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al573). AL compiler 30.0 reports all three on a pageextension of a Role Center, as it does on the Role Center page itself.
|
||||||
|
- Base application usage: [BusinessManagerRoleCenter.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Finance/RoleCenters/BusinessManagerRoleCenter.Page.al) (`Control96`, lines 124-127) and [AccountantRoleCenter.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Finance/RoleCenters/AccountantRoleCenter.Page.al). No Role Center page in BCApps declares a trigger or procedure.
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
table 50263 "Sales Order Ext"
|
||||||
|
{
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20]) { }
|
||||||
|
field(2; "Shipping Agent Code"; Code[10]) { TableRelation = "Shipping Agent"; }
|
||||||
|
field(3; "Legacy Carrier Code"; Code[10]) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.") { Clustered = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50261 "Upgrade All Companies"
|
||||||
|
{
|
||||||
|
Subtype = Upgrade;
|
||||||
|
|
||||||
|
trigger OnUpgradePerDatabase()
|
||||||
|
var
|
||||||
|
Company: Record Company;
|
||||||
|
SalesOrderExt: Record "Sales Order Ext";
|
||||||
|
begin
|
||||||
|
// Reaches into every company from one session. Each company also has its own
|
||||||
|
// upgrade session, triggers and subscribers run in the calling context, and a
|
||||||
|
// data error in any company aborts the whole upgrade.
|
||||||
|
if Company.FindSet() then
|
||||||
|
repeat
|
||||||
|
SalesOrderExt.ChangeCompany(Company.Name);
|
||||||
|
SalesOrderExt.SetRange("Shipping Agent Code", '');
|
||||||
|
if SalesOrderExt.FindSet(true) then
|
||||||
|
repeat
|
||||||
|
SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code");
|
||||||
|
SalesOrderExt.Modify(true);
|
||||||
|
until SalesOrderExt.Next() = 0;
|
||||||
|
until Company.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
table 50262 "Sales Order Ext"
|
||||||
|
{
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20]) { }
|
||||||
|
field(2; "Shipping Agent Code"; Code[10]) { TableRelation = "Shipping Agent"; }
|
||||||
|
field(3; "Legacy Carrier Code"; Code[10]) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.") { Clustered = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50260 "Upgrade Current Company"
|
||||||
|
{
|
||||||
|
Subtype = Upgrade;
|
||||||
|
|
||||||
|
// The platform runs this trigger once per company, in that company's own session.
|
||||||
|
trigger OnUpgradePerCompany()
|
||||||
|
begin
|
||||||
|
UpgradeShippingAgentCodes();
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure UpgradeShippingAgentCodes()
|
||||||
|
var
|
||||||
|
SalesOrderExt: Record "Sales Order Ext";
|
||||||
|
UpgradeTag: Codeunit "Upgrade Tag";
|
||||||
|
begin
|
||||||
|
if UpgradeTag.HasUpgradeTag(ShippingAgentUpgradeTag()) then
|
||||||
|
exit;
|
||||||
|
|
||||||
|
// Only the current company's rows; triggers, events, and the tag all apply here.
|
||||||
|
SalesOrderExt.SetRange("Shipping Agent Code", '');
|
||||||
|
if SalesOrderExt.FindSet(true) then
|
||||||
|
repeat
|
||||||
|
SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code");
|
||||||
|
SalesOrderExt.Modify(true);
|
||||||
|
until SalesOrderExt.Next() = 0;
|
||||||
|
|
||||||
|
UpgradeTag.SetUpgradeTag(ShippingAgentUpgradeTag());
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure ShippingAgentUpgradeTag(): Code[250]
|
||||||
|
begin
|
||||||
|
exit('CONTOSO-1001-ShippingAgentCode-20260101');
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50264 "Shipping Agent Feat. Data Upd." implements "Feature Data Update"
|
||||||
|
{
|
||||||
|
// Read-only preflight: counting rows across companies to report scope is allowed.
|
||||||
|
procedure IsDataUpdateRequired(): Boolean
|
||||||
|
var
|
||||||
|
Company: Record Company;
|
||||||
|
SalesOrderExt: Record "Sales Order Ext";
|
||||||
|
begin
|
||||||
|
if Company.FindSet() then
|
||||||
|
repeat
|
||||||
|
SalesOrderExt.ChangeCompany(Company.Name);
|
||||||
|
SalesOrderExt.SetRange("Shipping Agent Code", '');
|
||||||
|
if not SalesOrderExt.IsEmpty() then
|
||||||
|
exit(true);
|
||||||
|
until Company.Next() = 0;
|
||||||
|
exit(false);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure ReviewData()
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Feature Management runs this once per company, in that company.
|
||||||
|
procedure UpdateData(FeatureDataUpdateStatus: Record "Feature Data Update Status")
|
||||||
|
var
|
||||||
|
SalesOrderExt: Record "Sales Order Ext";
|
||||||
|
begin
|
||||||
|
SalesOrderExt.SetRange("Shipping Agent Code", '');
|
||||||
|
if SalesOrderExt.FindSet(true) then
|
||||||
|
repeat
|
||||||
|
SalesOrderExt.Validate("Shipping Agent Code", SalesOrderExt."Legacy Carrier Code");
|
||||||
|
SalesOrderExt.Modify(true);
|
||||||
|
until SalesOrderExt.Next() = 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure AfterUpdate(FeatureDataUpdateStatus: Record "Feature Data Update Status")
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure GetTaskDescription(): Text
|
||||||
|
begin
|
||||||
|
exit('Copies legacy carrier codes to the shipping agent code.');
|
||||||
|
end;
|
||||||
|
}
|
||||||
36
microsoft/knowledge/upgrade/no-changecompany-in-upgrade.md
Normal file
36
microsoft/knowledge/upgrade/no-changecompany-in-upgrade.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: upgrade
|
||||||
|
keywords: [changecompany, cross-company, onupgradepercompany, onupgradeperdatabase, feature-data-update, taskscheduler, multi-company, company-context]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Upgrade code must not use ChangeCompany
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The platform upgrades each company in its own context: `OnUpgradePerCompany` (like the other `PerCompany` upgrade and install triggers) runs once per company, in a separate system session opened for that company, while `PerDatabase` triggers run in a session that opens no company. Calling `ChangeCompany(<name>)` in upgrade code reaches from the company being upgraded into another company. Cross-company work bypasses the platform's per-company execution boundary, making execution order and migration ownership difficult to reason about and potentially repeating work. A cross-company read cannot assume that the other company's migration has already run, so it can observe pre-upgrade or post-upgrade data. Per-company upgrade tags are set for the current company only, so a tag set after writing to company B is recorded for company A, which can cause B's own session to repeat the migration. Execution context does not move with `ChangeCompany`: table triggers and trigger-event subscribers still run in the calling company (see `changecompany-runs-triggers-in-the-calling-company`). Data and side effects then land in different companies, and an error in another company's data aborts the current company's upgrade. The same applies to the migration path of a feature-switch data update: the `UpdateData` and `AfterUpdate` methods of a `Feature Data Update` implementation. Feature Management runs them for one company's status row, either as a task scheduled in that company or in the current session. The interface's preflight methods, `IsDataUpdateRequired` and `ReviewData`, are a separate phase that reports scope before any update is scheduled.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Upgrade code operates only on the company it is running in. Put per-company data migration in `OnUpgradePerCompany` (or a helper reachable only from it), guard it with a per-company upgrade tag, and let the platform invoke it for every company. Reserve `OnUpgradePerDatabase` for tables with `DataPerCompany = false` and other database-wide state; it needs no `ChangeCompany`. A feature data update implements `UpdateData` and `AfterUpdate` against the current company only. Its read-only preflight may use `ChangeCompany` to count or inspect rows in other companies, because it migrates nothing. When code outside the upgrade pipeline must start work in other companies, schedule it in each company with `TaskScheduler.CreateTask` and the company name, as Feature Management does, rather than writing there through `ChangeCompany`. The scheduled task runs in the target company, so triggers, events, permissions, and upgrade tags all use that company.
|
||||||
|
|
||||||
|
See sample: [`no-changecompany-in-upgrade.good.al`](no-changecompany-in-upgrade.good.al).
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
A loop over the `Company` table in `OnUpgradePerDatabase` or `OnUpgradePerCompany` that calls `ChangeCompany(Company.Name)` on a record or `RecordRef` and then reads, inserts, modifies, or deletes data. Another form is a `Feature Data Update` implementation whose `UpdateData` or `AfterUpdate` does the same to update every company from one task. On these paths reads are not exempt: they observe another company whose upgrade state is unknown.
|
||||||
|
|
||||||
|
Detection signal: `Record.ChangeCompany(<name>)` or `RecordRef.ChangeCompany(<name>)` with a company-name argument in a codeunit with `Subtype = Upgrade` or in any procedure transitively reachable from its upgrade triggers, or in a `Feature Data Update` implementation's `UpdateData` or `AfterUpdate` or any procedure transitively reachable from them. Do not flag `ChangeCompany` in `IsDataUpdateRequired`, `ReviewData`, or helpers reachable only from them; a helper shared with `UpdateData` or `AfterUpdate` is in scope. Do not flag the parameterless `ChangeCompany()`, which only points the variable back at the current company, or `ChangeCompany` in ordinary runtime code; `changecompany-runs-triggers-in-the-calling-company` governs that.
|
||||||
|
|
||||||
|
See sample: [`no-changecompany-in-upgrade.bad.al`](no-changecompany-in-upgrade.bad.al).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Upgrading extensions, Upgrade triggers: `PerCompany` triggers run once per company, each in its own system session for that company — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-upgrading-extensions
|
||||||
|
- Record.ChangeCompany method, Remarks: triggers still run in the current company — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-changecompany-method
|
||||||
|
- Interface "Feature Data Update" — https://learn.microsoft.com/en-us/dynamics365/business-central/application/system-application/interface/system.environment.configuration.feature-data-update
|
||||||
|
- `FeatureManagementImpl.UpdateData` calls the interface's `UpdateData` then `AfterUpdate`; `ReviewData` calls `IsDataUpdateRequired` and `ReviewData` — https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Feature%20Key/src/FeatureManagementImpl.Codeunit.al
|
||||||
|
- `FeatureManagementImpl.CreateTask` schedules `Update Feature Data` with `TaskScheduler.CreateTask` for the status row's company — https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Feature%20Key/src/FeatureManagementImpl.Codeunit.al
|
||||||
|
|
@ -71,6 +71,22 @@ Sub-skills that fail either check are not invoked and are recorded in `skipped-s
|
||||||
|
|
||||||
The worklist is the list of sub-skills judged relevant by the previous step. Every sub-skill in the worklist will be invoked in the Action step.
|
The worklist is the list of sub-skills judged relevant by the previous step. Every sub-skill in the worklist will be invoked in the Action step.
|
||||||
|
|
||||||
|
Before dispatch, preserve that selection in a private host-owned expected
|
||||||
|
composition artifact using DO's input contract. Start from the resolver's
|
||||||
|
ordered selection; enrich configuration skips with the declared indexed
|
||||||
|
skill's version, and record any input-incompatible exclusions with
|
||||||
|
`reason: "not-applicable"`. Do not derive this artifact from leaf reports or
|
||||||
|
change it merely because execution later runs out of budget. Pass its path as
|
||||||
|
`-ExpectedCompositionPath` for final super-skill validation.
|
||||||
|
Initialize `acceptedResults` to `[]`. After each leaf's acceptance gate, the
|
||||||
|
host saves its exact accepted copy (or host-created failed validation result)
|
||||||
|
in an immutable private file and appends its `id`, `version`, and `reportPath`
|
||||||
|
to that array. The host alone owns these captures; neither workers nor the
|
||||||
|
composing model may write them. Never derive them from composed `sub-results`.
|
||||||
|
Keep the pre-dispatch selection and exclusions unchanged. Final validation
|
||||||
|
requires every nested leaf to match its captured JSON content exactly and
|
||||||
|
every captured result to be included. Property order is immaterial.
|
||||||
|
|
||||||
## Action
|
## Action
|
||||||
|
|
||||||
### Execution discipline (mandatory)
|
### Execution discipline (mandatory)
|
||||||
|
|
@ -85,7 +101,7 @@ The Action step consists of **discrete leaf invocations**, not one combined gene
|
||||||
- Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches).
|
- Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches).
|
||||||
- The agent self-review pass is its own final iteration. Begin it only after every sub-skill in the worklist has completed and its sub-result is recorded.
|
- The agent self-review pass is its own final iteration. Begin it only after every sub-skill in the worklist has completed and its sub-result is recorded.
|
||||||
- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this — `sub-results` carries one entry per sub-skill, each a complete findings-report, in the frontmatter `sub-skills` order regardless of completion order.
|
- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this — `sub-results` carries one entry per sub-skill, each a complete findings-report, in the frontmatter `sub-skills` order regardless of completion order.
|
||||||
- When isolated calls are unavailable and the current model cannot finish every leaf within its budget, return `partial` with completed `sub-results` and name the first unevaluated sub-skill in `outcome-reason`. Never silently mark the remaining leaves clean.
|
- When the execution budget prevents invoking every selected leaf, wait for started invocations to finish and preserve their accepted `sub-results`. Return `partial` if any returned report is non-`failed`, otherwise `failed`, and name the unfinished leaf IDs in `outcome-reason`. Do not run the self-review on incomplete composition, invent sub-results, or record budget exhaustion as a configured/input-incompatible skip. Never silently mark the remaining leaves clean.
|
||||||
|
|
||||||
### Roll up sub-skill findings
|
### Roll up sub-skill findings
|
||||||
|
|
||||||
|
|
@ -138,7 +154,9 @@ Calculate `summary.counts` from the final top-level `findings[]`, after failed s
|
||||||
Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."*
|
Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."*
|
||||||
|
|
||||||
Before emitting the rollup, apply DO's consumer acceptance gate to every nested
|
Before emitting the rollup, apply DO's consumer acceptance gate to every nested
|
||||||
and top-level finding. A leaf's nested report is its accepted exact return or
|
and top-level finding, and validate the final report with
|
||||||
|
`-SkillKind super -ExpectedCompositionPath <host-owned-json>` against the
|
||||||
|
selection preserved before dispatch. A leaf's nested report is its accepted exact return or
|
||||||
its accepted normalized candidate copy; its exact Task return remains the
|
its accepted normalized candidate copy; its exact Task return remains the
|
||||||
separate immutable raw audit payload. Treat an invalid sub-result as failed and
|
separate immutable raw audit payload. Treat an invalid sub-result as failed and
|
||||||
exclude all of its findings from the top-level rollup. Never reconstruct it
|
exclude all of its findings from the top-level rollup. Never reconstruct it
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
|
||||||
|
|
||||||
An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, document print/email/Post-and-Send actions, `Navigate` page subscribers, Report Selection registration or dispatch, price-calculation/price-source extensibility, `TransferFields`-based posting-cascade field mirroring, `TableRelation` field-length design, barcode/report-layout font-provider usage, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. The skill returns `not-applicable` when none of those apply.
|
An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, document print/email/Post-and-Send actions, `Navigate` page subscribers, Report Selection registration or dispatch, price-calculation/price-source extensibility, code that reads or writes sales/purchase/service line price and amount fields, `TransferFields`-based posting-cascade field mirroring, `TableRelation` field-length design, barcode/report-layout font-provider usage, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. The skill returns `not-applicable` when none of those apply.
|
||||||
|
|
||||||
## Source
|
## Source
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review
|
||||||
|
|
||||||
- The changed AL object names and types — especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, document or journal lines that reference a master, document pages/codeunits exposing print/email/Post-and-Send actions, codeunits subscribing to `Navigate`, enumextensions to `"Report Selection Usage"`/`"Price Calculation Handler"`/`"Price Source Type"`, and report objects that render barcodes.
|
- The changed AL object names and types — especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, document or journal lines that reference a master, document pages/codeunits exposing print/email/Post-and-Send actions, codeunits subscribing to `Navigate`, enumextensions to `"Report Selection Usage"`/`"Price Calculation Handler"`/`"Price Source Type"`, and report objects that render barcodes.
|
||||||
- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, posting validation, and posting-cascade `TransferFields` calls.
|
- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, posting validation, and posting-cascade `TransferFields` calls.
|
||||||
- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `ValidateTableRelation`, `TestTableRelation`, `Text[`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`).
|
- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `ValidateTableRelation`, `TestTableRelation`, `Text[`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Prices Including VAT`, `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Prepmt. Line Amount`, `Amount Including VAT`, `CalculateOutstandingAmountExclTax`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`).
|
||||||
|
|
||||||
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
|
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
|
||||||
|
|
||||||
|
|
@ -67,6 +67,7 @@ The following targeted checks cover every current `data-modeling` article. Treat
|
||||||
- An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Method`/`Type`/`Asset Type` — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. `Default := true` is only required on that row when it is meant as the fallback for its `Method`/`Type`/`Asset Type` combination; a row meant to be selected only through an explicit, specific `"Dtld. Price Calculation Setup"` row does not need it, so do not flag a missing `Default := true` by itself — flag the missing setup row/subscriber entirely.
|
- An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Method`/`Type`/`Asset Type` — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. `Default := true` is only required on that row when it is meant as the fallback for its `Method`/`Type`/`Asset Type` combination; a row meant to be selected only through an explicit, specific `"Dtld. Price Calculation Setup"` row does not need it, so do not flag a missing `Default := true` by itself — flag the missing setup row/subscriber entirely.
|
||||||
- An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`.
|
- An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`.
|
||||||
- A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that triggers recalculation — either `SalesLine.UpdateUnitPrice(<field no.>)`, or the explicit `SalesLine.PlanPriceCalcByField(<field no.>)` followed by `SalesLine.UpdateUnitPriceByField(<same field no.>)`. A bare `UpdateUnitPriceByField` without a preceding `PlanPriceCalcByField` for the same field number does not count as recalculation (it exits without recalculating) — `new-price-source-must-add-candidate-and-trigger-recalculation`.
|
- A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that triggers recalculation — either `SalesLine.UpdateUnitPrice(<field no.>)`, or the explicit `SalesLine.PlanPriceCalcByField(<field no.>)` followed by `SalesLine.UpdateUnitPriceByField(<same field no.>)`. A bare `UpdateUnitPriceByField` without a preceding `PlanPriceCalcByField` for the same field number does not count as recalculation (it exits without recalculating) — `new-price-source-must-add-candidate-and-trigger-recalculation`.
|
||||||
|
- Code reads `Unit Price`, `Direct Unit Cost`, `Line Amount`, `Line Discount Amount`, `Inv. Discount Amount`, `Prepmt. Line Amount`, `Prepmt. Amt. Inv.`, `Prepmt Amt to Deduct`, `Prepmt Amt Deducted`, or the result of `CalculateOutstandingAmountExclTax` of a `Sales Line`/`Purchase Line`/`Service Line` as a known net or gross value (a net/gross total, a VAT computation, a comparison with `Amount` or `Item."Unit Price"`/`"Last Direct Cost"`, an export), or writes a source price of known basis into `Unit Price`/`Direct Unit Cost`, without reading the document header's `Prices Including VAT` — `document-line-prices-follow-prices-including-vat`. Reads of fixed-basis fields (`Amount`, `Amount Including VAT`, `Prepayment Amount`, `Prepmt. Amt. Incl. VAT`), combinations of header-dependent fields with each other, prices returned by the standard price calculation, and copies between lines of the same document are not this anti-pattern.
|
||||||
- A report hand-constructs a barcode string only where a concrete, independently provable defect is visible: the source value can contain characters outside the symbology's character set and is never validated, a checksum the symbology/setup requires is never applied, or there is concrete evidence of an incompatible font binding. Do not flag manual start/stop delimiters by themselves — `*value*` is a documented, valid Code 39 form for IDAutomation fonts (IDAutomation also accepts parentheses), so delimiter choice alone is never a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`.
|
- A report hand-constructs a barcode string only where a concrete, independently provable defect is visible: the source value can contain characters outside the symbology's character set and is never validated, a checksum the symbology/setup requires is never applied, or there is concrete evidence of an incompatible font binding. Do not flag manual start/stop delimiters by themselves — `*value*` is a documented, valid Code 39 form for IDAutomation fonts (IDAutomation also accepts parentheses), so delimiter choice alone is never a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`.
|
||||||
- A new field is typed `Code`/`Text` and its `OnValidate` calls `DimensionManagement`/`DimMgt`, or a table adds Shortcut Dimension fields, a `Dimension Set ID` field, or `AddDimSource`/`GetDefaultDimID` — `dimension-management-wiring`. A master table calling `SaveDefaultDim` and a document/journal table computing its own `Dimension Set ID` are two different valid shapes; do not flag a master table for lacking a `Dimension Set ID` field or a document for lacking `SaveDefaultDim`.
|
- A 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.
|
- 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.
|
||||||
|
|
@ -101,7 +102,7 @@ Outcome selection:
|
||||||
|
|
||||||
- `completed` — the skill evaluated every worklist item.
|
- `completed` — the skill evaluated every worklist item.
|
||||||
- `no-knowledge` — no applicable data-modeling knowledge survived filtering.
|
- `no-knowledge` — no applicable data-modeling knowledge survived filtering.
|
||||||
- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface, and no document print/email/Post-and-Send action, `Navigate` subscriber, Report Selection registration/dispatch, price-calculation/price-source extensibility point, posting-cascade `TransferFields` mirroring, `TableRelation` field length, barcode/report-font-provider usage, dimension wiring, posting-routine structure, or Item-Ledger-Entry-document-number surface.
|
- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface, and no document print/email/Post-and-Send action, `Navigate` subscriber, Report Selection registration/dispatch, price-calculation/price-source extensibility point, sales/purchase/service line price or amount read/write, posting-cascade `TransferFields` mirroring, `TableRelation` field length, barcode/report-font-provider usage, dimension wiring, posting-routine structure, or Item-Ledger-Entry-document-number surface.
|
||||||
- `partial` — a budget was hit before the worklist was exhausted.
|
- `partial` — a budget was hit before the worklist was exhausted.
|
||||||
- `failed` — an unrecoverable error occurred.
|
- `failed` — an unrecoverable error occurred.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ Narrow the relevant files to the subset that applies to the changes under review
|
||||||
- **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files.
|
- **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.
|
- A new or changed page's name/suffix, primary-key handling, `CardPageID`, `SubPageLink`, `AutoSplitKey`, or `UsageCategory` doesn't match the conventions of its own declared `PageType` — `page-design-must-match-bc-page-type-conventions.md`. A Card page over a composite-key table that supplements a master record, or a supporting/subpage/dialog page intended only to be reached through another workflow and correctly omitting `UsageCategory`, is not this anti-pattern on its own; check whether the page is actually mixing conventions or is meant as a searchable entry point before flagging.
|
||||||
- For each relevant knowledge file, compute overlap against changed page declarations and control add-in files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, promoted action definitions, field importance, page background tasks, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL.
|
- For each relevant knowledge file, compute overlap against changed page declarations and control add-in files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, promoted action definitions, field importance, page background tasks, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL.
|
||||||
- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `fieldgroups`, `DropDown`, `UpdatePropagation`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions, `CardPageID`, `AutoSplitKey`, `PageType = Card`, `PageType = List`, `PageType = Worksheet`, `PageType = Document`).
|
- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `fieldgroups`, `DropDown`, `UpdatePropagation`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions, `CardPageID`, `AutoSplitKey`, `PageType = Card`, `PageType = List`, `PageType = Worksheet`, `PageType = Document`, `PageType = RoleCenter`, `Enabled`, `Visible`, `Editable`, `in [`, `AccessByPermission`, `ReadPermission`, `WritePermission`).
|
||||||
|
|
||||||
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed page element. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.
|
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed page element. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.
|
||||||
|
|
||||||
|
|
@ -50,6 +50,8 @@ Apply these high-signal mappings before fuzzy topic ranking:
|
||||||
|
|
||||||
- A tableextension adds a field to `DropDown` while the corresponding lookup-page control remains `Visible = false` — `dropdown-fieldgroup-respects-lookup-page-visibility`.
|
- A tableextension adds a field to `DropDown` while the corresponding lookup-page control remains `Visible = false` — `dropdown-fieldgroup-respects-lookup-page-visibility`.
|
||||||
- An editable page part affects a total, FlowField, or FactBox on the parent but does not set `UpdatePropagation = Both` — `updatepropagation-both-refreshes-main-page`.
|
- An editable page part affects a total, FlowField, or FactBox on the parent but does not set `UpdatePropagation = Both` — `updatepropagation-both-refreshes-main-page`.
|
||||||
|
- A page or pageextension `Enabled`, `Visible`, `Editable`, or `StyleExpr` value contains an `in [...]` list (compiler: "InListExpression is not valid for client expressions", AL0573 or AL0322), or replaces one with a procedure call — `page-client-expression-must-not-use-in-list`. Plain `=`/`<>` comparisons joined with `and`/`or`, and `in [...]` inside a trigger or procedure body, are valid; do not flag them.
|
||||||
|
- A `RoleCenter` page, or a pageextension whose target is a Role Center, gates a part, action, or field by binding `Visible` or `Enabled` to a procedure that tests a permission, or declares a procedure (AL0569 "A page of type Role Center cannot have procedures", AL0573) — `rolecenter-permission-gating-must-use-accessbypermission`. The target page name is not reliable evidence of its type; confirm it is a Role Center from its `PageType` or the AL0569 diagnostic. Setup- or feature-flag gating inside the part page is not this pattern.
|
||||||
|
|
||||||
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.
|
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ application-area: [all]
|
||||||
|
|
||||||
Reviews AL source changes against the `upgrade` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
|
Reviews AL source changes against the `upgrade` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
|
||||||
|
|
||||||
An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Upgrade findings are narrow by design — they apply when the review scope contains upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply.
|
An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Upgrade findings are narrow by design — they apply when the review scope contains upgrade codeunits, install codeunits, `Feature Data Update` implementations, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply.
|
||||||
|
|
||||||
## Source
|
## Source
|
||||||
|
|
||||||
|
|
@ -37,13 +37,14 @@ Discard files that are not applicable. Retain conditionally applicable files (an
|
||||||
|
|
||||||
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
|
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
|
||||||
|
|
||||||
- The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces.
|
- The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, codeunits implementing `Feature Data Update`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces.
|
||||||
- The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, and helper procedures transitively reachable from those entry points.
|
- The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, the `UpdateData`/`AfterUpdate` methods of `Feature Data Update` implementations, and helper procedures transitively reachable from those entry points.
|
||||||
- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `ModuleInfo`, `AppVersion`, `DataVersion`, `NavApp.GetCurrentModuleInfo`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`).
|
- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `ModuleInfo`, `AppVersion`, `DataVersion`, `NavApp.GetCurrentModuleInfo`, `ExecutionContext`, `ChangeCompany`, `Feature Data Update`, `UpdateData`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`).
|
||||||
- For each `OnCheckPreconditions...` and `OnValidateUpgrade...` trigger, build the best available call graph from surrounding unchanged source as well as changed hunks, tracing resolved calls through reachable local or internal helpers. Worklist the check-only rule when a database write occurs either directly in the trigger or in any helper procedure reachable from it. Writes include `Insert`, `Modify`, `ModifyAll`, `Delete`, `DeleteAll`, `Rename`, and `DataTransfer`. Also perform the reverse check when a PR changes a writing helper body: worklist the rule when that helper is invoked directly or transitively by an unchanged check or validation trigger.
|
- For each `OnCheckPreconditions...` and `OnValidateUpgrade...` trigger, build the best available call graph from surrounding unchanged source as well as changed hunks, tracing resolved calls through reachable local or internal helpers. Worklist the check-only rule when a database write occurs either directly in the trigger or in any helper procedure reachable from it. Writes include `Insert`, `Modify`, `ModifyAll`, `Delete`, `DeleteAll`, `Rename`, and `DataTransfer`. Also perform the reverse check when a PR changes a writing helper body: worklist the rule when that helper is invoked directly or transitively by an unchanged check or validation trigger.
|
||||||
- Treat a direct write or a fully resolved call chain as high-confidence evidence. When cross-object dispatch, unavailable declarations, or an incomplete call graph prevents proving the complete chain, cap confidence at `medium`, name the unresolved edge in the finding, and do not claim a violation without a resolved path from a check or validation trigger to a write.
|
- Treat a direct write or a fully resolved call chain as high-confidence evidence. When cross-object dispatch, unavailable declarations, or an incomplete call graph prevents proving the complete chain, cap confidence at `medium`, name the unresolved edge in the finding, and do not claim a violation without a resolved path from a check or validation trigger to a write.
|
||||||
- Worklist the install-versus-upgrade rule when migration helpers are reachable only from an install codeunit.
|
- Worklist the install-versus-upgrade rule when migration helpers are reachable only from an install codeunit.
|
||||||
- Worklist `install-and-upgrade-codeunits-have-no-order.md` when a change adds multiple install or upgrade codeunits whose same-phase triggers share state or depend on one another.
|
- Worklist `install-and-upgrade-codeunits-have-no-order.md` when a change adds multiple install or upgrade codeunits whose same-phase triggers share state or depend on one another.
|
||||||
|
- Worklist `no-changecompany-in-upgrade.md` when `ChangeCompany` with a company-name argument appears in an upgrade codeunit, in a helper reachable from its upgrade triggers, or in the `UpdateData` or `AfterUpdate` method of a `Feature Data Update` implementation or a helper reachable from them. Trace that reachability with the same call-graph and confidence rules as the check-only rule. Do not worklist it for `ChangeCompany` reachable only from `IsDataUpdateRequired` or `ReviewData`; that read-only preflight is permitted.
|
||||||
- Worklist `appversion-meaning-depends-on-execution-context.md` when install or upgrade code branches on `ModuleInfo.AppVersion()` or confuses it with `DataVersion()`.
|
- 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.
|
- An upgrade tag's existence check (`HasUpgradeTag`) is nested inside another tag's guarded body, or one tagged procedure performs two or more functionally unrelated migrations (different tables, fields, or concerns) under a single tag, or one procedure mixes the gated logic for more than one distinct upgrade tag — `upgrade-tag-logic-must-not-nest-deeply.md`. Do not flag record loops or business-data safety guards (corruption checks, redundant-write checks, or other conditions) that serve the single migration the tag represents, however many `if` levels they take — that is the compliant shape the article explicitly permits.
|
||||||
|
|
||||||
|
|
@ -77,7 +78,7 @@ Outcome selection:
|
||||||
|
|
||||||
- `completed` — the skill evaluated every worklist item.
|
- `completed` — the skill evaluated every worklist item.
|
||||||
- `no-knowledge` — no applicable upgrade knowledge survived filtering.
|
- `no-knowledge` — no applicable upgrade knowledge survived filtering.
|
||||||
- `not-applicable` — the diff touches no upgrade, install, schema, or enum surface.
|
- `not-applicable` — the diff touches no upgrade, install, feature data update, schema, or enum surface.
|
||||||
- `partial` — a budget was hit before the worklist was exhausted.
|
- `partial` — a budget was hit before the worklist was exhausted.
|
||||||
- `failed` — an unrecoverable error occurred.
|
- `failed` — an unrecoverable error occurred.
|
||||||
|
|
||||||
|
|
|
||||||
56
skills/do.md
56
skills/do.md
|
|
@ -224,7 +224,36 @@ as a findings-report, a coordinator or host MUST validate it deterministically:
|
||||||
|
|
||||||
Hosts SHOULD execute `tools/Validate-FindingsReport.ps1` with the exact source
|
Hosts SHOULD execute `tools/Validate-FindingsReport.ps1` with the exact source
|
||||||
scope and the leaf's recorded set of fully retrieved article paths. Pass
|
scope and the leaf's recorded set of fully retrieved article paths. Pass
|
||||||
`-SkillKind super` when validating a super-skill's rolled-up report. Pass
|
`-SkillKind super -ExpectedCompositionPath <host-owned-json>` when validating
|
||||||
|
a super-skill's rolled-up report. Prepare that private artifact before leaf
|
||||||
|
dispatch, after layer resolution and input compatibility checks. It contains
|
||||||
|
`superSkill` (`id`, `version`), ordered selected `subSkills` (each with `id`,
|
||||||
|
`version`), `skipped` (each with `id`, `version`, `reason`), and an initially
|
||||||
|
empty `acceptedResults` array. Reasons are `configuration` or `not-applicable`;
|
||||||
|
budget exhaustion is not a skip reason.
|
||||||
|
Additional resolver metadata may be retained in the artifact, not the report.
|
||||||
|
After each leaf passes its acceptance gate, the host saves the exact accepted
|
||||||
|
copy in a private immutable file and appends an `acceptedResults` entry with
|
||||||
|
`id`, `version`, and `reportPath`. Capture host-created failed validation
|
||||||
|
results the same way. Paths may be absolute or relative to the composition
|
||||||
|
artifact's directory. Capture the normalized accepted copy when normalization
|
||||||
|
was permitted, not the invalid raw return. Do not expose these files or write
|
||||||
|
access to the artifact to leaf workers or the composing model. Only the host
|
||||||
|
may append captures; the pre-dispatch selection and exclusions remain fixed.
|
||||||
|
The validator binds the super-skill and leaf identities and versions, checks
|
||||||
|
selected order, and requires exact agreement on exclusions. Each nested leaf
|
||||||
|
must exactly match its host-captured accepted JSON content, ignoring object
|
||||||
|
property order but preserving array order, types, values, and field presence.
|
||||||
|
Every captured leaf must be included; uncaptured or altered leaves are invalid.
|
||||||
|
Every returned leaf must be unique; a selected leaf cannot be reclassified as skipped by the
|
||||||
|
report. When selected leaves are missing, `outcome-reason` must name every
|
||||||
|
missing ID exactly and top-level `from-sub-skill: "agent"` findings are forbidden.
|
||||||
|
Without the artifact, validation remains structural and semantic but
|
||||||
|
cannot prove composition completeness, selected versions, order, or legitimate
|
||||||
|
exclusions, nor bind leaves to accepted host outputs. Duplicate or both
|
||||||
|
returned-and-skipped leaf IDs are invalid even without the artifact. Never
|
||||||
|
derive the expected composition from model output.
|
||||||
|
Pass
|
||||||
`-AllowBoundedNormalization` only when the host preserves the immutable raw
|
`-AllowBoundedNormalization` only when the host preserves the immutable raw
|
||||||
payload and records `removedRanges` in private telemetry as required above.
|
payload and records `removedRanges` in private telemetry as required above.
|
||||||
|
|
||||||
|
|
@ -330,7 +359,7 @@ Omit `suggested-code` only when the appropriate fix depends on context the skill
|
||||||
- `reference` — the suppressed file (same object shape as `findings[].references`).
|
- `reference` — the suppressed file (same object shape as `findings[].references`).
|
||||||
- `reason` — `layer-precedence` when another layer won under READ's precedence rules; `configuration` when the consumer disabled the file's layer.
|
- `reason` — `layer-precedence` when another layer won under READ's precedence rules; `configuration` when the consumer disabled the file's layer.
|
||||||
|
|
||||||
**`sub-results`** — super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Entries MUST appear in the worklist's declared order, regardless of invocation or completion order. Leaf skills MUST NOT emit `sub-results`.
|
**`sub-results`** — super-skills only. Array of complete findings-reports, one per invoked sub-skill, with no duplicate skill IDs. Each entry MUST itself conform to this output contract. Entries MUST appear in the worklist's declared order, regardless of invocation or completion order. A selected leaf left uninvoked by budget exhaustion has no fabricated sub-result and is not a configured or input-incompatible skip; its absence requires the incomplete-composition outcome below. Leaf skills MUST NOT emit `sub-results`.
|
||||||
|
|
||||||
**`skipped-sub-skills`** — super-skills only. Array of sub-skills that were declared in frontmatter but not invoked. `reason` is `configuration` when the orchestrator disabled the sub-skill, or `not-applicable` when the super-skill's Relevance step ruled it out.
|
**`skipped-sub-skills`** — super-skills only. Array of sub-skills that were declared in frontmatter but not invoked. `reason` is `configuration` when the orchestrator disabled the sub-skill, or `not-applicable` when the super-skill's Relevance step ruled it out.
|
||||||
|
|
||||||
|
|
@ -356,10 +385,14 @@ orchestrator.
|
||||||
|
|
||||||
Each leaf invocation MUST remain a discrete evaluation with its own complete
|
Each leaf invocation MUST remain a discrete evaluation with its own complete
|
||||||
findings-report. An orchestrator MAY execute independent leaves serially or
|
findings-report. An orchestrator MAY execute independent leaves serially or
|
||||||
concurrently, but MUST invoke every worklisted leaf, preserve `sub-results` in
|
concurrently, but MUST attempt every worklisted leaf, preserve `sub-results`
|
||||||
the declared worklist order, and wait for every invocation to finish before
|
in the declared worklist order, and wait for every started invocation to
|
||||||
performing any super-skill self-review or final rollup. Scheduling MUST NOT
|
finish before final rollup. If its execution budget prevents dispatching
|
||||||
change relevance, coverage, failure, reference-integrity, or output semantics.
|
remaining leaves, preserve the unfinished selection in the host-owned expected
|
||||||
|
composition and use the incomplete-composition outcome below. Do not perform
|
||||||
|
the super-skill self-review until every selected leaf has returned. Scheduling
|
||||||
|
MUST NOT change relevance, coverage, failure, reference-integrity, or output
|
||||||
|
semantics.
|
||||||
|
|
||||||
Orchestrators SHOULD generate `skill-index.json` with
|
Orchestrators SHOULD generate `skill-index.json` with
|
||||||
`tools/Build-SkillIndex.ps1` instead of parsing Markdown. Each declared
|
`tools/Build-SkillIndex.ps1` instead of parsing Markdown. Each declared
|
||||||
|
|
@ -390,7 +423,16 @@ The five required sections still apply. Their meaning shifts from knowledge file
|
||||||
|
|
||||||
### Outcome rollup
|
### Outcome rollup
|
||||||
|
|
||||||
A super-skill's `outcome` is derived from its sub-skills' outcomes. Let S be the multiset of sub-skill outcomes for sub-skills in the worklist (skipped sub-skills do not contribute):
|
A super-skill's `outcome` is derived from its selected worklist and returned
|
||||||
|
sub-skills' outcomes. If selected leaves have no returned report, the outcome
|
||||||
|
is `partial` when at least one returned report is non-`failed`, or `failed`
|
||||||
|
when no non-`failed` report is available. It MUST NOT be `completed`,
|
||||||
|
`not-applicable`, or `no-knowledge`. Name unfinished leaf IDs in
|
||||||
|
`outcome-reason`, preserve the valid returned reports, and never invent
|
||||||
|
successful or failed invocations for leaves that were not invoked.
|
||||||
|
|
||||||
|
When every selected leaf has returned, let S be the multiset of their outcomes
|
||||||
|
(skipped sub-skills do not contribute):
|
||||||
|
|
||||||
- `failed` — every element of S is `failed`.
|
- `failed` — every element of S is `failed`.
|
||||||
- `partial` — S contains at least one `partial`, OR S contains at least one `failed` alongside at least one non-`failed` outcome.
|
- `partial` — S contains at least one `partial`, OR S contains at least one `failed` alongside at least one non-`failed` outcome.
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,8 @@ try {
|
||||||
& $validator -ReportPath $reportPath -BCQualityRoot $Root
|
& $validator -ReportPath $reportPath -BCQualityRoot $Root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$completedSecurityLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$completedSecurityLeaf.skill.id = 'al-security-review'
|
||||||
$validSuperReport = [ordered]@{
|
$validSuperReport = [ordered]@{
|
||||||
skill = [ordered]@{ id = 'al-code-review'; version = 1 }
|
skill = [ordered]@{ id = 'al-code-review'; version = 1 }
|
||||||
outcome = 'completed'
|
outcome = 'completed'
|
||||||
|
|
@ -278,12 +280,410 @@ try {
|
||||||
}
|
}
|
||||||
findings = @()
|
findings = @()
|
||||||
suppressed = @()
|
suppressed = @()
|
||||||
'sub-results' = @($completedLeaf, $completedLeaf)
|
'sub-results' = @($completedLeaf, $completedSecurityLeaf)
|
||||||
}
|
}
|
||||||
Set-Content -LiteralPath $reportPath -Value ($validSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
Set-Content -LiteralPath $reportPath -Value ($validSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
$acceptedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
|
$acceptedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
|
||||||
Assert-True (-not $acceptedSuper.normalized) 'valid super-skill report is accepted'
|
Assert-True (-not $acceptedSuper.normalized) 'valid super-skill report is accepted'
|
||||||
|
|
||||||
|
$duplicateLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$duplicateLeafReport.'sub-results' = @($completedLeaf, $completedLeaf)
|
||||||
|
Set-Content -LiteralPath $reportPath -Value ($duplicateLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-ThrowsLike -Pattern '*SUPER_DUPLICATE_SUB_RESULT*' -Action {
|
||||||
|
& $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
|
||||||
|
}
|
||||||
|
|
||||||
|
$compositionPath = Join-Path $tmp 'composition.json'
|
||||||
|
function Save-AcceptedLeafReports {
|
||||||
|
param([object[]] $LeafReports)
|
||||||
|
|
||||||
|
foreach ($leafReport in $LeafReports) {
|
||||||
|
$leafPath = Join-Path $tmp "$([guid]::NewGuid()).json"
|
||||||
|
Set-Content -LiteralPath $leafPath -Value ($leafReport | ConvertTo-Json -Depth 100) -Encoding utf8NoBOM
|
||||||
|
$accepted = & $validator -ReportPath $leafPath -BCQualityRoot $Root
|
||||||
|
Assert-True (-not $accepted.normalized) 'host captures a validated leaf report'
|
||||||
|
@{ id = $leafReport.skill.id; version = $leafReport.skill.version; reportPath = $leafPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedComposition = [ordered]@{
|
||||||
|
superSkill = @{ id = 'al-code-review'; version = 1 }
|
||||||
|
subSkills = @(
|
||||||
|
@{ id = 'al-style-review'; version = 1 }
|
||||||
|
@{ id = 'al-security-review'; version = 1 }
|
||||||
|
)
|
||||||
|
skipped = @()
|
||||||
|
acceptedResults = @(Save-AcceptedLeafReports @($completedLeaf, $completedSecurityLeaf))
|
||||||
|
}
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Set-Content -LiteralPath $reportPath -Value ($validSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$acceptedBoundSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
|
||||||
|
-ExpectedCompositionPath $compositionPath
|
||||||
|
Assert-True (-not $acceptedBoundSuper.normalized) 'complete composition matches the expected worklist'
|
||||||
|
|
||||||
|
$incompleteComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$incompleteComposition.acceptedResults = @($incompleteComposition.acceptedResults[0])
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($incompleteComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$missingLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$missingLeafReport.'sub-results' = @($completedLeaf)
|
||||||
|
$missingLeafReport.summary.coverage.'worklist-size' = 1
|
||||||
|
$missingLeafReport.summary.coverage.'items-evaluated' = 1
|
||||||
|
Set-Content -LiteralPath $reportPath -Value ($missingLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-ThrowsLike -Pattern '*SUPER_OUTCOME_MISMATCH*' -Action {
|
||||||
|
& $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super -ExpectedCompositionPath $compositionPath
|
||||||
|
}
|
||||||
|
$missingLeafReport.outcome = 'partial'
|
||||||
|
$missingLeafReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'al-security-review was not evaluated before the budget expired.'
|
||||||
|
Set-Content -LiteralPath $reportPath -Value ($missingLeafReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$acceptedIncompleteSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
|
||||||
|
-ExpectedCompositionPath $compositionPath
|
||||||
|
Assert-True ($acceptedIncompleteSuper.report.outcome -ceq 'partial') 'unfinished selected leaves require a truthful partial outcome'
|
||||||
|
|
||||||
|
function Assert-CompositionReport {
|
||||||
|
param([object] $Candidate, [string] $ErrorPattern)
|
||||||
|
|
||||||
|
Set-Content -LiteralPath $reportPath -Value ($Candidate | ConvertTo-Json -Depth 30) -Encoding utf8NoBOM
|
||||||
|
if ($ErrorPattern) {
|
||||||
|
Assert-ThrowsLike -Pattern $ErrorPattern -Action {
|
||||||
|
& $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
|
||||||
|
-ExpectedCompositionPath $compositionPath -AllowBoundedNormalization
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$accepted = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
|
||||||
|
-ExpectedCompositionPath $compositionPath
|
||||||
|
Assert-True (-not $accepted.normalized) 'valid expected composition is accepted without repairs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$genericMissingReason = $missingLeafReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$genericMissingReason.'outcome-reason' = 'Budget expired.'
|
||||||
|
Assert-CompositionReport $genericMissingReason '*SUPER_MISSING_LEAF_REASON*'
|
||||||
|
$genericMissingReason.'outcome-reason' = 'prefix-al-security-review-suffix was not evaluated.'
|
||||||
|
Assert-CompositionReport $genericMissingReason '*SUPER_MISSING_LEAF_REASON*'
|
||||||
|
|
||||||
|
foreach ($fabricatedOutcome in 'completed', 'not-applicable', 'no-knowledge') {
|
||||||
|
$fabricatedLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$fabricatedLeafReport.'sub-results'[1].outcome = $fabricatedOutcome
|
||||||
|
if ($fabricatedOutcome -cne 'completed') {
|
||||||
|
$fabricatedLeafReport.'sub-results'[1].summary.coverage.'worklist-size' = 0
|
||||||
|
$fabricatedLeafReport.'sub-results'[1].summary.coverage.'items-evaluated' = 0
|
||||||
|
$fabricatedLeafReport.summary.coverage.'worklist-size' = 1
|
||||||
|
$fabricatedLeafReport.summary.coverage.'items-evaluated' = 1
|
||||||
|
}
|
||||||
|
Assert-CompositionReport $fabricatedLeafReport '*SUPER_LEAF_NOT_ACCEPTED*'
|
||||||
|
}
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-CompositionReport $missingLeafReport '*SUPER_ACCEPTED_LEAF_MISSING*'
|
||||||
|
$alteredLeafReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$alteredLeafReport.'sub-results'[1].summary.coverage.'worklist-size' = 2
|
||||||
|
$alteredLeafReport.'sub-results'[1].summary.coverage.'items-evaluated' = 2
|
||||||
|
$alteredLeafReport.summary.coverage.'worklist-size' = 3
|
||||||
|
$alteredLeafReport.summary.coverage.'items-evaluated' = 3
|
||||||
|
Assert-CompositionReport $alteredLeafReport '*SUPER_LEAF_CONTENT_MISMATCH*'
|
||||||
|
$reorderedProperties = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$reorderedProperties.'sub-results'[0].skill = [pscustomobject]@{ version = 1; id = 'al-style-review' }
|
||||||
|
Assert-CompositionReport $reorderedProperties
|
||||||
|
foreach ($alteredOutcome in 'not-applicable', 'no-knowledge') {
|
||||||
|
$alteredOutcomeReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$alteredOutcomeReport.'sub-results'[1].outcome = $alteredOutcome
|
||||||
|
$alteredOutcomeReport.'sub-results'[1].summary.coverage.'worklist-size' = 0
|
||||||
|
$alteredOutcomeReport.'sub-results'[1].summary.coverage.'items-evaluated' = 0
|
||||||
|
$alteredOutcomeReport.summary.coverage.'worklist-size' = 1
|
||||||
|
$alteredOutcomeReport.summary.coverage.'items-evaluated' = 1
|
||||||
|
Assert-CompositionReport $alteredOutcomeReport '*SUPER_LEAF_CONTENT_MISMATCH*'
|
||||||
|
}
|
||||||
|
$relativeCaptureComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
foreach ($capture in $relativeCaptureComposition.acceptedResults) {
|
||||||
|
$capture.reportPath = Split-Path -Leaf $capture.reportPath
|
||||||
|
}
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($relativeCaptureComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-CompositionReport $validSuperReport
|
||||||
|
$uncapturedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$uncapturedComposition.PSObject.Properties.Remove('acceptedResults')
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($uncapturedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-CompositionReport $validSuperReport '*Invalid expected composition*'
|
||||||
|
$duplicateCaptureComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$duplicateCaptureComposition.acceptedResults = @($duplicateCaptureComposition.acceptedResults[0], $duplicateCaptureComposition.acceptedResults[0])
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($duplicateCaptureComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-CompositionReport $validSuperReport '*Invalid expected composition*'
|
||||||
|
|
||||||
|
$capturedFindingLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$capturedFindingLeaf.findings = @(@{
|
||||||
|
id = 'agent:leaf-issue'
|
||||||
|
severity = 'minor'
|
||||||
|
confidence = 'medium'
|
||||||
|
message = 'Preserve this accepted leaf finding.'
|
||||||
|
references = @()
|
||||||
|
})
|
||||||
|
$secondCapturedFinding = $capturedFindingLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$secondCapturedFinding.id = 'agent:second-leaf-issue'
|
||||||
|
$capturedFindingLeaf.findings = @($capturedFindingLeaf.findings[0], $secondCapturedFinding)
|
||||||
|
$capturedFindingLeaf.summary.counts.minor = 2
|
||||||
|
$findingComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$findingComposition.acceptedResults = @(Save-AcceptedLeafReports @($capturedFindingLeaf, $completedSecurityLeaf))
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($findingComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$capturedFindingReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$capturedFindingReport.'sub-results'[0] = $capturedFindingLeaf
|
||||||
|
$capturedFindingReport.findings = @($capturedFindingLeaf.findings | ConvertTo-Json -Depth 20 | ConvertFrom-Json)
|
||||||
|
foreach ($finding in $capturedFindingReport.findings) {
|
||||||
|
$finding.id = "al-style-review:$($finding.id)"
|
||||||
|
$finding | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-style-review'
|
||||||
|
}
|
||||||
|
$capturedFindingReport.summary.counts.minor = 2
|
||||||
|
Assert-CompositionReport $capturedFindingReport
|
||||||
|
$reorderedFindingReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$reorderedFindingReport.'sub-results'[0].findings = @(
|
||||||
|
$reorderedFindingReport.'sub-results'[0].findings[1]
|
||||||
|
$reorderedFindingReport.'sub-results'[0].findings[0]
|
||||||
|
)
|
||||||
|
Assert-CompositionReport $reorderedFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*'
|
||||||
|
$addedLeafFieldReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$addedLeafFieldReport.'sub-results'[0] | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'A composer-added field.'
|
||||||
|
Assert-CompositionReport $addedLeafFieldReport '*SUPER_LEAF_CONTENT_MISMATCH*'
|
||||||
|
$alteredFindingReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$alteredFindingReport.'sub-results'[0].findings[0].message = 'A fabricated replacement message.'
|
||||||
|
$alteredFindingReport.findings[0].message = 'A fabricated replacement message.'
|
||||||
|
Assert-CompositionReport $alteredFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*'
|
||||||
|
$removedFindingReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$removedFindingReport.'sub-results'[0].findings = @()
|
||||||
|
$removedFindingReport.'sub-results'[0].summary.counts.minor = 0
|
||||||
|
$removedFindingReport.findings = @()
|
||||||
|
$removedFindingReport.summary.counts.minor = 0
|
||||||
|
Assert-CompositionReport $removedFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*'
|
||||||
|
|
||||||
|
$capturedCorrectionLeaf = $capturedFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$capturedCorrectionLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'exit(1);'
|
||||||
|
$correctionComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$correctionComposition.acceptedResults = @(Save-AcceptedLeafReports @($capturedCorrectionLeaf, $completedSecurityLeaf))
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($correctionComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$capturedCorrectionReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$capturedCorrectionReport.'sub-results'[0] = $capturedCorrectionLeaf
|
||||||
|
$capturedCorrectionReport.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'exit(1);'
|
||||||
|
Assert-CompositionReport $capturedCorrectionReport
|
||||||
|
$correctionCapturePath = $correctionComposition.acceptedResults[0].reportPath
|
||||||
|
$immutableCorrectionCapture = [IO.File]::ReadAllText($correctionCapturePath)
|
||||||
|
foreach ($codePoint in @(0x0000, 0x00AD, 0x200B, 0xFEFF)) {
|
||||||
|
$alteredCorrectionReport = $capturedCorrectionReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$alteredCorrection = 'ex' + [char]$codePoint + 'it(1);'
|
||||||
|
$alteredCorrectionReport.'sub-results'[0].findings[0].'suggested-code' = $alteredCorrection
|
||||||
|
$alteredCorrectionReport.findings[0].'suggested-code' = $alteredCorrection
|
||||||
|
Assert-CompositionReport $alteredCorrectionReport '*SUPER_LEAF_CONTENT_MISMATCH*'
|
||||||
|
$rolledOnlyCorrectionReport = $capturedCorrectionReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$rolledOnlyCorrectionReport.findings[0].'suggested-code' = $alteredCorrection
|
||||||
|
Assert-CompositionReport $rolledOnlyCorrectionReport '*SUPER_FINDING_MISMATCH*'
|
||||||
|
Assert-True ([string]::Equals([IO.File]::ReadAllText($correctionCapturePath), $immutableCorrectionCapture, [StringComparison]::Ordinal)) `
|
||||||
|
'rejecting altered corrections leaves the immutable host capture unchanged'
|
||||||
|
}
|
||||||
|
$timestampReason = '2026-10-02T09:00:00Z'
|
||||||
|
$timestampLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$timestampLeaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue $timestampReason
|
||||||
|
$timestampComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$timestampComposition.acceptedResults = @(Save-AcceptedLeafReports @($timestampLeaf, $completedSecurityLeaf))
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($timestampComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$timestampReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$timestampReport.'sub-results'[0] = $timestampLeaf
|
||||||
|
foreach ($alteredTimestamp in @('2026-10-02T09:00:00.000Z', '2026-10-02T09:00:00+00:00')) {
|
||||||
|
$timestampLeaf.'outcome-reason' = $alteredTimestamp
|
||||||
|
Assert-CompositionReport $timestampReport '*SUPER_LEAF_CONTENT_MISMATCH*'
|
||||||
|
}
|
||||||
|
$timestampLeaf.'outcome-reason' = $timestampReason
|
||||||
|
Set-Content -LiteralPath $reportPath -Value ($timestampReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$acceptedTimestampReport = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super `
|
||||||
|
-ExpectedCompositionPath $compositionPath
|
||||||
|
$acceptedReason = $acceptedTimestampReport.report.'sub-results'[0].'outcome-reason'
|
||||||
|
Assert-True ($acceptedReason -is [string] -and [string]::Equals($acceptedReason, $timestampReason, [StringComparison]::Ordinal)) `
|
||||||
|
'accepted timestamp-shaped JSON text remains the original literal string'
|
||||||
|
$normalizedTimestampReport = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$normalizedTimestampReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue '2026-10-02T09:00:00.000Z'
|
||||||
|
$normalizedTimestampReport.findings[0].location.range.'start-line' = 1
|
||||||
|
Set-Content -LiteralPath $reportPath -Value ($normalizedTimestampReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$acceptedNormalizedTimestamp = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp `
|
||||||
|
-SourcePaths $sourcePath -RetrievedArticlePaths $articlePath -AllowBoundedNormalization
|
||||||
|
Assert-True $acceptedNormalizedTimestamp.normalized 'bounded normalization still applies to an eligible range'
|
||||||
|
Assert-True ($acceptedNormalizedTimestamp.report.'outcome-reason' -is [string] -and
|
||||||
|
[string]::Equals($acceptedNormalizedTimestamp.report.'outcome-reason', $normalizedTimestampReport.'outcome-reason', [StringComparison]::Ordinal)) `
|
||||||
|
'bounded normalization preserves unrelated timestamp-shaped text exactly'
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
|
||||||
|
foreach ($case in @(
|
||||||
|
@{ Pattern = '*SUPER_IDENTITY_MISMATCH*'; Change = { param($candidate) $candidate.skill.id = 'al-other-review' } }
|
||||||
|
@{ Pattern = '*SUPER_IDENTITY_MISMATCH*'; Change = { param($candidate) $candidate.skill.version = 2 } }
|
||||||
|
@{ Pattern = '*SUPER_LEAF_VERSION_MISMATCH*'; Change = { param($candidate) $candidate.'sub-results'[0].skill.version = 2 } }
|
||||||
|
@{ Pattern = '*SUPER_UNEXPECTED_SUB_RESULT*'; Change = { param($candidate) $candidate.'sub-results'[0].skill.id = 'al-other-review' } }
|
||||||
|
@{ Pattern = '*SUPER_SUB_RESULT_ORDER*'; Change = { param($candidate) $candidate.'sub-results' = @($candidate.'sub-results'[1], $candidate.'sub-results'[0]) } }
|
||||||
|
@{ Pattern = '*SUPER_DUPLICATE_SUB_RESULT*'; Change = { param($candidate) $candidate.'sub-results' = @($candidate.'sub-results'[0], $candidate.'sub-results'[0]) } }
|
||||||
|
)) {
|
||||||
|
$candidate = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
& $case.Change $candidate
|
||||||
|
Assert-CompositionReport $candidate $case.Pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
$fabricatedSkip = $missingLeafReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$fabricatedSkip | Add-Member -NotePropertyName 'skipped-sub-skills' -NotePropertyValue @(
|
||||||
|
@{ skill = @{ id = 'al-security-review'; version = 1 }; reason = 'configuration' }
|
||||||
|
)
|
||||||
|
Assert-CompositionReport $fabricatedSkip '*SUPER_UNEXPECTED_SKIP*'
|
||||||
|
|
||||||
|
$emptyAcceptedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$emptyAcceptedComposition.acceptedResults = @()
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($emptyAcceptedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$noResults = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$noResults.'sub-results' = @()
|
||||||
|
$noResults.summary.coverage.'worklist-size' = 0
|
||||||
|
$noResults.summary.coverage.'items-evaluated' = 0
|
||||||
|
$noResults.outcome = 'not-applicable'
|
||||||
|
Assert-CompositionReport $noResults '*SUPER_OUTCOME_MISMATCH*'
|
||||||
|
$noResults.outcome = 'failed'
|
||||||
|
$noResults | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'al-style-review and al-security-review could not be evaluated.'
|
||||||
|
Assert-CompositionReport $noResults
|
||||||
|
|
||||||
|
$oneMissingId = $noResults | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$oneMissingId.'outcome-reason' = 'al-security-review could not be evaluated.'
|
||||||
|
Assert-CompositionReport $oneMissingId '*SUPER_MISSING_LEAF_REASON*'
|
||||||
|
foreach ($baseReport in @($missingLeafReport, $noResults, $validSuperReport)) {
|
||||||
|
$capturedComposition = if ($baseReport.outcome -ceq 'completed') { $expectedComposition } else { $incompleteComposition }
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($capturedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$selfReviewReport = $baseReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$selfReviewReport.findings = @(@{
|
||||||
|
id = 'agent:cross-domain-gap'
|
||||||
|
domain = 'Agent'
|
||||||
|
severity = 'minor'
|
||||||
|
confidence = 'medium'
|
||||||
|
message = 'A cross-domain issue needs attention.'
|
||||||
|
references = @()
|
||||||
|
'from-sub-skill' = 'agent'
|
||||||
|
})
|
||||||
|
$selfReviewReport.summary.counts.minor = 1
|
||||||
|
if ($baseReport.outcome -ceq 'completed') {
|
||||||
|
Assert-CompositionReport $selfReviewReport
|
||||||
|
}
|
||||||
|
elseif ($baseReport.outcome -ceq 'failed') {
|
||||||
|
Assert-CompositionReport $selfReviewReport '*Invalid findings-report JSON or schema*'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Assert-CompositionReport $selfReviewReport '*SUPER_AGENT_REVIEW_INCOMPLETE*'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$allFailed = $noResults | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$allFailed.'sub-results' = @($completedLeaf, $completedSecurityLeaf) | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
foreach ($leaf in $allFailed.'sub-results') {
|
||||||
|
$leaf.outcome = 'failed'
|
||||||
|
$leaf.summary.coverage.'items-evaluated' = 0
|
||||||
|
$leaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'Invocation failed.'
|
||||||
|
}
|
||||||
|
$failedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$failedComposition.acceptedResults = @(Save-AcceptedLeafReports $allFailed.'sub-results')
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($failedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-CompositionReport $allFailed
|
||||||
|
|
||||||
|
$skipComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$skipComposition.subSkills = @($skipComposition.subSkills[0])
|
||||||
|
$skipComposition.skipped = @(@{ id = 'al-security-review'; version = 1; reason = 'not-applicable' })
|
||||||
|
$skipComposition.acceptedResults = @($skipComposition.acceptedResults[0])
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($skipComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$validSkippedReport = $fabricatedSkip | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$validSkippedReport.outcome = 'completed'
|
||||||
|
$validSkippedReport.PSObject.Properties.Remove('outcome-reason')
|
||||||
|
$validSkippedReport.'skipped-sub-skills'[0].reason = 'not-applicable'
|
||||||
|
Assert-CompositionReport $validSkippedReport
|
||||||
|
Assert-CompositionReport $missingLeafReport '*SUPER_SKIP_MISSING*'
|
||||||
|
$wrongSkip = $validSkippedReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$wrongSkip.'skipped-sub-skills'[0].reason = 'configuration'
|
||||||
|
Assert-CompositionReport $wrongSkip '*SUPER_SKIP_MISMATCH*'
|
||||||
|
$wrongSkip.'skipped-sub-skills'[0].reason = 'not-applicable'
|
||||||
|
$wrongSkip.'skipped-sub-skills'[0].skill.version = 2
|
||||||
|
Assert-CompositionReport $wrongSkip '*SUPER_SKIP_MISMATCH*'
|
||||||
|
$duplicateSkip = $validSkippedReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$duplicateSkip.'skipped-sub-skills' = @($duplicateSkip.'skipped-sub-skills'[0], $duplicateSkip.'skipped-sub-skills'[0])
|
||||||
|
Assert-CompositionReport $duplicateSkip '*SUPER_SKIP_CONFLICT*'
|
||||||
|
$returnedAndSkipped = $validSkippedReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$returnedAndSkipped.'skipped-sub-skills'[0].skill.id = 'al-style-review'
|
||||||
|
Assert-CompositionReport $returnedAndSkipped '*SUPER_SKIP_CONFLICT*'
|
||||||
|
|
||||||
|
$allSkippedComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$allSkippedComposition.skipped = @($allSkippedComposition.subSkills | ForEach-Object {
|
||||||
|
@{ id = $_.id; version = $_.version; reason = 'configuration' }
|
||||||
|
})
|
||||||
|
$allSkippedComposition.subSkills = @()
|
||||||
|
$allSkippedComposition.acceptedResults = @()
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($allSkippedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
$allSkippedReport = $noResults | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$allSkippedReport.outcome = 'not-applicable'
|
||||||
|
$allSkippedReport.PSObject.Properties.Remove('outcome-reason')
|
||||||
|
$allSkippedReport | Add-Member -NotePropertyName 'skipped-sub-skills' -NotePropertyValue @(
|
||||||
|
$allSkippedComposition.skipped | ForEach-Object { @{ skill = @{ id = $_.id; version = $_.version }; reason = $_.reason } }
|
||||||
|
)
|
||||||
|
Assert-CompositionReport $allSkippedReport
|
||||||
|
|
||||||
|
$invalidComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$invalidComposition.skipped = @(@{ id = 'al-style-review'; version = 1; reason = 'configuration' })
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($invalidComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-CompositionReport $validSuperReport '*Invalid expected composition*'
|
||||||
|
$invalidComposition.skipped = @()
|
||||||
|
$invalidComposition.subSkills[0].version = '1'
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($invalidComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-CompositionReport $validSuperReport '*Invalid expected composition*'
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
|
||||||
|
$layerFixtureRoot = Join-Path $tmp 'layered-skills'
|
||||||
|
foreach ($layer in 'microsoft', 'community', 'custom') {
|
||||||
|
$layerDirectory = Join-Path $layerFixtureRoot $layer
|
||||||
|
New-Item -ItemType Directory -Path $layerDirectory -Force | Out-Null
|
||||||
|
$sourceSkills = Join-Path $Root "$layer/skills"
|
||||||
|
if (Test-Path -LiteralPath $sourceSkills -PathType Container) {
|
||||||
|
Copy-Item -LiteralPath $sourceSkills -Destination (Join-Path $layerDirectory 'skills') -Recurse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$customSkillDirectory = Join-Path $layerFixtureRoot 'custom/skills/review'
|
||||||
|
New-Item -ItemType Directory -Path $customSkillDirectory -Force | Out-Null
|
||||||
|
$customSkillPath = 'custom/skills/review/company-style-review.md'
|
||||||
|
$styleSkillText = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-style-review.md') -Raw
|
||||||
|
Set-Content -LiteralPath (Join-Path $layerFixtureRoot $customSkillPath) `
|
||||||
|
-Value ($styleSkillText -replace '(?m)^version: 1\r?$', 'version: 7') -Encoding utf8NoBOM
|
||||||
|
$fixtureIndexPath = Join-Path $tmp 'layered-skill-index.json'
|
||||||
|
& (Join-Path $Root 'tools/Build-SkillIndex.ps1') -BCQualityRoot $layerFixtureRoot -IndexPath $fixtureIndexPath | Out-Null
|
||||||
|
$fixtureIndex = Get-Content -LiteralPath $fixtureIndexPath -Raw | ConvertFrom-Json
|
||||||
|
foreach ($selection in @(
|
||||||
|
@{ Disabled = @(); ExpectedLayer = 'custom'; ExpectedVersion = 7 }
|
||||||
|
@{ Disabled = @($customSkillPath); ExpectedLayer = 'microsoft'; ExpectedVersion = 1 }
|
||||||
|
@{ Disabled = @($customSkillPath, 'microsoft/skills/review/al-style-review.md'); ExpectedLayer = $null }
|
||||||
|
)) {
|
||||||
|
$resolved = & (Join-Path $Root 'tools/Resolve-SkillWorklist.ps1') -BCQualityRoot $layerFixtureRoot `
|
||||||
|
-IndexPath $fixtureIndexPath -SuperSkillPath 'microsoft/skills/review/al-code-review.md' `
|
||||||
|
-DisabledSkills $selection.Disabled
|
||||||
|
$styleSlots = @($resolved.subSkills | Where-Object id -CEQ 'al-style-review')
|
||||||
|
if ($selection.ExpectedLayer) {
|
||||||
|
Assert-True ($styleSlots.Count -eq 1 -and $styleSlots[0].layer -ceq $selection.ExpectedLayer -and
|
||||||
|
$styleSlots[0].version -eq $selection.ExpectedVersion) 'resolver-selected override or fallback is authoritative'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Assert-True ($styleSlots.Count -eq 0) 'fully disabled slot is not selected'
|
||||||
|
}
|
||||||
|
$resolved.skipped = @($resolved.skipped | ForEach-Object {
|
||||||
|
$declaredPath = $_.declaredPath
|
||||||
|
$declaredSkill = @($fixtureIndex.skills | Where-Object path -CEQ $declaredPath)[0]
|
||||||
|
@{ id = $_.id; version = $declaredSkill.version; reason = $_.reason; declaredPath = $declaredPath }
|
||||||
|
})
|
||||||
|
$resolvedReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$resolvedReport.'sub-results' = @($resolved.subSkills | ForEach-Object {
|
||||||
|
$leafReport = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$leafReport.skill.id = $_.id
|
||||||
|
$leafReport.skill.version = $_.version
|
||||||
|
$leafReport
|
||||||
|
})
|
||||||
|
$resolvedReport.summary.coverage.'worklist-size' = $resolved.subSkills.Count
|
||||||
|
$resolvedReport.summary.coverage.'items-evaluated' = $resolved.subSkills.Count
|
||||||
|
$resolvedReport | Add-Member -NotePropertyName 'skipped-sub-skills' -NotePropertyValue @(
|
||||||
|
$resolved.skipped | ForEach-Object { @{ skill = @{ id = $_.id; version = $_.version }; reason = $_.reason } }
|
||||||
|
)
|
||||||
|
$resolved | Add-Member -NotePropertyName 'acceptedResults' -NotePropertyValue @(Save-AcceptedLeafReports $resolvedReport.'sub-results')
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($resolved | ConvertTo-Json -Depth 30) -Encoding utf8NoBOM
|
||||||
|
Assert-CompositionReport $resolvedReport
|
||||||
|
}
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
|
||||||
$styleFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
$styleFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
$securityFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
$securityFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
$securityFindingLeaf.skill.id = 'al-security-review'
|
$securityFindingLeaf.skill.id = 'al-security-review'
|
||||||
|
|
@ -446,6 +846,10 @@ try {
|
||||||
Set-Content -LiteralPath $reportPath -Value ($partialSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
Set-Content -LiteralPath $reportPath -Value ($partialSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
$acceptedPartialSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
|
$acceptedPartialSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super
|
||||||
Assert-True (-not $acceptedPartialSuper.normalized) 'partial super-skill excludes failed coverage from its rollup'
|
Assert-True (-not $acceptedPartialSuper.normalized) 'partial super-skill excludes failed coverage from its rollup'
|
||||||
|
$partialComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
|
$partialComposition.acceptedResults = @(Save-AcceptedLeafReports @($completedLeaf, $failedLeaf))
|
||||||
|
Set-Content -LiteralPath $compositionPath -Value ($partialComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM
|
||||||
|
Assert-CompositionReport $partialSuperReport
|
||||||
|
|
||||||
$failedLeafLeakage = $partialSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
$failedLeafLeakage = $partialSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json
|
||||||
$failedLeafLeakage.summary.counts.minor = 1
|
$failedLeafLeakage.summary.counts.minor = 1
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
#Requires -Version 7.5
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Validates a BCQuality findings-report against its structural and semantic contract.
|
Validates a BCQuality findings-report against its structural and semantic contract.
|
||||||
|
|
@ -12,6 +13,7 @@ param(
|
||||||
[string[]] $RetrievedArticlePaths = @(),
|
[string[]] $RetrievedArticlePaths = @(),
|
||||||
[ValidateSet('leaf', 'super')]
|
[ValidateSet('leaf', 'super')]
|
||||||
[string] $SkillKind = 'leaf',
|
[string] $SkillKind = 'leaf',
|
||||||
|
[string] $ExpectedCompositionPath,
|
||||||
[switch] $AllowBoundedNormalization
|
[switch] $AllowBoundedNormalization
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -28,12 +30,105 @@ try {
|
||||||
if (-not ($raw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) {
|
if (-not ($raw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) {
|
||||||
throw 'Report does not satisfy schemas/findings-report.schema.json.'
|
throw 'Report does not satisfy schemas/findings-report.schema.json.'
|
||||||
}
|
}
|
||||||
$report = $raw | ConvertFrom-Json -Depth 100
|
$report = $raw | ConvertFrom-Json -Depth 100 -DateKind String
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
throw "Invalid findings-report JSON or schema: $($_.Exception.Message)"
|
throw "Invalid findings-report JSON or schema: $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$expectedComposition = $null
|
||||||
|
$expectedLeaves = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
|
||||||
|
$expectedSkips = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
|
||||||
|
$acceptedLeaves = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
|
||||||
|
if ($ExpectedCompositionPath) {
|
||||||
|
if ($SkillKind -cne 'super') {
|
||||||
|
throw 'Expected composition is supported only for super-skill reports.'
|
||||||
|
}
|
||||||
|
$contractSchema = Get-Content -LiteralPath $schemaPath -Raw | ConvertFrom-Json -AsHashtable
|
||||||
|
$identitySchema = @{
|
||||||
|
type = 'object'
|
||||||
|
required = @('id', 'version')
|
||||||
|
properties = $contractSchema.definitions.skillReference.properties
|
||||||
|
}
|
||||||
|
$skipProperties = @{
|
||||||
|
id = $identitySchema.properties.id
|
||||||
|
version = $identitySchema.properties.version
|
||||||
|
reason = @{ enum = @('configuration', 'not-applicable') }
|
||||||
|
}
|
||||||
|
$compositionSchema = @{
|
||||||
|
type = 'object'
|
||||||
|
required = @('superSkill', 'subSkills', 'skipped', 'acceptedResults')
|
||||||
|
properties = @{
|
||||||
|
superSkill = $identitySchema
|
||||||
|
subSkills = @{ type = 'array'; items = $identitySchema }
|
||||||
|
acceptedResults = @{
|
||||||
|
type = 'array'
|
||||||
|
items = @{
|
||||||
|
type = 'object'
|
||||||
|
required = @('id', 'version', 'reportPath')
|
||||||
|
properties = @{
|
||||||
|
id = $identitySchema.properties.id
|
||||||
|
version = $identitySchema.properties.version
|
||||||
|
reportPath = @{ type = 'string'; minLength = 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skipped = @{
|
||||||
|
type = 'array'
|
||||||
|
items = @{ type = 'object'; required = @('id', 'version', 'reason'); properties = $skipProperties }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json -Depth 20
|
||||||
|
try {
|
||||||
|
$compositionRaw = Get-Content -LiteralPath $ExpectedCompositionPath -Raw
|
||||||
|
if (-not ($compositionRaw | Test-Json -Schema $compositionSchema -ErrorAction Stop)) {
|
||||||
|
throw 'Expected composition does not satisfy its input contract.'
|
||||||
|
}
|
||||||
|
$expectedComposition = $compositionRaw | ConvertFrom-Json -Depth 100 -DateKind String
|
||||||
|
$expectedIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
||||||
|
foreach ($leaf in @($expectedComposition.subSkills)) {
|
||||||
|
if (-not $expectedIds.Add([string]$leaf.id)) {
|
||||||
|
throw "Duplicate expected skill id '$($leaf.id)'."
|
||||||
|
}
|
||||||
|
$expectedLeaves.Add([string]$leaf.id, $leaf)
|
||||||
|
}
|
||||||
|
foreach ($skip in @($expectedComposition.skipped)) {
|
||||||
|
if (-not $expectedIds.Add([string]$skip.id)) {
|
||||||
|
throw "Duplicate or selected-and-skipped expected skill id '$($skip.id)'."
|
||||||
|
}
|
||||||
|
$expectedSkips.Add([string]$skip.id, $skip)
|
||||||
|
}
|
||||||
|
$compositionDirectory = Split-Path -Parent (Resolve-Path -LiteralPath $ExpectedCompositionPath).Path
|
||||||
|
foreach ($accepted in @($expectedComposition.acceptedResults)) {
|
||||||
|
if (-not $expectedLeaves.ContainsKey([string]$accepted.id) -or
|
||||||
|
$accepted.version -ne $expectedLeaves[$accepted.id].version -or
|
||||||
|
$acceptedLeaves.ContainsKey([string]$accepted.id)) {
|
||||||
|
throw "Accepted result '$($accepted.id)' must uniquely match a selected leaf and version."
|
||||||
|
}
|
||||||
|
$acceptedPath = if ([IO.Path]::IsPathRooted($accepted.reportPath)) {
|
||||||
|
$accepted.reportPath
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Join-Path $compositionDirectory $accepted.reportPath
|
||||||
|
}
|
||||||
|
$acceptedRaw = Get-Content -LiteralPath $acceptedPath -Raw
|
||||||
|
if (-not ($acceptedRaw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) {
|
||||||
|
throw "Accepted result '$($accepted.id)' does not satisfy the report schema."
|
||||||
|
}
|
||||||
|
$acceptedReport = $acceptedRaw | ConvertFrom-Json -Depth 100 -DateKind String
|
||||||
|
if ($acceptedReport.skill.id -cne $accepted.id -or $acceptedReport.skill.version -ne $accepted.version -or
|
||||||
|
$acceptedReport.PSObject.Properties.Name -ccontains 'sub-results' -or
|
||||||
|
$acceptedReport.PSObject.Properties.Name -ccontains 'skipped-sub-skills') {
|
||||||
|
throw "Accepted result '$($accepted.id)' must be a leaf report with the captured identity."
|
||||||
|
}
|
||||||
|
$acceptedLeaves.Add([string]$accepted.id, $acceptedReport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw "Invalid expected composition: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$retrieved = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
$retrieved = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
||||||
foreach ($path in $RetrievedArticlePaths) {
|
foreach ($path in $RetrievedArticlePaths) {
|
||||||
$retrieved.Add($path) | Out-Null
|
$retrieved.Add($path) | Out-Null
|
||||||
|
|
@ -49,6 +144,43 @@ function Test-HasProperty {
|
||||||
return $null -ne $Object -and $Object.PSObject.Properties.Name -ccontains $Name
|
return $null -ne $Object -and $Object.PSObject.Properties.Name -ccontains $Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-JsonContentEqual {
|
||||||
|
param([object] $First, [object] $Second)
|
||||||
|
|
||||||
|
if ($null -eq $First -or $null -eq $Second) {
|
||||||
|
return $null -eq $First -and $null -eq $Second
|
||||||
|
}
|
||||||
|
if ($First -is [pscustomobject] -or $Second -is [pscustomobject]) {
|
||||||
|
if ($First -isnot [pscustomobject] -or $Second -isnot [pscustomobject] -or
|
||||||
|
@($First.PSObject.Properties).Count -ne @($Second.PSObject.Properties).Count) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
foreach ($property in $First.PSObject.Properties) {
|
||||||
|
if (-not (Test-HasProperty $Second $property.Name) -or
|
||||||
|
-not (Test-JsonContentEqual $property.Value $Second.PSObject.Properties[$property.Name].Value)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if ($First -is [array] -or $Second -is [array]) {
|
||||||
|
if ($First -isnot [array] -or $Second -isnot [array] -or $First.Count -ne $Second.Count) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
for ($index = 0; $index -lt $First.Count; $index++) {
|
||||||
|
if (-not (Test-JsonContentEqual $First[$index] $Second[$index])) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if ($First -is [string] -or $Second -is [string]) {
|
||||||
|
return $First -is [string] -and $Second -is [string] -and
|
||||||
|
[string]::Equals($First, $Second, [StringComparison]::Ordinal)
|
||||||
|
}
|
||||||
|
return $First.GetType() -eq $Second.GetType() -and $First -ceq $Second
|
||||||
|
}
|
||||||
|
|
||||||
function Get-SourceLineCount {
|
function Get-SourceLineCount {
|
||||||
param([string] $Path)
|
param([string] $Path)
|
||||||
|
|
||||||
|
|
@ -79,8 +211,14 @@ function Get-SemanticErrors {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-DerivedSuperOutcome {
|
function Get-DerivedSuperOutcome {
|
||||||
param([object[]] $SubResults)
|
param([object[]] $SubResults, [int] $MissingResults = 0)
|
||||||
|
|
||||||
|
if ($MissingResults -gt 0) {
|
||||||
|
if (@($SubResults | Where-Object outcome -CNE 'failed').Count) {
|
||||||
|
return 'partial'
|
||||||
|
}
|
||||||
|
return 'failed'
|
||||||
|
}
|
||||||
if (-not $SubResults.Count) {
|
if (-not $SubResults.Count) {
|
||||||
return 'not-applicable'
|
return 'not-applicable'
|
||||||
}
|
}
|
||||||
|
|
@ -138,9 +276,10 @@ function Get-SemanticErrors {
|
||||||
$firstHasCode = Test-HasProperty $First 'suggested-code'
|
$firstHasCode = Test-HasProperty $First 'suggested-code'
|
||||||
$secondHasCode = Test-HasProperty $Second 'suggested-code'
|
$secondHasCode = Test-HasProperty $Second 'suggested-code'
|
||||||
if ($firstHasCode -or $secondHasCode) {
|
if ($firstHasCode -or $secondHasCode) {
|
||||||
return $firstHasCode -and $secondHasCode -and $First.'suggested-code' -ceq $Second.'suggested-code'
|
return $firstHasCode -and $secondHasCode -and
|
||||||
|
[string]::Equals($First.'suggested-code', $Second.'suggested-code', [StringComparison]::Ordinal)
|
||||||
}
|
}
|
||||||
return $First.message -ceq $Second.message
|
return [string]::Equals($First.message, $Second.message, [StringComparison]::Ordinal)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Test-ReferencesInclude {
|
function Test-ReferencesInclude {
|
||||||
|
|
@ -181,7 +320,7 @@ function Get-SemanticErrors {
|
||||||
$RolledFinding.id -cne $expectedId -or
|
$RolledFinding.id -cne $expectedId -or
|
||||||
$RolledFinding.severity -cne $LeafFinding.severity -or
|
$RolledFinding.severity -cne $LeafFinding.severity -or
|
||||||
$RolledFinding.confidence -cne $LeafFinding.confidence -or
|
$RolledFinding.confidence -cne $LeafFinding.confidence -or
|
||||||
$RolledFinding.message -cne $LeafFinding.message -or
|
-not [string]::Equals($RolledFinding.message, $LeafFinding.message, [StringComparison]::Ordinal) -or
|
||||||
$rolledReferences.Count -ne $leafReferences.Count -or
|
$rolledReferences.Count -ne $leafReferences.Count -or
|
||||||
-not (Test-ReferencesInclude $rolledReferences $leafReferences)) {
|
-not (Test-ReferencesInclude $rolledReferences $leafReferences)) {
|
||||||
return $false
|
return $false
|
||||||
|
|
@ -190,7 +329,7 @@ function Get-SemanticErrors {
|
||||||
$rolledHasProperty = Test-HasProperty $RolledFinding $name
|
$rolledHasProperty = Test-HasProperty $RolledFinding $name
|
||||||
$leafHasProperty = Test-HasProperty $LeafFinding $name
|
$leafHasProperty = Test-HasProperty $LeafFinding $name
|
||||||
if ($rolledHasProperty -ne $leafHasProperty -or
|
if ($rolledHasProperty -ne $leafHasProperty -or
|
||||||
($rolledHasProperty -and $RolledFinding.$name -cne $LeafFinding.$name)) {
|
($rolledHasProperty -and -not [string]::Equals($RolledFinding.$name, $LeafFinding.$name, [StringComparison]::Ordinal))) {
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,7 +345,7 @@ function Get-SemanticErrors {
|
||||||
$sameCorrection = Test-SameCorrection $RolledFinding $LeafFinding
|
$sameCorrection = Test-SameCorrection $RolledFinding $LeafFinding
|
||||||
$correctionsConflict = (Test-HasProperty $RolledFinding 'suggested-code') -and
|
$correctionsConflict = (Test-HasProperty $RolledFinding 'suggested-code') -and
|
||||||
(Test-HasProperty $LeafFinding 'suggested-code') -and
|
(Test-HasProperty $LeafFinding 'suggested-code') -and
|
||||||
$RolledFinding.'suggested-code' -cne $LeafFinding.'suggested-code'
|
-not [string]::Equals($RolledFinding.'suggested-code', $LeafFinding.'suggested-code', [StringComparison]::Ordinal)
|
||||||
$explicitCrossRuleMerge = $leafReferences.Count -and
|
$explicitCrossRuleMerge = $leafReferences.Count -and
|
||||||
$rolledReferences.Count -gt $leafReferences.Count -and
|
$rolledReferences.Count -gt $leafReferences.Count -and
|
||||||
-not $correctionsConflict -and
|
-not $correctionsConflict -and
|
||||||
|
|
@ -342,20 +481,105 @@ function Get-SemanticErrors {
|
||||||
|
|
||||||
if ($CurrentSkillKind -ceq 'super' -and $hasSubResults) {
|
if ($CurrentSkillKind -ceq 'super' -and $hasSubResults) {
|
||||||
$subResults = @($Current.'sub-results')
|
$subResults = @($Current.'sub-results')
|
||||||
|
$producerIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
||||||
for ($index = 0; $index -lt $subResults.Count; $index++) {
|
for ($index = 0; $index -lt $subResults.Count; $index++) {
|
||||||
|
if (-not $producerIds.Add([string]$subResults[$index].skill.id)) {
|
||||||
|
Add-Error 'SUPER_DUPLICATE_SUB_RESULT' "$ReportPathPrefix.sub-results[$index].skill.id" `
|
||||||
|
'A leaf may appear only once in sub-results.'
|
||||||
|
}
|
||||||
Test-Report $subResults[$index] "$ReportPathPrefix.sub-results[$index]" 'leaf'
|
Test-Report $subResults[$index] "$ReportPathPrefix.sub-results[$index]" 'leaf'
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedOutcome = Get-DerivedSuperOutcome $subResults
|
$skippedIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
||||||
|
$skips = if ($hasSkippedSubSkills) { @($Current.'skipped-sub-skills') } else { @() }
|
||||||
|
foreach ($skip in $skips) {
|
||||||
|
if (-not $skippedIds.Add([string]$skip.skill.id) -or $producerIds.Contains([string]$skip.skill.id)) {
|
||||||
|
Add-Error 'SUPER_SKIP_CONFLICT' "$ReportPathPrefix.skipped-sub-skills" `
|
||||||
|
"Skill '$($skip.skill.id)' is duplicated or both returned and skipped."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$missingResults = 0
|
||||||
|
if ($null -ne $expectedComposition) {
|
||||||
|
if ($Current.skill.id -cne $expectedComposition.superSkill.id -or
|
||||||
|
$Current.skill.version -ne $expectedComposition.superSkill.version) {
|
||||||
|
Add-Error 'SUPER_IDENTITY_MISMATCH' "$ReportPathPrefix.skill" 'Super-skill identity differs from expected composition.'
|
||||||
|
}
|
||||||
|
$previousSlot = -1
|
||||||
|
$orderedIds = @($expectedComposition.subSkills | ForEach-Object { $_.id })
|
||||||
|
for ($index = 0; $index -lt $subResults.Count; $index++) {
|
||||||
|
$identity = $subResults[$index].skill
|
||||||
|
if (-not $expectedLeaves.ContainsKey([string]$identity.id)) {
|
||||||
|
Add-Error 'SUPER_UNEXPECTED_SUB_RESULT' "$ReportPathPrefix.sub-results[$index].skill" `
|
||||||
|
"Skill '$($identity.id)' was not selected."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ($identity.version -ne $expectedLeaves[$identity.id].version) {
|
||||||
|
Add-Error 'SUPER_LEAF_VERSION_MISMATCH' "$ReportPathPrefix.sub-results[$index].skill.version" `
|
||||||
|
"Unexpected version for '$($identity.id)'."
|
||||||
|
}
|
||||||
|
if (-not $acceptedLeaves.ContainsKey([string]$identity.id)) {
|
||||||
|
Add-Error 'SUPER_LEAF_NOT_ACCEPTED' "$ReportPathPrefix.sub-results[$index]" `
|
||||||
|
"Leaf '$($identity.id)' has no host-captured accepted result."
|
||||||
|
}
|
||||||
|
elseif (-not (Test-JsonContentEqual $subResults[$index] $acceptedLeaves[$identity.id])) {
|
||||||
|
Add-Error 'SUPER_LEAF_CONTENT_MISMATCH' "$ReportPathPrefix.sub-results[$index]" `
|
||||||
|
"Leaf '$($identity.id)' differs from its host-captured accepted result."
|
||||||
|
}
|
||||||
|
$slot = [Array]::IndexOf($orderedIds, $identity.id)
|
||||||
|
if ($slot -le $previousSlot) {
|
||||||
|
Add-Error 'SUPER_SUB_RESULT_ORDER' "$ReportPathPrefix.sub-results[$index].skill" `
|
||||||
|
'Sub-results must preserve the selected worklist order.'
|
||||||
|
}
|
||||||
|
$previousSlot = $slot
|
||||||
|
}
|
||||||
|
foreach ($leaf in @($expectedComposition.subSkills)) {
|
||||||
|
if (-not $producerIds.Contains([string]$leaf.id)) {
|
||||||
|
$missingResults++
|
||||||
|
if ($acceptedLeaves.ContainsKey([string]$leaf.id)) {
|
||||||
|
Add-Error 'SUPER_ACCEPTED_LEAF_MISSING' "$ReportPathPrefix.sub-results" `
|
||||||
|
"Host-captured accepted leaf '$($leaf.id)' must be included."
|
||||||
|
}
|
||||||
|
$reason = if (Test-HasProperty $Current 'outcome-reason') { $Current.'outcome-reason' } else { '' }
|
||||||
|
$idPattern = '(?<![a-zA-Z0-9_-])' + [regex]::Escape($leaf.id) + '(?![a-zA-Z0-9_-])'
|
||||||
|
if ($reason -cnotmatch $idPattern) {
|
||||||
|
Add-Error 'SUPER_MISSING_LEAF_REASON' "$ReportPathPrefix.outcome-reason" `
|
||||||
|
"The reason must name missing selected leaf '$($leaf.id)'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($skip in $skips) {
|
||||||
|
if (-not $expectedSkips.ContainsKey([string]$skip.skill.id)) {
|
||||||
|
Add-Error 'SUPER_UNEXPECTED_SKIP' "$ReportPathPrefix.skipped-sub-skills" `
|
||||||
|
"Skill '$($skip.skill.id)' was not excluded by the coordinator."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$expectedSkip = $expectedSkips[$skip.skill.id]
|
||||||
|
if ($skip.skill.version -ne $expectedSkip.version -or $skip.reason -cne $expectedSkip.reason) {
|
||||||
|
Add-Error 'SUPER_SKIP_MISMATCH' "$ReportPathPrefix.skipped-sub-skills" `
|
||||||
|
"Skip identity or reason differs for '$($skip.skill.id)'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($skip in @($expectedComposition.skipped)) {
|
||||||
|
if (-not $skippedIds.Contains([string]$skip.id)) {
|
||||||
|
Add-Error 'SUPER_SKIP_MISSING' "$ReportPathPrefix.skipped-sub-skills" `
|
||||||
|
"Expected exclusion '$($skip.id)' is missing."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedOutcome = Get-DerivedSuperOutcome $subResults $missingResults
|
||||||
if ($Current.outcome -cne $expectedOutcome) {
|
if ($Current.outcome -cne $expectedOutcome) {
|
||||||
Add-Error 'SUPER_OUTCOME_MISMATCH' "$ReportPathPrefix.outcome" "Expected '$expectedOutcome' from sub-results."
|
Add-Error 'SUPER_OUTCOME_MISMATCH' "$ReportPathPrefix.outcome" "Expected '$expectedOutcome' from sub-results."
|
||||||
}
|
}
|
||||||
|
|
||||||
$includedSubResults = @($subResults | Where-Object outcome -CNE 'failed')
|
$includedSubResults = @($subResults | Where-Object outcome -CNE 'failed')
|
||||||
$expectedWorklistSize = ($includedSubResults | Measure-Object -Property { $_.summary.coverage.'worklist-size' } -Sum).Sum
|
$expectedWorklistSize = 0
|
||||||
$expectedItemsEvaluated = ($includedSubResults | Measure-Object -Property { $_.summary.coverage.'items-evaluated' } -Sum).Sum
|
$expectedItemsEvaluated = 0
|
||||||
if ($null -eq $expectedWorklistSize) { $expectedWorklistSize = 0 }
|
foreach ($subResult in $includedSubResults) {
|
||||||
if ($null -eq $expectedItemsEvaluated) { $expectedItemsEvaluated = 0 }
|
$expectedWorklistSize += $subResult.summary.coverage.'worklist-size'
|
||||||
|
$expectedItemsEvaluated += $subResult.summary.coverage.'items-evaluated'
|
||||||
|
}
|
||||||
if ($worklistSize -ne $expectedWorklistSize -or $itemsEvaluated -ne $expectedItemsEvaluated) {
|
if ($worklistSize -ne $expectedWorklistSize -or $itemsEvaluated -ne $expectedItemsEvaluated) {
|
||||||
Add-Error 'SUPER_COVERAGE_MISMATCH' "$ReportPathPrefix.summary.coverage" `
|
Add-Error 'SUPER_COVERAGE_MISMATCH' "$ReportPathPrefix.summary.coverage" `
|
||||||
"Expected worklist-size $expectedWorklistSize and items-evaluated $expectedItemsEvaluated from non-failed sub-results."
|
"Expected worklist-size $expectedWorklistSize and items-evaluated $expectedItemsEvaluated from non-failed sub-results."
|
||||||
|
|
@ -378,6 +602,10 @@ function Get-SemanticErrors {
|
||||||
$finding = $findings[$index]
|
$finding = $findings[$index]
|
||||||
$producerId = [string]$finding.'from-sub-skill'
|
$producerId = [string]$finding.'from-sub-skill'
|
||||||
if ($producerId -ceq 'agent') {
|
if ($producerId -ceq 'agent') {
|
||||||
|
if ($missingResults -gt 0) {
|
||||||
|
Add-Error 'SUPER_AGENT_REVIEW_INCOMPLETE' "$ReportPathPrefix.findings[$index]" `
|
||||||
|
'Super-skill self-review is forbidden while selected leaf results are missing.'
|
||||||
|
}
|
||||||
if (@($finding.references).Count -or
|
if (@($finding.references).Count -or
|
||||||
$finding.id -cnotmatch '^agent:[a-z0-9]+(?:-[a-z0-9]+)*$' -or
|
$finding.id -cnotmatch '^agent:[a-z0-9]+(?:-[a-z0-9]+)*$' -or
|
||||||
-not (Test-HasProperty $finding 'domain') -or
|
-not (Test-HasProperty $finding 'domain') -or
|
||||||
|
|
@ -496,7 +724,7 @@ if ($errors.Count -and $AllowBoundedNormalization) {
|
||||||
$otherErrors = @($errors | Where-Object Code -CNE 'RANGE_START_MISMATCH')
|
$otherErrors = @($errors | Where-Object Code -CNE 'RANGE_START_MISMATCH')
|
||||||
$rangeErrors = @($errors | Where-Object Code -CEQ 'RANGE_START_MISMATCH')
|
$rangeErrors = @($errors | Where-Object Code -CEQ 'RANGE_START_MISMATCH')
|
||||||
if (-not $otherErrors.Count -and $rangeErrors.Count) {
|
if (-not $otherErrors.Count -and $rangeErrors.Count) {
|
||||||
$candidate = $report | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100
|
$candidate = $report | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 -DateKind String
|
||||||
$eligible = $true
|
$eligible = $true
|
||||||
foreach ($finding in @($candidate.findings)) {
|
foreach ($finding in @($candidate.findings)) {
|
||||||
if (-not (Test-HasProperty $finding 'location') -or
|
if (-not (Test-HasProperty $finding 'location') -or
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue