diff --git a/.github/scripts/Test-SkillIndex.ps1 b/.github/scripts/Test-SkillIndex.ps1 index b694a5b1..bc635f2b 100644 --- a/.github/scripts/Test-SkillIndex.ps1 +++ b/.github/scripts/Test-SkillIndex.ps1 @@ -30,9 +30,10 @@ function Assert-ThrowsLike { } $generator = Join-Path $Root 'tools/Build-SkillIndex.ps1' +$resolver = Join-Path $Root 'tools/Resolve-SkillWorklist.ps1' $indexSchema = Join-Path $Root 'schemas/skill-index.schema.json' $reportSchema = Join-Path $Root 'schemas/findings-report.schema.json' -foreach ($path in $generator, $indexSchema, $reportSchema) { +foreach ($path in $generator, $resolver, $indexSchema, $reportSchema) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required contract file not found: $path" } @@ -235,9 +236,83 @@ Output. Assert-ThrowsLike -Pattern '*Nested super-skills are not supported*' -Action { & $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'nested.json') } + + Remove-Item -LiteralPath (Join-Path $fixtureSkills 'al-nested-review.md') -Force + $validSuper = @' +--- +kind: action-skill +id: al-code-review +version: 1 +title: Review +description: Test super-skill. +inputs: [file-path] +outputs: [findings-report] +sub-skills: + - microsoft/skills/review/al-leaf-review.md +--- + +# Review + +## Source +Source. +## Relevance +Relevance. +## Worklist +Worklist. +## Action +Action. +## Output +Output. +'@ + Set-Content -LiteralPath $superPath -Value $validSuper -Encoding utf8NoBOM + + $customSkills = Join-Path -Path $fixtureRoot -ChildPath 'custom/skills/review' + New-Item -ItemType Directory -Path $customSkills -Force | Out-Null + $customLeaf = $leaf.Replace('title: Leaf', 'title: Custom Leaf') + Set-Content -LiteralPath (Join-Path $customSkills 'custom-leaf-review.md') -Value $customLeaf -Encoding utf8NoBOM + + $layeredPath = Join-Path $tmp 'layered.json' + & $generator -BCQualityRoot $fixtureRoot -IndexPath $layeredPath | Out-Null + $layeredIndex = Get-Content -LiteralPath $layeredPath -Raw | ConvertFrom-Json + $layeredLeaves = @($layeredIndex.skills | Where-Object id -eq 'al-leaf-review') + if ($layeredLeaves.Count -ne 2) { + throw "Expected both layered al-leaf-review implementations, found $($layeredLeaves.Count)." + } + if ((@($layeredLeaves.layer | Sort-Object) -join ',') -cne 'custom,microsoft') { + throw 'Layered al-leaf-review implementations did not preserve custom and microsoft records.' + } + + $resolved = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath ( + 'microsoft/skills/review/al-code-review.md' + ) + if ($resolved.subSkills.Count -ne 1 -or + $resolved.subSkills[0].path -cne 'custom/skills/review/custom-leaf-review.md') { + throw 'The custom implementation did not win the layered leaf slot.' + } + + $microsoftOnly = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath ( + 'microsoft/skills/review/al-code-review.md' + ) -EnabledLayers microsoft + if ($microsoftOnly.subSkills.Count -ne 1 -or + $microsoftOnly.subSkills[0].path -cne 'microsoft/skills/review/al-leaf-review.md') { + throw 'Disabling the custom layer did not fall back to the Microsoft implementation.' + } + + $customDisabled = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath ( + 'microsoft/skills/review/al-code-review.md' + ) -DisabledSkills 'custom/skills/review/custom-leaf-review.md' + if ($customDisabled.subSkills.Count -ne 1 -or + $customDisabled.subSkills[0].path -cne 'microsoft/skills/review/al-leaf-review.md') { + throw 'Disabling the custom implementation did not fall back to Microsoft.' + } + + Set-Content -LiteralPath (Join-Path $customSkills 'duplicate-leaf-review.md') -Value $customLeaf -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*Duplicate action-skill IDs within a layer: custom:al-leaf-review*' -Action { + & $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'duplicate-layer.json') + } } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } -Write-Output "Skill-index check PASSED: deterministic, schema-valid, and all $($expectedLeaves.Count) review leaves preserved in order." +Write-Output "Skill-index check PASSED: deterministic, schema-valid, layered overrides resolved, and all $($expectedLeaves.Count) review leaves preserved in order." diff --git a/.github/scripts/validate_frontmatter.py b/.github/scripts/validate_frontmatter.py index 20f33d69..51ff546e 100644 --- a/.github/scripts/validate_frontmatter.py +++ b/.github/scripts/validate_frontmatter.py @@ -688,12 +688,16 @@ def run(root: Path) -> Report: if domain_dir.is_dir(): validate_samples_in_domain(domain_dir, root, report) - # Third pass: R24 unique ids within kind + # Third pass: R24 unique ids within kind. Layered action-skill overrides + # may share an id, but two definitions in one layer are ambiguous. by_kind: dict[str, dict[str, list[Path]]] = {} for rec in skill_records: if rec.skill_id is None: continue - by_kind.setdefault(rec.kind, {}).setdefault(rec.skill_id, []).append(rec.path) + scope = rec.kind + if rec.kind == "action-skill": + scope = f"{rec.kind}:{rec.path.relative_to(root).parts[0]}" + by_kind.setdefault(scope, {}).setdefault(rec.skill_id, []).append(rec.path) for kind, by_id in by_kind.items(): for sid, paths in by_id.items(): if len(paths) > 1: diff --git a/.github/workflows/review-fixtures.yml b/.github/workflows/review-fixtures.yml index 0f39d9a0..0de9a022 100644 --- a/.github/workflows/review-fixtures.yml +++ b/.github/workflows/review-fixtures.yml @@ -12,7 +12,26 @@ jobs: steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Collect changed paths + shell: pwsh + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + $paths = if (-not $env:BASE_SHA -or $env:BASE_SHA -match '^0+$') { + @(git diff-tree --no-commit-id --name-only -r $env:GITHUB_SHA) + } else { + @(git diff --name-only $env:BASE_SHA $env:GITHUB_SHA) + } + $paths | Set-Content -LiteralPath "$env:RUNNER_TEMP/changed-paths.txt" -Encoding utf8NoBOM - name: Validate review evaluation corpus shell: pwsh - run: ./tools/Test-ReviewFixtures.ps1 -Root . -PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures" + run: | + ./tools/Test-ReviewFixtures.ps1 -Root . ` + -PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures" ` + -ChangedPathsFile "$env:RUNNER_TEMP/changed-paths.txt" ` + -CoverageReportPath "$env:RUNNER_TEMP/review-coverage.json" + Get-Content -LiteralPath "$env:RUNNER_TEMP/review-coverage.json" diff --git a/docs/customizing-bcquality.md b/docs/customizing-bcquality.md index d3d9148e..4809fcd7 100644 --- a/docs/customizing-bcquality.md +++ b/docs/customizing-bcquality.md @@ -44,6 +44,15 @@ Otherwise the layers are additive. A matching filename alone does not suppress an article; the [READ contract](../skills/read.md#layer-precedence) governs knowledge conflicts. Review reports record displaced knowledge in `suppressed`. +Action skills use the same layer order but override by frontmatter `id`. A +custom leaf with the same `id` as a Community or Microsoft leaf replaces that +leaf in every super-skill slot while its layer is enabled. The files may have +different names. IDs must remain unique within each layer. Disabling the custom +layer or the custom skill path makes composition fall back to the next enabled +implementation. Hosts should build the skill index and use +`tools/Resolve-SkillWorklist.ps1`; they must not implement this selection from +filenames. + Layer selection is **not an access-control boundary**. A plugin installation still contains excluded layers on disk. An integration requiring genuine exclusion must remove denied files from its own content copy before the agent diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 5829e4e9..e5e2a0c4 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -49,16 +49,20 @@ only result. action skills to run. Do not reproduce its routing logic. 3. Execute every dispatched action skill with the exact input subset in its dispatch record. Read `skills/read.md` and `skills/do.md` on demand. -4. When an action skill declares `sub-skills`, execute every relevant leaf as a - discrete invocation. Leaves are independent and may be scheduled serially - or concurrently. +4. When an action skill declares `sub-skills`, resolve its ordered leaf slots + with `tools/Resolve-SkillWorklist.ps1`, passing the enabled layers and + disabled skill paths from the task context. Execute every resolved leaf as + a discrete invocation. Leaves are independent and may be scheduled serially + or concurrently. 5. Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in private artifacts or host logs. Before the full DO acceptance gate, create a normalized candidate only for DO's bounded optional-range case, record that normalization separately in private telemetry, and accept the candidate only if the entire copy passes the - unchanged strict gate. The accepted report contains no undeclared telemetry - fields. + unchanged strict gate. Use `tools/Validate-FindingsReport.ps1`, passing the + exact source paths and fully retrieved article paths; pass `-SkillKind super` + for the final rolled-up report. The accepted report contains no undeclared + telemetry fields. 6. Collect each accepted findings-report into `sub-results` in the declared `sub-skills` order, not completion order. Run the super-skill self-review only after all leaves have finished. @@ -92,6 +96,8 @@ A compatible runner: - invokes every worklisted leaf exactly once unless a documented retry replaces a failed attempt; +- resolves same-ID leaf implementations by `custom > community > microsoft`, + preserves declared slot order, and falls back when a higher layer is disabled; - keeps leaf contexts isolated and passes only the inputs they declare; - preserves each raw Task return unchanged for audit and distinguishes it from any normalized accepted copy; diff --git a/evaluation/README.md b/evaluation/README.md index bce3208f..5863fcc1 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -4,6 +4,15 @@ The evaluation is convention-driven. The harness discovers every `/skills `review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different `article`, add context when the generic convention cannot express a scenario, or use an `articles` array when one domain needs explicit regression coverage for several paired articles. Specify either `article` or `articles`, not both. The first selected article retains the stable `-bad` and `-good` manifest IDs; additional articles use slug-qualified IDs. Overrides should remain empty in the normal case. +CI also measures selected paired articles against every effective article that +has both AL companions. A changed paired article must be selected by the +domain convention or an override. When adding it would not provide a useful +deterministic regression, add a narrow `coverageWaivers` entry with its exact +article path and a non-empty reason. Waivers are reviewable exceptions, not a +substitute for domain coverage. The generated coverage report includes totals +and per-domain ratios; the ratio is informational, while changed-file coverage +is mandatory. + Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer. The SCM `articles` override deliberately selects every rule in the initial @@ -36,6 +45,13 @@ pwsh ./tools/Test-ReviewFixtures.ps1 -Root . This credential-free check proves every selected leaf maps to a same-named knowledge domain with at least one complete AL sample pair and that all configured overrides are valid. +To reproduce the changed-file gate and emit the same measurable report as CI: + +```powershell +git diff --name-only origin/main...HEAD | Set-Content .changed-paths.txt +pwsh ./tools/Test-ReviewFixtures.ps1 -Root . -ChangedPathsFile .changed-paths.txt -CoverageReportPath .coverage.json +``` + ## Run a fast-model evaluation 1. Prepare neutral inputs: diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 3f131a55..90fc7d16 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -3,6 +3,7 @@ "selection": "first-paired-al-article", "minimumExpectedRecall": 1.0, "minimumCleanRate": 1.0, + "coverageWaivers": [], "overrides": { "agents": { "article": "wire-all-three-agent-interfaces" @@ -75,7 +76,14 @@ "reconcile-warehouse-adjustments-with-the-item-ledger", "post-transfers-through-shipment-and-receipt-codeunits", "use-date-aware-availability-for-promising", - "carry-out-requisition-actions-through-the-standard-workflow" + "carry-out-requisition-actions-through-the-standard-workflow", + "derive-base-quantities-through-the-line-unit-of-measure" + ] + }, + "security": { + "articles": [ + "al-has-no-built-in-htmlencode", + "do-not-concatenate-external-text-into-setfilter" ] }, "style": { @@ -88,14 +96,23 @@ "article": "telemetry-event-id-stable-unique" }, "testing": { - "article": "ui-handlers-in-tests" + "articles": [ + "ui-handlers-in-tests", + "reset-per-test-state-before-the-isinitialized-guard" + ] }, "upgrade": { "article": "initvalue-does-not-update-existing-rows", "context": "The extended table existed in the previous app version and already contains rows." }, "web-services": { - "article": "expose-systemid-as-the-api-key" + "articles": [ + "expose-systemid-as-the-api-key", + "handle-httpclient-platform-failure-before-response-access", + "check-http-status-before-consuming-response-body", + "check-json-null-before-converting-values", + "format-exchanged-values-with-standard-format-9" + ] } } } diff --git a/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al new file mode 100644 index 00000000..744ad538 --- /dev/null +++ b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.bad.al @@ -0,0 +1,26 @@ +codeunit 50181 "Scanner Receipt Import Bad" +{ + procedure PostScannedReceipt(ItemNo: Code[20]; LocationCode: Code[10]; UnitOfMeasureCode: Code[10]; ScannedQuantity: Decimal; DocumentNo: Code[20]) + var + ItemJournalLine: Record "Item Journal Line"; + ItemJnlPostLine: Codeunit "Item Jnl.-Post Line"; + begin + if ScannedQuantity <= 0 then + Error(PositiveQuantityErr); + + ItemJournalLine.Init(); + ItemJournalLine.Validate("Posting Date", WorkDate()); + ItemJournalLine.Validate("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt."); + ItemJournalLine.Validate("Document No.", DocumentNo); + ItemJournalLine.Validate("Item No.", ItemNo); + ItemJournalLine.Validate("Location Code", LocationCode); + ItemJournalLine."Unit of Measure Code" := UnitOfMeasureCode; + ItemJournalLine.Quantity := ScannedQuantity; + ItemJournalLine."Quantity (Base)" := ScannedQuantity; + + ItemJnlPostLine.RunWithCheck(ItemJournalLine); + end; + + var + PositiveQuantityErr: Label 'The scanned quantity must be greater than zero.'; +} diff --git a/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al new file mode 100644 index 00000000..6280b592 --- /dev/null +++ b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.good.al @@ -0,0 +1,25 @@ +codeunit 50180 "Scanner Receipt Import Good" +{ + procedure PostScannedReceipt(ItemNo: Code[20]; LocationCode: Code[10]; UnitOfMeasureCode: Code[10]; ScannedQuantity: Decimal; DocumentNo: Code[20]) + var + ItemJournalLine: Record "Item Journal Line"; + ItemJnlPostLine: Codeunit "Item Jnl.-Post Line"; + begin + if ScannedQuantity <= 0 then + Error(PositiveQuantityErr); + + ItemJournalLine.Init(); + ItemJournalLine.Validate("Posting Date", WorkDate()); + ItemJournalLine.Validate("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt."); + ItemJournalLine.Validate("Document No.", DocumentNo); + ItemJournalLine.Validate("Item No.", ItemNo); + ItemJournalLine.Validate("Location Code", LocationCode); + ItemJournalLine.Validate("Unit of Measure Code", UnitOfMeasureCode); + ItemJournalLine.Validate(Quantity, ScannedQuantity); + + ItemJnlPostLine.RunWithCheck(ItemJournalLine); + end; + + var + PositiveQuantityErr: Label 'The scanned quantity must be greater than zero.'; +} diff --git a/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md new file mode 100644 index 00000000..3f3d3a0f --- /dev/null +++ b/microsoft/knowledge/scm/derive-base-quantities-through-the-line-unit-of-measure.md @@ -0,0 +1,37 @@ +--- +bc-version: [all] +domain: scm +keywords: [quantity-base, qty-per-unit-of-measure, unit-of-measure-code, unit-of-measure-management, calcbaseqty, getqtyperunitofmeasure, qty-rounding-precision, item-journal-line] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Derive base quantities through the line's unit of measure + +## Description + +Inventory, item ledger entries, reservations, item tracking, and warehouse quantities are measured in the item's base unit of measure. Document and journal lines hold `Quantity` in the line's `"Unit of Measure Code"`, together with `"Qty. per Unit of Measure"` and base-unit fields such as `"Quantity (Base)"`. Ten boxes of twelve pieces are 120 base units, not 10. When a line's `Quantity` is validated, the table derives the base quantity through its `CalcBaseQty` procedure. That procedure calls `"Unit of Measure Management".CalcBaseQty` with the line's quantity rounding precision and raises an error when rounding would turn a non-zero quantity into a zero base quantity. Code that bypasses this conversion creates a line whose quantity and base quantity disagree, or makes a stock decision in the wrong unit. + +## Best Practice + +On a document or journal line, validate `"Unit of Measure Code"` before `Quantity`, and validate both. Validating the unit of measure sets `"Qty. per Unit of Measure"` from the item unit of measure; validating the quantity then fills the base fields with the correct rounding. Compare line quantities with inventory or availability in base units, for example `"Quantity (Base)"` or `"Outstanding Qty. (Base)"`. + +Outside a line, get the factor with `"Unit of Measure Management".GetQtyPerUnitOfMeasure(Item, UnitOfMeasureCode)` and convert with its `CalcBaseQty` or `CalcQtyFromBase` procedures instead of multiplying by hand. Pass the item unit's quantity rounding precision where the available overload accepts it. + +Reading these fields for display, reporting, or a temporary buffer that is never posted is not a conversion defect. Code that proves the line uses the base unit of measure (`"Qty. per Unit of Measure"` equal to 1) is also correct, but don't assume this from the item alone, because a line can use another unit. + +See sample: [`derive-base-quantities-through-the-line-unit-of-measure.good.al`](derive-base-quantities-through-the-line-unit-of-measure.good.al). + +## Anti Pattern + +Assigning `Quantity` directly on an item journal, sales, purchase, or transfer line and then inserting, modifying, or posting it. Also assigning a base field such as `"Quantity (Base)" := Quantity`, multiplying by a hard-coded or separately looked-up factor without the line's rounding, or comparing a line's `Quantity` with `Item.Inventory` or another base-unit value. Detection signal: a direct `:=` to `Quantity`, `"Qty. per Unit of Measure"`, or a `(Base)` quantity field on a persisted or posted line, or a comparison between a non-base line quantity and an inventory quantity. + +See sample: [`derive-base-quantities-through-the-line-unit-of-measure.bad.al`](derive-base-quantities-through-the-line-unit-of-measure.bad.al). + +## References + +- [Set up units of measure, including quantity rounding precision](https://learn.microsoft.com/en-us/dynamics365/business-central/inventory-how-setup-units-of-measure) +- [BCApps: Unit of Measure Management conversions](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Foundation/UOM/UnitofMeasureManagement.Codeunit.al) +- [BCApps: Item Journal Line quantity validation and CalcBaseQty](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Inventory/Journal/ItemJournalLine.Table.al) +- [BCApps: Sales Line quantity validation and CalcBaseQty](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al) diff --git a/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.bad.al b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.bad.al new file mode 100644 index 00000000..ef67908a --- /dev/null +++ b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.bad.al @@ -0,0 +1,11 @@ +codeunit 50161 "Cancel External Quotes Bad" +{ + procedure CancelQuote(ExternalDocumentNo: Text) + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote); + SalesHeader.SetFilter("External Document No.", ExternalDocumentNo); + SalesHeader.DeleteAll(true); + end; +} diff --git a/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.good.al b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.good.al new file mode 100644 index 00000000..329bc09d --- /dev/null +++ b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.good.al @@ -0,0 +1,25 @@ +codeunit 50160 "Cancel External Quotes Good" +{ + procedure CancelQuote(ExternalDocumentNo: Code[35]) + var + SalesHeader: Record "Sales Header"; + begin + if ExternalDocumentNo = '' then + Error(MissingExternalDocumentNoErr); + + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote); + SalesHeader.SetRange("External Document No.", ExternalDocumentNo); + SalesHeader.DeleteAll(true); + end; + + procedure CancelQuotes(ExternalDocumentNos: List of [Code[35]]) + var + ExternalDocumentNo: Code[35]; + begin + foreach ExternalDocumentNo in ExternalDocumentNos do + CancelQuote(ExternalDocumentNo); + end; + + var + MissingExternalDocumentNoErr: Label 'The external document number is missing.'; +} diff --git a/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.md b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.md new file mode 100644 index 00000000..df6a87f2 --- /dev/null +++ b/microsoft/knowledge/security/do-not-concatenate-external-text-into-setfilter.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: security +keywords: [setfilter, setrange, filter-expression, filter-injection, external-input, wildcard, deleteall, modifyall] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not concatenate external text into a SetFilter expression + +## Description + +The `String` argument of `SetFilter` is a filter expression, not a value. Characters such as `..`, `|`, `&`, `<`, `>`, `=`, `*`, `?`, `@`, parentheses, and single quotes are operators. Text from a user, request page, API payload, file, or another system that is concatenated into that expression can therefore change which records match: `*` matches every value, `A|B` widens an exact lookup to two values, `10000..` becomes a range, and `J & V` becomes a conjunction or an invalid filter. `SetFilter` with an empty expression applies no filter at all. When the filtered record is then modified, deleted, exported, or used for a permission-relevant decision, the procedure acts on records the caller never identified. + +## Best Practice + +Treat an externally supplied identifier as a value. Use `SetRange(Field, Value)` for equality and `SetRange(Field, FromValue, ToValue)` for a typed range; neither parses operators. Reject an empty identifier explicitly when "no value" must not mean "every record". For several external values, apply `SetRange` once per value instead of joining them with `|`. + +Use `SetFilter` when an operator is part of the procedure's own contract. Keep the operator in a constant expression and pass operands through replacement fields (`%1`, `%2`) of the field's data type, such as a `Date` or `Decimal` operand for `'>=%1'`. Do not rely on wrapping text in single quotes to neutralize it: according to the filter syntax, quotes protect `&`, `(`, `)`, `=`, and `|`, but `*` still acts as a wildcard inside `'J & V*'`. + +Text that the user deliberately entered as a filter is supposed to be parsed. Do not report a request-page or `FilterPageBuilder` filter, a `GetFilters`/`GetView` round trip, a FlowFilter, or a field whose documented purpose is to hold a filter expression. Constant filter strings and operands produced by the extension's own code are also not external input. + +See sample: [`do-not-concatenate-external-text-into-setfilter.good.al`](do-not-concatenate-external-text-into-setfilter.good.al). + +## Anti Pattern + +`Rec.SetFilter(Field, ExternalText)` or `Rec.SetFilter(Field, Prefix + ExternalText + Suffix)` where the text is meant to identify one record or a known list of records, with no validation that restricts it to a literal value. The finding is strongest when the resulting set is written by `Modify`, `ModifyAll`, `Delete`, or `DeleteAll`, or is returned to an external caller. Detection signal: a non-constant expression, concatenation, or `StrSubstNo` result passed as the `String` argument of `SetFilter`, where the operand comes from a parameter, page field, JSON/XML value, file line, or HTTP request. + +See sample: [`do-not-concatenate-external-text-into-setfilter.bad.al`](do-not-concatenate-external-text-into-setfilter.bad.al). + +## References + +- [Record.SetFilter method, including the empty-filter remark](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-setfilter-method) +- [Filtering with SetRange and SetFilter](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-setcurrentkey-setrange-setfilter-getrangemin-and-getrangemax-methods) +- [Filter criteria, operators, and values that contain symbols](https://learn.microsoft.com/en-us/dynamics365/business-central/ui-enter-criteria-filters#filter-criteria-and-operators) diff --git a/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al new file mode 100644 index 00000000..afebd56f --- /dev/null +++ b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.bad.al @@ -0,0 +1,57 @@ +codeunit 50411 "Test Sales Setup Initialize Bad" +{ + Subtype = Test; + + [Test] + [HandlerFunctions('CustomerCardHandler')] + procedure CustomerCardOpensForSelectedCustomer() + var + Customer: Record Customer; + begin + Initialize(); + LibrarySales.CreateCustomer(Customer); + LibraryVariableStorage.Enqueue(Customer."No."); + + Page.RunModal(Page::"Customer Card", Customer); + + LibraryVariableStorage.AssertEmpty(); + end; + + [Test] + procedure StockoutWarningCanBeDisabled() + var + SalesSetup: Record "Sales & Receivables Setup"; + begin + Initialize(); + + LibrarySales.SetStockoutWarning(false); + + SalesSetup.Get(); + Assert.IsFalse(SalesSetup."Stockout Warning", 'The stockout warning was not disabled.'); + end; + + local procedure Initialize() + begin + if IsInitialized then + exit; + + LibraryVariableStorage.Clear(); + LibrarySetupStorage.Restore(); + LibrarySales.SetStockoutWarning(true); + IsInitialized := true; + LibrarySetupStorage.SaveSalesSetup(); + end; + + [ModalPageHandler] + procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card") + begin + Assert.AreEqual(LibraryVariableStorage.DequeueText(), CustomerCard."No.".Value(), 'The customer card opened for the wrong customer.'); + end; + + var + Assert: Codeunit Assert; + LibrarySales: Codeunit "Library - Sales"; + LibrarySetupStorage: Codeunit "Library - Setup Storage"; + LibraryVariableStorage: Codeunit "Library - Variable Storage"; + IsInitialized: Boolean; +} diff --git a/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al new file mode 100644 index 00000000..0d1d7c79 --- /dev/null +++ b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.good.al @@ -0,0 +1,62 @@ +codeunit 50410 "Test Sales Setup Initialize Good" +{ + Subtype = Test; + + [Test] + [HandlerFunctions('CustomerCardHandler')] + procedure CustomerCardOpensForSelectedCustomer() + var + Customer: Record Customer; + begin + Initialize(); + LibrarySales.CreateCustomer(Customer); + LibraryVariableStorage.Enqueue(Customer."No."); + + Page.RunModal(Page::"Customer Card", Customer); + + LibraryVariableStorage.AssertEmpty(); + end; + + [Test] + procedure StockoutWarningCanBeDisabled() + var + SalesSetup: Record "Sales & Receivables Setup"; + begin + Initialize(); + + LibrarySales.SetStockoutWarning(false); + + SalesSetup.Get(); + Assert.IsFalse(SalesSetup."Stockout Warning", 'The stockout warning was not disabled.'); + end; + + local procedure Initialize() + begin + LibraryTestInitialize.OnTestInitialize(Codeunit::"Test Sales Setup Initialize Good"); + LibraryVariableStorage.Clear(); + LibrarySetupStorage.Restore(); + + if IsInitialized then + exit; + LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Test Sales Setup Initialize Good"); + + LibrarySales.SetStockoutWarning(true); + IsInitialized := true; + LibrarySetupStorage.SaveSalesSetup(); + LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Test Sales Setup Initialize Good"); + end; + + [ModalPageHandler] + procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card") + begin + Assert.AreEqual(LibraryVariableStorage.DequeueText(), CustomerCard."No.".Value(), 'The customer card opened for the wrong customer.'); + end; + + var + Assert: Codeunit Assert; + LibrarySales: Codeunit "Library - Sales"; + LibrarySetupStorage: Codeunit "Library - Setup Storage"; + LibraryTestInitialize: Codeunit "Library - Test Initialize"; + LibraryVariableStorage: Codeunit "Library - Variable Storage"; + IsInitialized: Boolean; +} diff --git a/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md new file mode 100644 index 00000000..2d2c4172 --- /dev/null +++ b/microsoft/knowledge/testing/reset-per-test-state-before-the-isinitialized-guard.md @@ -0,0 +1,41 @@ +--- +bc-version: [all] +domain: testing +keywords: [initialize, isinitialized, library-test-initialize, ontestinitialize, library-variable-storage, library-setup-storage, test-fixture, test-codeunit] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reset per-test state before the IsInitialized guard + +## Description + +Standard Business Central test codeunits call a local `Initialize` procedure at the start of every test method. Global variables in a test codeunit keep their values between the codeunit's test methods, so a Boolean such as `IsInitialized` lets `Initialize` run expensive shared setup only once. The procedure therefore has two parts with different lifetimes: work that must run before **every** test, and one-time setup behind the guard. If per-test reset is placed after the guard, it runs only for the first test. Values left in `Library - Variable Storage` by a failed test, or setup records a test changed, then leak into later tests, which pass or fail depending on execution order. + +## Best Practice + +Call `Initialize()` as the first statement of every test method. Inside it, keep this order, which the Base Application tests follow: + +1. Per-test work, before the guard: raise `"Library - Test Initialize".OnTestInitialize`, call `LibraryVariableStorage.Clear()`, and call `LibrarySetupStorage.Restore()` when setup tables were saved. +2. `if IsInitialized then exit;` +3. One-time work: raise `OnBeforeTestSuiteInitialize`, create the shared fixture and setup values, set `IsInitialized := true`, save the setup tables that tests may change (for example `LibrarySetupStorage.SaveSalesSetup()`), and raise `OnAfterTestSuiteInitialize`. + +Create data that a single test changes inside that test, not in the shared fixture. Base Application suites also commit after the one-time setup so the shared fixture survives each test's transaction; whether that commit is valid depends on the test transaction model and runner isolation, see [`transactionmodel-attribute-governs-test-transactions.md`](transactionmodel-attribute-governs-test-transactions.md) and [`testisolation-belongs-on-the-test-runner.md`](testisolation-belongs-on-the-test-runner.md). + +A test codeunit with no shared setup and no queued values doesn't need an `Initialize` procedure. Don't report its absence on its own. + +See sample: [`reset-per-test-state-before-the-isinitialized-guard.good.al`](reset-per-test-state-before-the-isinitialized-guard.good.al). + +## Anti Pattern + +`if IsInitialized then exit;` as the first statement of `Initialize`, followed by `LibraryVariableStorage.Clear()`, `LibrarySetupStorage.Restore()`, or other reset calls that are then skipped for every test after the first. A related defect is a test method in a codeunit that uses the pattern but doesn't call `Initialize()`, so it runs with whatever state the previous test left. Detection signal: in a `Subtype = Test` codeunit, a reset call placed after the `IsInitialized` exit, or a `[Test]` procedure that uses shared globals or queued values without first calling `Initialize()`. + +See sample: [`reset-per-test-state-before-the-isinitialized-guard.bad.al`](reset-per-test-state-before-the-isinitialized-guard.bad.al). + +## References + +- [BCApps: `Initialize` in the ERM Sales Document tests](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/Tests/ERM-Sales/ERMSalesDocument.Codeunit.al) +- [BCApps: Library - Test Initialize events](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/Tests/ApplicationTestLibrary/LibraryTestInitialize.Codeunit.al) +- [BCApps: Library - Setup Storage](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/Tests/ApplicationTestLibrary/LibrarySetupStorage.Codeunit.al) +- [Testing the application](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-testing-application) diff --git a/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.bad.al b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.bad.al new file mode 100644 index 00000000..847045b1 --- /dev/null +++ b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.bad.al @@ -0,0 +1,21 @@ +codeunit 50100 "HTTP Status Handling Bad" +{ + procedure GetCustomer(CustomerId: Guid): JsonObject + var + Client: HttpClient; + Response: HttpResponseMessage; + CustomerJson: JsonObject; + ResponseText: Text; + begin + if not Client.Get( + StrSubstNo('https://api.example.com/customers/%1', CustomerId), + Response) + then + Error('The customer service could not be reached.'); + + // A completed request can still contain a 4xx or 5xx error document. + Response.Content().ReadAs(ResponseText); + CustomerJson.ReadFrom(ResponseText); + exit(CustomerJson); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al new file mode 100644 index 00000000..e15682b2 --- /dev/null +++ b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.good.al @@ -0,0 +1,23 @@ +codeunit 50100 "HTTP Status Handling Good" +{ + procedure GetCustomer(CustomerId: Guid): JsonObject + var + Client: HttpClient; + Response: HttpResponseMessage; + CustomerJson: JsonObject; + ResponseText: Text; + begin + if not Client.Get( + StrSubstNo('https://api.example.com/customers/%1', CustomerId), + Response) + then + Error('The customer service could not be reached.'); + + if not Response.IsSuccessStatusCode() then + Error('The customer service returned HTTP status %1.', Response.HttpStatusCode()); + + Response.Content().ReadAs(ResponseText); + CustomerJson.ReadFrom(ResponseText); + exit(CustomerJson); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.md b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.md new file mode 100644 index 00000000..f9248707 --- /dev/null +++ b/microsoft/knowledge/web-services/check-http-status-before-consuming-response-body.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: web-services +keywords: [httpclient, httpresponsemessage, issuccessstatuscode, httpstatuscode, response-body, json] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Check HTTP status before consuming the response body + +## Description + +A successful AL `HttpClient` call only confirms that the platform completed the HTTP exchange. The server can still return `4xx` or `5xx`, often with an error document whose shape differs from the expected success payload. Parsing that body as business data can produce misleading parse errors, incomplete records, or decisions based on an error response. + +## Best Practice + +After handling any platform or transport failure, check `HttpResponseMessage.IsSuccessStatusCode()` or the expected `HttpStatusCode()` before interpreting the response body as a success payload. Handle non-success status explicitly and include safe diagnostic context when appropriate. A bounded error body may be read for diagnostics, but it must not enter the success parsing path. + +See sample: [`check-http-status-before-consuming-response-body.good.al`](check-http-status-before-consuming-response-body.good.al). + +## Anti Pattern + +Checking only the Boolean result of `Get`, `Post`, `Put`, `Delete`, or `Send` and then parsing `Response.Content()` as the expected payload. The Boolean can be `true` for any HTTP status, including authentication failures, throttling, validation errors, and server failures. + +See sample: [`check-http-status-before-consuming-response-body.bad.al`](check-http-status-before-consuming-response-body.bad.al). + +## References + +- [HttpResponseMessage.IsSuccessStatusCode method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/httpresponsemessage/httpresponsemessage-issuccessstatuscode-method) +- [HttpClient data type](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/httpclient/httpclient-data-type) \ No newline at end of file diff --git a/microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al b/microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al new file mode 100644 index 00000000..d52ebf91 --- /dev/null +++ b/microsoft/knowledge/web-services/check-json-null-before-converting-values.bad.al @@ -0,0 +1,11 @@ +codeunit 50173 "Contact Payload Reader Bad" +{ + procedure ApplyPayload(var Contact: Record Contact; Payload: JsonObject) + var + Token: JsonToken; + begin + if Payload.Get('email', Token) then + Contact.Validate("E-Mail", CopyStr(Token.AsValue().AsText(), 1, MaxStrLen(Contact."E-Mail"))); + Contact.Modify(true); + end; +} diff --git a/microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al b/microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al new file mode 100644 index 00000000..91f83d5c --- /dev/null +++ b/microsoft/knowledge/web-services/check-json-null-before-converting-values.good.al @@ -0,0 +1,28 @@ +codeunit 50172 "Contact Payload Reader Good" +{ + procedure ApplyPayload(var Contact: Record Contact; Payload: JsonObject) + var + EmailAddress: Text; + begin + if TryGetText(Payload, 'email', EmailAddress) then + Contact.Validate("E-Mail", CopyStr(EmailAddress, 1, MaxStrLen(Contact."E-Mail"))); + Contact.Modify(true); + end; + + local procedure TryGetText(Payload: JsonObject; PropertyName: Text; var Value: Text): Boolean + var + Token: JsonToken; + begin + if not Payload.Get(PropertyName, Token) then + exit(false); + if not Token.IsValue() then + Error(NotAValueErr, PropertyName); + if Token.AsValue().IsNull() then + exit(false); + Value := Token.AsValue().AsText(); + exit(true); + end; + + var + NotAValueErr: Label 'The property %1 must contain a single value.', Comment = '%1 = JSON property name'; +} diff --git a/microsoft/knowledge/web-services/check-json-null-before-converting-values.md b/microsoft/knowledge/web-services/check-json-null-before-converting-values.md new file mode 100644 index 00000000..b5a560e4 --- /dev/null +++ b/microsoft/knowledge/web-services/check-json-null-before-converting-values.md @@ -0,0 +1,35 @@ +--- +bc-version: [all] +domain: web-services +keywords: [jsonobject, jsontoken, jsonvalue, isnull, asvalue, astext, asdecimal, optional-property, json-null, payload-parsing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Check for JSON null before converting a value + +## Description + +An optional property in a JSON payload can be missing or present with the value `null`, and the two cases behave differently in AL. When the Boolean result is captured, `JsonObject.Get` returns `false` for a missing key but `true` for `"email": null`, because the key exists. The conversion methods on `JsonValue` (`AsText`, `AsCode`, `AsDecimal`, `AsInteger`, `AsDate`, `AsBoolean`, and the others) fail with a runtime error when the value is `NULL` or `UNDEFINED`. Code that guards only with `Get` therefore passes its tests with the property omitted and fails in production when the sender serializes an empty field as `null`, which many services do by default. + +## Best Practice + +For every property that the contract allows to be optional or nullable, check three things before converting: that `Get` (or `SelectToken`) returned `true`, that the token `IsValue()` rather than an object or array, and that `AsValue().IsNull()` is `false`. Put this in one small helper per target type and decide explicitly what a missing or null property means: a default, leaving the field unchanged, or a validation error that names the property. + +Required properties can still fail fast, but with an error that states the missing or null property instead of a generic conversion error. Keep a numeric or date conversion strict when the contract says the value must be a number or a date; `IsNull` covers only `null`, not a value of the wrong type. + +See sample: [`check-json-null-before-converting-values.good.al`](check-json-null-before-converting-values.good.al). + +## Anti Pattern + +`if Json.Get('email', Token) then Email := Token.AsValue().AsText();` or an unguarded `Token.AsValue().AsDecimal()` on a property that the external contract allows to be `null`. The `Get` check makes the code look defensive, but it doesn't handle a present `null`. Detection signal: an `As()` call on a `JsonValue` obtained from an external payload with no preceding `IsNull()` check on the same token, where the property isn't documented as always non-null. + +See sample: [`check-json-null-before-converting-values.bad.al`](check-json-null-before-converting-values.bad.al). + +## References + +- [JsonObject.Get method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonobject/jsonobject-get-method) +- [JsonValue.AsText method: fails on NULL or UNDEFINED](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonvalue/jsonvalue-astext-method) +- [JsonValue.IsNull method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonvalue/jsonvalue-isnull-method) +- [JsonToken data type](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsontoken/jsontoken-data-type) diff --git a/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al new file mode 100644 index 00000000..3e36c871 --- /dev/null +++ b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.bad.al @@ -0,0 +1,27 @@ +codeunit 50171 "Exchange Rate Export Bad" +{ + procedure SendRate(CurrencyCode: Code[10]; StartingDate: Date; ExchangeRate: Decimal) + var + Client: HttpClient; + Response: HttpResponseMessage; + RequestUrl: Text; + begin + RequestUrl := StrSubstNo(RateUrlTok, CurrencyCode, Format(StartingDate), Format(ExchangeRate)); + if not Client.Get(RequestUrl, Response) then + Error(RequestFailedErr); + if not Response.IsSuccessStatusCode() then + Error(RateRejectedErr, Response.HttpStatusCode()); + end; + + procedure ReadRate(RateText: Text) ExchangeRate: Decimal + begin + if not Evaluate(ExchangeRate, RateText) then + Error(InvalidRateErr, RateText); + end; + + var + RateUrlTok: Label 'https://rates.example.com/rates?currency=%1&date=%2&rate=%3', Locked = true; + RequestFailedErr: Label 'The exchange rate service could not be reached.'; + RateRejectedErr: Label 'The exchange rate service rejected the rate. Status code: %1.', Comment = '%1 = HTTP status code'; + InvalidRateErr: Label 'The exchange rate %1 is not a valid decimal number.', Comment = '%1 = received value'; +} diff --git a/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al new file mode 100644 index 00000000..21d6a22b --- /dev/null +++ b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.good.al @@ -0,0 +1,27 @@ +codeunit 50170 "Exchange Rate Export Good" +{ + procedure SendRate(CurrencyCode: Code[10]; StartingDate: Date; ExchangeRate: Decimal) + var + Client: HttpClient; + Response: HttpResponseMessage; + RequestUrl: Text; + begin + RequestUrl := StrSubstNo(RateUrlTok, CurrencyCode, Format(StartingDate, 0, 9), Format(ExchangeRate, 0, 9)); + if not Client.Get(RequestUrl, Response) then + Error(RequestFailedErr); + if not Response.IsSuccessStatusCode() then + Error(RateRejectedErr, Response.HttpStatusCode()); + end; + + procedure ReadRate(RateText: Text) ExchangeRate: Decimal + begin + if not Evaluate(ExchangeRate, RateText, 9) then + Error(InvalidRateErr, RateText); + end; + + var + RateUrlTok: Label 'https://rates.example.com/rates?currency=%1&date=%2&rate=%3', Locked = true; + RequestFailedErr: Label 'The exchange rate service could not be reached.'; + RateRejectedErr: Label 'The exchange rate service rejected the rate. Status code: %1.', Comment = '%1 = HTTP status code'; + InvalidRateErr: Label 'The exchange rate %1 is not a valid decimal number.', Comment = '%1 = received value'; +} diff --git a/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md new file mode 100644 index 00000000..adc1daec --- /dev/null +++ b/microsoft/knowledge/web-services/format-exchanged-values-with-standard-format-9.md @@ -0,0 +1,39 @@ +--- +bc-version: [all] +domain: web-services +keywords: [format, evaluate, standard-format-9, xml-format, locale, regional-settings, decimal-separator, data-exchange, integration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Format exchanged values with standard format 9 + +## Description + +`Format(Value)` uses standard format 0, the display format, and follows the current user's regional settings. The same decimal renders as `-76.543,21` for a European region and `-76,543.21` for English (US); the same date renders as `05-04-21` or `04/05/21`. Text built this way and sent outside Business Central (an HTTP query string or body, an XML or CSV file, a signature or hash input, or an external key) changes with the user or job queue session that produces it. The receiver can reject it or, worse, misread it. `Evaluate` without a format number has the same dependency when it parses machine-generated text. + +## Best Practice + +Use `Format(Value, 0, 9)` for machine-readable text. Standard format 9 is the XML format and doesn't depend on the region: `-76543.21` for a decimal, `2021-04-05` for a date, `04:35:55.553` for a time, `true`/`false` for a Boolean, and a UTC `DateTime` such as `2021-04-05T03:35:55.553Z`. Parse such text with `Evaluate(Variable, Text, 9)`. + +Prefer typed APIs when they exist. `JsonObject.Add` and `JsonValue.SetValue` with a `Decimal`, `Date`, or `Boolean` argument write a JSON value without going through display text. An XMLport handles this with `FormatEvaluate = Xml`. + +For `Enum` and `Option` values, format 9 produces the ordinal number, not the name. When the external contract exchanges names, map them explicitly; see [`api-enum-values-are-a-contract-by-name-not-ordinal.md`](api-enum-values-are-a-contract-by-name-not-ordinal.md). + +Text shown to a person (messages, captions, report columns, notifications) should keep the regional display format. `Code`, `Text`, and `Guid` values don't need format 9 because their standard formats don't vary by region. + +See sample: [`format-exchanged-values-with-standard-format-9.good.al`](format-exchanged-values-with-standard-format-9.good.al). + +## Anti Pattern + +`Format(Amount)`, `Format(PostingDate)`, or `Format(SomeDateTime)` concatenated into a URL, request body, XML or CSV line, file name, or hash input. Passing the `Decimal` or `Date` itself to `StrSubstNo` for such text has the same effect, because `StrSubstNo` formats it with the display format. Also `Evaluate(DecimalOrDateVariable, ExternalText)` without format number 9 on text received from another system. The code usually works for the developer's own region and fails for users or job queue sessions in another one. Detection signal: `Format` with one argument, or with a format number other than 9, applied to a `Decimal`, `Date`, `Time`, `DateTime`, or `Boolean` on a path that writes to an `HttpContent`, `HttpRequestMessage`, `OutStream`, `XmlDocument`, or file. + +See sample: [`format-exchanged-values-with-standard-format-9.bad.al`](format-exchanged-values-with-standard-format-9.bad.al). + +## References + +- [Formatting values, dates, and time: standard formats by region and format 9](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-format-property) +- [System.Format method](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/system/system-format-joker-integer-integer-method) +- [System.Evaluate method and format number 9](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/system/system-evaluate-method) +- [FormatEvaluate property for XMLports](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-formatevaluate-property) diff --git a/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al new file mode 100644 index 00000000..8ac6d6a6 --- /dev/null +++ b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.bad.al @@ -0,0 +1,18 @@ +codeunit 50100 "Http Platform Failure Bad" +{ + procedure GetCustomer(CustomerId: Guid): Text + var + Client: HttpClient; + Response: HttpResponseMessage; + ResponseText: Text; + RequestSucceeded: Boolean; + begin + RequestSucceeded := Client.Get( + StrSubstNo('https://api.example.com/customers/%1', CustomerId), + Response); + + // Response content is unavailable when the platform call failed. + Response.Content().ReadAs(ResponseText); + exit(ResponseText); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al new file mode 100644 index 00000000..019f0be1 --- /dev/null +++ b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.good.al @@ -0,0 +1,18 @@ +codeunit 50100 "Http Platform Failure Good" +{ + procedure GetCustomer(CustomerId: Guid): Text + var + Client: HttpClient; + Response: HttpResponseMessage; + ResponseText: Text; + begin + if not Client.Get( + StrSubstNo('https://api.example.com/customers/%1', CustomerId), + Response) + then + Error('The customer service could not be reached.'); + + Response.Content().ReadAs(ResponseText); + exit(ResponseText); + end; +} \ No newline at end of file diff --git a/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md new file mode 100644 index 00000000..e3dd227f --- /dev/null +++ b/microsoft/knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: web-services +keywords: [httpclient, transport-failure, boolean-return, httpresponsemessage, content, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Handle HttpClient platform failure before accessing the response + +## Description + +AL `HttpClient` methods can fail before a usable HTTP response exists because of an invalid request, DNS or network failure, certificate validation, timeout, a disabled extension setting, or the response-size limit. When code captures the optional Boolean return value, `false` reports this platform or transport failure. The accompanying `HttpResponseMessage` is not safe to consume; accessing its content after the failed call can raise another error and obscure the original failure. + +## Best Practice + +When capturing the Boolean return value from `Get`, `Post`, `Put`, `Delete`, or `Send`, stop the current response-processing path immediately when it is `false`. Report or propagate the transport failure without reading status, headers, or content. Omitting the optional Boolean is also valid when fail-fast behavior is intended: the runtime then raises an error if the operation cannot execute. + +See sample: [`handle-httpclient-platform-failure-before-response-access.good.al`](handle-httpclient-platform-failure-before-response-access.good.al). + +## Anti Pattern + +Capturing a failed call in a Boolean and then reading `Response.Content()`, parsing the body, or otherwise treating `Response` as usable. Do not report omission of the Boolean by itself; that form deliberately delegates failure propagation to the runtime. + +See sample: [`handle-httpclient-platform-failure-before-response-access.bad.al`](handle-httpclient-platform-failure-before-response-access.bad.al). + +## References + +- [HttpClient.Send method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/httpclient/httpclient-send-method) +- [Call external services with HttpClient](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-httpclient) \ No newline at end of file diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md index df476f3d..7a3d0c60 100644 --- a/microsoft/skills/review/al-code-review.md +++ b/microsoft/skills/review/al-code-review.md @@ -46,8 +46,12 @@ The sub-skills invoked by this skill are those listed in frontmatter `sub-skills Hosts that orchestrate leaves mechanically SHOULD run `tools/Build-SkillIndex.ps1` and resolve this skill by `id: al-code-review`. -The generated `subSkills` array preserves the frontmatter order and avoids -host-specific Markdown parsing. +The generated `subSkills` array preserves the frontmatter slot order and +avoids host-specific Markdown parsing. Before invoking leaves, run +`tools/Resolve-SkillWorklist.ps1` with this skill's path and the task's enabled +layers and disabled skill paths. Each declared path supplies a leaf `id`; the +resolver selects the highest-precedence enabled implementation with that `id` +without changing slot order. ## Relevance diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md index 56bc0fe4..a3408079 100644 --- a/microsoft/skills/review/al-error-handling-review.md +++ b/microsoft/skills/review/al-error-handling-review.md @@ -22,6 +22,13 @@ An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-pat Use READ's **Bounded retrieval for review skills** workflow with `-Domain error-handling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback. +When the review scope contains outbound `HttpClient.Get` or `HttpClient.Post` calls, including resolved call paths, also retrieve every catalog page with `-Domain web-services`, using the same known task dimensions and enabled layers. Restrict supplementary candidates to these article slugs across enabled layers; the links identify their canonical owners: + +- [`handle-httpclient-platform-failure-before-response-access`](../../knowledge/web-services/handle-httpclient-platform-failure-before-response-access.md) +- [`check-http-status-before-consuming-response-body`](../../knowledge/web-services/check-http-status-before-consuming-response-body.md) + +Retain each selected catalog row's exact `path`; do not invent paths for missing, pruned, or disabled entries. Apply this leaf's Relevance, Worklist, and READ layer precedence to the supplementary candidates before retrieving complete bodies with `Get-KnowledgeArticles.ps1`. Apply each selected article's own scope and exceptions when evaluating code and agent-finding candidates. Use READ's bounded path-discovery fallback over these same sources when needed. This supplements error-handling knowledge, not the scope of the review with unrelated web-services concerns. + ## Relevance Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: @@ -40,6 +47,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially codeunits that post or validate, tables and table extensions with `OnValidate` triggers, and any procedure that raises errors or orchestrates a batch over records. - The changed procedures and triggers, weighted toward `OnValidate`/`OnInsert`/`OnModify` triggers, posting and validation routines, and procedures attributed with `[ErrorBehavior(...)]` or `[TryFunction]`. - Tokens extracted from the diff that relate to error surfacing and diagnostics (`Error`, `ErrorInfo`, `FieldError`, `TestField`, `Title`, `Message`, `DetailedMessage`, `AddAction`, `AddNavigationAction`, `RecordId`, `PageNo`, `ErrorBehavior`, `Collect`, `HasCollectedErrors`, `GetCollectedErrors`, `ClearCollectedErrors`, `ErrorType`, `Internal`, `Client`, `TryFunction`, `GetLastErrorText`, Boolean assignment). +- For the outbound HTTP call paths identified in Source, include `HttpClient`, `Get`, `Post`, `HttpResponseMessage`, response use, and caller failure handling (including `[TryFunction]` call sites) in keyword and topic matching. - Resolve changed standalone call targets; when the target declaration has `[TryFunction]`, worklist the ignored-return rule even if the declaration itself is unchanged. Only assignment and conditional use activate try semantics. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. diff --git a/microsoft/skills/review/al-scm-review.md b/microsoft/skills/review/al-scm-review.md index a4345292..8e22e2c5 100644 --- a/microsoft/skills/review/al-scm-review.md +++ b/microsoft/skills/review/al-scm-review.md @@ -77,6 +77,7 @@ paths; they select articles, not findings. Facts and exceptions stay in articles | Registered warehouse quantity/physical-adjustment synchronization, `"Directed Put-away and Pick"`, `"Adjustment Bin Code"`, `"Warehouse Adjustment"`, or `"Calculate Whse. Adjustment"` and the resulting item-journal posting | `reconcile-warehouse-adjustments-with-the-item-ledger` | | `"Transfer Header"`/`"Transfer Line"` shipment/receipt completion, transfer posting publishers, in-transit/document-link changes, or item-journal posting presented as transfer-order completion | `post-transfers-through-shipment-and-receipt-codeunits` | | `Inventory`, `CalcQtyAvailableToPromise`, or stock sums used in a dated supply/demand promise, including changed location/variant/date filters and source-demand context | `use-date-aware-availability-for-promising` | +| Direct assignment to `Quantity`, `"Unit of Measure Code"`, `"Qty. per Unit of Measure"`, or a `(Base)` quantity field on a persisted or posted item journal, sales, purchase, or transfer line, or a line quantity compared with a base-unit inventory value | `derive-base-quantities-through-the-line-unit-of-measure` | | `"Requisition Line"` action-message execution, accepted planning suggestions, `"Req. Wksh.-Make Order"`, `CarryOutBatchAction`, or linked supply creation/change plus requisition-line deletion | `carry-out-requisition-actions-through-the-standard-workflow` | Route clean supported calls through the same cues, not just suspicious writes. diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md index 00e8d103..e18d3fcd 100644 --- a/microsoft/skills/review/al-security-review.md +++ b/microsoft/skills/review/al-security-review.md @@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially permission sets, codeunits handling authentication or authorization, objects touching `Isolated Storage`, `OAuth2` flows, web service endpoints, API pages, event publishers, and RecordRef helpers. - The changed procedures and triggers, weighted toward those that call `HttpClient`, validate or compose URLs, write to telemetry, read or write secrets, unwrap SecretText, manipulate record-level security, expose var Boolean guard parameters, or bypass the permission model (for example, `RecordRef.Open`, `Record.WritePermission`, direct table access from a non-owning app). -- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `TransferFields`, `Codeunit.Run`, `Access = Internal`, `internalsVisibleTo`, `Open`, `IntegrationEvent`, `SkipValidation`, `HasAccess`, `Permission`, `UserSecurityId`, `Commit`). +- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `TransferFields`, `Codeunit.Run`, `Access = Internal`, `internalsVisibleTo`, `Open`, `IntegrationEvent`, `SkipValidation`, `HasAccess`, `Permission`, `UserSecurityId`, `Commit`, `SetFilter`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md index 6986977d..2adbc133 100644 --- a/microsoft/skills/review/al-testing-review.md +++ b/microsoft/skills/review/al-testing-review.md @@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially codeunits with `Subtype = Test`, test runner codeunits with `TestIsolation`, test libraries, and codeunits that define UI handlers. - The changed methods and attributes, weighted toward `[Test]`, `[TransactionModel(...)]`, `[TestPermissions(...)]`, `[HandlerFunctions(...)]`, handler attributes, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, fixture initialization, and test-library calls. -- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Library - Utility`, `LibraryUtility`, `GenerateGUID`, `GenerateRandomCode`, `TestPage`, `.Visible(`, `.Enabled(`, `.Editable(`, `OpenNew`, `OpenView`, `OpenEdit`, `Init`, `Insert`). +- Tokens extracted from the diff that relate to testing (`Subtype = Test`, `Subtype = TestRunner`, `TestIsolation`, `TestPermissions`, `Restrictive`, `NonRestrictive`, `Disabled`, `Permissions Mock`, `Library - Lower Permissions`, `TransactionModel`, `AutoRollback`, `AutoCommit`, `Commit`, `asserterror`, `ExpectedError`, `ExpectedErrorCode`, `HandlerFunctions`, `ConfirmHandler`, `MessageHandler`, `StrMenuHandler`, `ModalPageHandler`, `SendNotificationHandler`, `RecallNotificationHandler`, `Enqueue`, `Dequeue`, `AssertEmpty`, `Initialize`, `IsInitialized`, `OnTestInitialize`, `LibrarySetupStorage`, `Library Assert`, `LibraryVariableStorage`, `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, `Library - Utility`, `LibraryUtility`, `GenerateGUID`, `GenerateRandomCode`, `TestPage`, `.Visible(`, `.Enabled(`, `.Editable(`, `OpenNew`, `OpenView`, `OpenEdit`, `Init`, `Insert`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no testing-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -49,6 +49,7 @@ The following targeted checks cover every current `testing` article. Treat each - An `AutoCommit` test runs under a `Subtype = TestRunner` codeunit that omits `TestIsolation` or sets it to `Disabled`, leaving committed data between tests — `testisolation-belongs-on-the-test-runner`. Require runner/repository context; a standalone test file cannot prove which runner executes it. - A permission-sensitive test uses `TestPermissions = Disabled`, claims to test a restricted user without `"Permissions Mock"`/`"Library - Lower Permissions"`, or declares `[TestPermissions(...)]` without applying that context — `permission-tests-must-lower-the-execution-context`. - Test fixture code manually calls `Init`/`Insert`, invents keys or prerequisite records, or bypasses available `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom`, or equivalent library codeunits — `use-library-codeunits-for-test-fixtures`. +- A test codeunit's `Initialize` procedure exits on `IsInitialized` before per-test reset such as `LibraryVariableStorage.Clear`, `LibrarySetupStorage.Restore`, or `LibraryTestInitialize.OnTestInitialize`, or a `[Test]` method in a codeunit using that pattern does not call `Initialize()` first — `reset-per-test-state-before-the-isinitialized-guard`. - `asserterror` is added or changed without a following `Assert.ExpectedError`, `Assert.ExpectedErrorCode`, or a purpose-built assertion such as `ExpectedTestFieldError` — `asserterror-needs-expectederror-and-code`. - Test fixture code assigns a hardcoded literal to a primary-key field or a field the test relies on as a unique lookup identifier, hand-builds a "unique" value for such a field (string concatenation, a counter, `Format(CurrentDateTime)`), or truncates `LibraryUtility.GenerateGUID()`'s result with `CopyStr` for such a field shorter than 10 characters — `use-generateguid-for-unique-test-fixture-values`. Calling `GenerateGUID()` untruncated into a full-length field, `GenerateRandomCodeWithLength` for a shorter field needing real verified uniqueness, or `GenerateRandomCode20` specifically for a `Code[20]` field, is the compliant shape, not the signal to flag. `GenerateRandomCode20` is not a substitute for `GenerateRandomCodeWithLength` on a shorter field — it truncates `GenerateGUID()`'s sequential value down to the field's length by keeping the *leftmost* characters, which change the slowest, so retries against a short field can churn through the same truncated prefix far longer than `GenerateRandomCodeWithLength`'s equivalent. A hardcoded or deterministic value in an ordinary descriptive field is not this anti-pattern — that field carries no uniqueness constraint. Do not claim `GenerateRandomCode` (without `WithLength`/`20`) or `GenerateRandomXMLText` verify uniqueness against the real table, or that `GenerateRandomCode` is collision-free even within one test run for a short field — none of that is true. - A test asserts against a `TestPage` field's `.Visible()` or `.Enabled()` — `use-testpage-visible-enabled-to-verify-field-ui-state`. When the assertion is against `.Editable()`, or the page is opened with `OpenEdit()` specifically to check editability — `use-testpage-editable-to-verify-field-editability`. diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md index c486c88f..b86c79b7 100644 --- a/microsoft/skills/review/al-web-services-review.md +++ b/microsoft/skills/review/al-web-services-review.md @@ -3,7 +3,7 @@ kind: action-skill id: al-web-services-review version: 1 title: AL web services review -description: Reviews AL API surfaces and webhook integration handlers against web-services guidance from BCQuality. +description: Reviews AL API surfaces, outbound HTTP integrations, and webhook handlers against web-services guidance from BCQuality. inputs: [pr-diff, file-path, folder-path] outputs: [findings-report] bc-version: [all] @@ -14,7 +14,7 @@ application-area: [all] # AL web services review -Reviews AL source changes against the `web-services` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. +Reviews AL source changes against the `web-services` knowledge domain in BCQuality and emits a findings report. This includes inbound API surfaces, outbound HTTP integrations, and webhook lifecycle code. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract. @@ -39,10 +39,12 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially pages declared with `PageType = API`, API page `part` controls, queries declared with `QueryType = API`, and procedures that expose bound actions. - The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`, `SourceTableTemporary`), navigation metadata (`SubPageLink`, `Multiplicity`, and visible singleton or collection semantics), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields. +- Outbound HTTP integration code that constructs or sends requests, captures a client method's optional Boolean result, checks an `HttpResponseMessage`, or reads and parses response content. +- Integration code that converts values to or from exchanged text: `Format` or `Evaluate` on a request URL, body, or file line, and `JsonToken`/`JsonValue` conversions of payload properties. - Webhook subscriber handlers and subscription lifecycle code, especially code that creates or renews subscriptions, handles `validationToken`, schedules from `expirationDateTime`, or targets resources whose eligibility is visible in the diff. - An API page (`PageType = API`) with `InsertAllowed = true` lists a field in `ODataKeyFields` and that same field control sets `Editable = false` — `api-page-key-fields-must-be-editable-on-insert.md`. A system-generated key such as `SystemId` marked read-only is not this anti-pattern. - An API page exposes a field via `Rec` directly, and that field is a stored value computed from other fields inside an `OnValidate` trigger rather than a FlowField recalculated in `OnAfterGetRecord` — `stored-derived-fields-must-not-be-exposed-directly.md`. Exposing a genuine FlowField, or a value already recalculated in `OnAfterGetRecord`, is not this anti-pattern. -- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `QueryType`, `API`, `api-page`, `page-part`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `SubPageLink`, `subpagelink`, `Multiplicity`, `multiplicity`, `Many`, `ZeroOrOne`, `SourceTableTemporary`, `Job Queue Entry`, `webhook`, `webhookSupportedResources`, `webhook-supported-resources`, `subscriptions`, `notificationUrl`, `validationToken`, `validationtoken`, `expirationDateTime`, `expirationdatetime`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`). +- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `QueryType`, `API`, `api-page`, `page-part`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `SubPageLink`, `subpagelink`, `Multiplicity`, `multiplicity`, `Many`, `ZeroOrOne`, `SourceTableTemporary`, `Job Queue Entry`, `webhook`, `webhookSupportedResources`, `webhook-supported-resources`, `subscriptions`, `notificationUrl`, `validationToken`, `validationtoken`, `expirationDateTime`, `expirationdatetime`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`, `HttpClient`, `HttpRequestMessage`, `HttpResponseMessage`, `Get`, `Post`, `Put`, `Delete`, `Send`, `IsSuccessStatusCode`, `HttpStatusCode`, `Content`, `ReadAs`, `JsonObject`, `JsonToken`, `JsonValue`, `AsValue`, `IsNull`, `SelectToken`, `Format`, `Evaluate`, `XmlDocument`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. @@ -58,7 +60,14 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice` - When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. - Applicability alone is not a finding. Emit `info` only for a concrete, non-actionable observation the article explicitly defines; otherwise emit nothing when no violation is present. -For API parts whose parent declares `ODataKeyFields = SystemId`, detect a child foreign key linked to a parent business field instead of `Field(SystemId)`. Do not apply the SystemId-link rule to APIs intentionally keyed by another field. Omitted `Multiplicity` is valid and means the documented default 1:N collection; never report omission alone. Report an explicit `ZeroOrOne` only when the visible contract clearly intends a collection or deep insert, and report an explicit `Many` only when it clearly intends a singleton. Singleton metadata requires an explicit `ZeroOrOne`; do not infer singleton intent from naming alone. For webhook eligibility, detect `QueryType = API`, `SourceTableTemporary = true`, composite `ODataKeyFields` (including an omitted property when a visible source primary key is composite), Job Queue Entry, and visible system-table sources; do not infer an unknown table number. For lifecycle code, require both create and renew paths to use a handler that returns the query-string `validationToken` verbatim with `200 OK`, and flag renewal scheduling that assumes subscriptions are permanent instead of using `expirationDateTime`. Do not emit generic HTTP or REST advice. +For API parts whose parent declares `ODataKeyFields = SystemId`, detect a child foreign key linked to a parent business field instead of `Field(SystemId)`. Do not apply the SystemId-link rule to APIs intentionally keyed by another field. Omitted `Multiplicity` is valid and means the documented default 1:N collection; never report omission alone. Report an explicit `ZeroOrOne` only when the visible contract clearly intends a collection or deep insert, and report an explicit `Many` only when it clearly intends a singleton. Singleton metadata requires an explicit `ZeroOrOne`; do not infer singleton intent from naming alone. For webhook eligibility, detect `QueryType = API`, `SourceTableTemporary = true`, composite `ODataKeyFields` (including an omitted property when a visible source primary key is composite), Job Queue Entry, and visible system-table sources; do not infer an unknown table number. For lifecycle code, require both create and renew paths to use a handler that returns the query-string `validationToken` verbatim with `200 OK`, and flag renewal scheduling that assumes subscriptions are permanent instead of using `expirationDateTime`. + +For outbound HTTP calls, apply these targeted checks: + +- When code captures the optional Boolean result of `HttpClient.Get`, `Post`, `Put`, `Delete`, or `Send`, require the failed branch to stop before status, headers, or content are accessed. Do not report a call that omits the Boolean result: that form intentionally lets the runtime raise an error when the request cannot execute. +- After platform success, require `IsSuccessStatusCode()` or an explicit acceptable `HttpStatusCode()` check before response content is interpreted as a success payload. Do not report code that reads a bounded non-success body solely for diagnostics and keeps it out of the success parsing path. + +Do not emit generic HTTP or REST advice beyond applicable knowledge articles. Set `confidence` to: @@ -66,7 +75,7 @@ Set `confidence` to: - `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. - `low` when the finding is an advisory derived only from applicability. -After evaluating each worklist entry, also consider whether the diff exhibits a web-services defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material web-services defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly API pages and web-service surfaces; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. +After evaluating each worklist entry, also consider whether the diff exhibits a web-services defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain — emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material web-services defect a knowledgeable BC reviewer would agree is wrong — steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly API pages, outbound HTTP integrations, and other web-service surfaces; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate — if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract. For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: set `ODataKeyFields = SystemId`; add the three `*Allowed = false` guards to a read-only page; add the missing `OnOpenPage` isolation assignment). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement — no diff markers, no fences, no commentary — that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`. @@ -76,7 +85,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item; default when the skill finishes normally, including when the resulting `findings` array is empty. - `no-knowledge` — no applicable web-services knowledge survived Source, Relevance, configuration filtering, and conflict resolution. `findings` is empty. -- `not-applicable` — the task context contains no AL API surface, JavaScript webhook subscription lifecycle code, or JavaScript notification handler, or the `technologies` filter rejected the task. +- `not-applicable` — the task context contains no AL API surface, outbound AL HTTP integration, JavaScript webhook subscription lifecycle code, or JavaScript notification handler, or the `technologies` filter rejected the task. - `partial` — a time or token budget was hit before the worklist was exhausted. `summary.coverage` reflects the evaluated subset; `outcome-reason` explains the cause. - `failed` — an unrecoverable error occurred. `outcome-reason` is required. diff --git a/skills/do.md b/skills/do.md index e67faf66..db0d6346 100644 --- a/skills/do.md +++ b/skills/do.md @@ -222,6 +222,12 @@ as a findings-report, a coordinator or host MUST validate it deterministically: inclusive range identify existing lines with `start-line == line` and `end-line >= start-line`. +Hosts SHOULD execute `tools/Validate-FindingsReport.ps1` with the exact source +scope and the leaf's recorded set of fully retrieved article paths. Pass +`-SkillKind super` when validating a super-skill's rolled-up report. Pass +`-AllowBoundedNormalization` only when the host preserves the immutable raw +payload and records `removedRanges` in private telemetry as required above. + Validation failure invalidates the complete return; consumers MUST NOT salvage individual findings, infer missing fields, reconstruct JSON, clamp ranges, rewrite paths, or otherwise silently repair model output. Preserve the invalid @@ -356,16 +362,27 @@ performing any super-skill self-review or final rollup. Scheduling MUST NOT change relevance, coverage, failure, reference-integrity, or output semantics. Orchestrators SHOULD generate `skill-index.json` with -`tools/Build-SkillIndex.ps1` and consume the super-skill's ordered `subSkills` -from that index instead of parsing Markdown. Action-skill frontmatter remains -the source of truth; the generated index conforms to +`tools/Build-SkillIndex.ps1` instead of parsing Markdown. Each declared +`subSkills` path defines an ordered leaf slot: its indexed `id` identifies the +slot, while its path fixes the declaration order. Before scheduling leaves, +the orchestrator MUST resolve each slot to the highest-precedence enabled, +non-disabled leaf with that `id` (`custom` over `community` over `microsoft`). +If no implementation remains, the slot is skipped with `reason: +"configuration"`. The orchestrator SHOULD use +`tools/Resolve-SkillWorklist.ps1` for this resolution. Action-skill +frontmatter remains the source of truth; the generated index conforms to `schemas/skill-index.schema.json`. +Layer resolution MUST NOT reorder slots. Multiple implementations with the +same `id` are valid only when they belong to different layers; duplicate IDs +within one layer are invalid. Disabling a winning implementation falls back +to the next enabled implementation for that slot when one exists. + ### Section interpretation for super-skills The five required sections still apply. Their meaning shifts from knowledge files to sub-skills: -- `## Source` — names the sub-skills invoked (mirrors `sub-skills` in frontmatter). +- `## Source` — names the declared sub-skill slots (mirrors `sub-skills` in frontmatter) and resolves their effective leaf implementations by the layered rule above. - `## Relevance` — rules for deciding which sub-skills apply to the current task. A sub-skill is relevant when its declared `inputs` are satisfied by the orchestrator's provided inputs and the orchestrator has not disabled it via configuration. The super-skill MUST NOT filter sub-skills by task content (for example, by inspecting the diff or the file). Task-level applicability is the sub-skill's own responsibility; sub-skills signal non-applicability by returning `outcome: "not-applicable"` or `outcome: "no-knowledge"`. - `## Worklist` — the final list of sub-skills to invoke; the rest go to `skipped-sub-skills`. - `## Action` — invoke each worklisted sub-skill with the appropriate subset of inputs, collect its findings-report verbatim into `sub-results`, and copy its `findings[]` into the super-skill's top-level `findings[]` with `from-sub-skill` set. All finding fields, including the optional `domain`, are preserved verbatim unless this contract explicitly requires a transformation. Findings from a sub-skill with `outcome: "failed"` MUST NOT be copied into the super-skill's top-level `findings[]` and MUST NOT contribute to the super-skill's `summary.counts` (their report is still preserved in `sub-results` for traceability, consistent with DO's rule that consumers ignore a failed skill's findings). diff --git a/tools/Build-SkillIndex.ps1 b/tools/Build-SkillIndex.ps1 index 022898ec..d4028c22 100644 --- a/tools/Build-SkillIndex.ps1 +++ b/tools/Build-SkillIndex.ps1 @@ -203,9 +203,19 @@ foreach ($layer in 'microsoft', 'community', 'custom') { } } -$ids = @($records | Group-Object id | Where-Object Count -gt 1) -if ($ids.Count) { - throw "Duplicate action-skill IDs: $($ids.Name -join ', ')" +$idsWithinLayer = @( + $records | + Group-Object { "$($_.layer)`0$($_.id)" } | + Where-Object Count -gt 1 +) +if ($idsWithinLayer.Count) { + $duplicates = @( + $idsWithinLayer | ForEach-Object { + $parts = $_.Name -split "`0", 2 + "$($parts[0]):$($parts[1])" + } + ) + throw "Duplicate action-skill IDs within a layer: $($duplicates -join ', ')" } foreach ($record in $records) { diff --git a/tools/Resolve-SkillWorklist.ps1 b/tools/Resolve-SkillWorklist.ps1 new file mode 100644 index 00000000..1e5e9f3d --- /dev/null +++ b/tools/Resolve-SkillWorklist.ps1 @@ -0,0 +1,92 @@ +<# +.SYNOPSIS + Resolves a super-skill's ordered leaf worklist across enabled layers. +#> +[CmdletBinding()] +param( + [string] $BCQualityRoot, + [string] $IndexPath, + [Parameter(Mandatory)] + [string] $SuperSkillPath, + [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'), + [string[]] $DisabledSkills = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $BCQualityRoot) { + $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +} +$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path +if (-not $IndexPath) { + $IndexPath = Join-Path $BCQualityRoot 'skill-index.json' +} +if (-not (Test-Path -LiteralPath $IndexPath -PathType Leaf)) { + & (Join-Path $PSScriptRoot 'Build-SkillIndex.ps1') -BCQualityRoot $BCQualityRoot -IndexPath $IndexPath | Out-Null +} + +$knownLayers = @('microsoft', 'community', 'custom') +$unknownLayers = @($EnabledLayers | Where-Object { $_ -cnotin $knownLayers }) +if ($unknownLayers.Count) { + throw "Unknown enabled layers: $($unknownLayers -join ', ')" +} + +$index = Get-Content -LiteralPath $IndexPath -Raw | ConvertFrom-Json +$skills = @($index.skills) +$superSkills = @($skills | Where-Object path -CEQ $SuperSkillPath) +if ($superSkills.Count -ne 1) { + throw "Expected one indexed super-skill at '$SuperSkillPath', found $($superSkills.Count)." +} +$superSkill = $superSkills[0] +if (-not @($superSkill.subSkills).Count) { + throw "Action skill '$SuperSkillPath' is not a super-skill." +} + +$precedence = @{ microsoft = 0; community = 1; custom = 2 } +$resolved = [Collections.Generic.List[object]]::new() +$skipped = [Collections.Generic.List[object]]::new() +foreach ($declaredPath in @($superSkill.subSkills)) { + $declared = @($skills | Where-Object path -CEQ $declaredPath) + if ($declared.Count -ne 1) { + throw "Declared sub-skill '$declaredPath' is not uniquely indexed." + } + + $candidates = @( + $skills | + Where-Object { + $_.id -CEQ $declared[0].id -and + $_.layer -cin $EnabledLayers -and + $_.path -cnotin $DisabledSkills -and + -not @($_.subSkills).Count + } | + Sort-Object @{ Expression = { $precedence[$_.layer] }; Descending = $true }, path + ) + if (-not $candidates.Count) { + $skipped.Add([pscustomobject][ordered]@{ + id = $declared[0].id + declaredPath = $declaredPath + reason = 'configuration' + }) | Out-Null + continue + } + + $winner = $candidates[0] + $resolved.Add([pscustomobject][ordered]@{ + id = $winner.id + path = $winner.path + version = $winner.version + layer = $winner.layer + declaredPath = $declaredPath + }) | Out-Null +} + +return [pscustomobject][ordered]@{ + superSkill = [pscustomobject][ordered]@{ + id = $superSkill.id + path = $superSkill.path + version = $superSkill.version + } + subSkills = @($resolved) + skipped = @($skipped) +} \ No newline at end of file diff --git a/tools/Test-KnowledgeRetrieval.ps1 b/tools/Test-KnowledgeRetrieval.ps1 index ef658125..568a4bd7 100644 --- a/tools/Test-KnowledgeRetrieval.ps1 +++ b/tools/Test-KnowledgeRetrieval.ps1 @@ -348,6 +348,60 @@ try { $indexPath = Join-Path $tmp 'knowledge-index.json' & $generator -BCQualityRoot $Root -IndexPath $indexPath | Out-Null $index = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json + + # Check the declared finder route and real tool reachability, not model compliance. + $errorSkill = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-error-handling-review.md') -Raw + $errorSource = [regex]::Match($errorSkill, '(?ms)^## Source\r?\n(.*?)(?=^## )').Groups[1].Value + $sourceDomains = @([regex]::Matches($errorSource, '-Domain ([a-z-]+)') | ForEach-Object { $_.Groups[1].Value }) + Assert-Sequence $sourceDomains @('error-handling', 'web-services') 'error-handling declares its own and supplementary HTTP catalogs' + foreach ($cue in @('HttpClient.Get', 'HttpClient.Post', 'resolved call paths', 'same known task dimensions and enabled layers')) { + Assert-True ($errorSource.Contains($cue)) "supplementary source retains '$cue'" + } + $httpArticleNames = @( + [regex]::Matches($errorSource, '\]\(\.\./\.\./knowledge/web-services/([a-z-]+\.md)\)') | + ForEach-Object { $_.Groups[1].Value } + ) + Assert-Sequence $httpArticleNames @( + 'handle-httpclient-platform-failure-before-response-access.md' + 'check-http-status-before-consuming-response-body.md' + ) 'supplementary source names only the two canonical HTTP articles' + $httpArguments = @{ + BCQualityRoot = $Root + IndexPath = $indexPath + EnabledLayers = @('microsoft', 'community', 'custom') + Technologies = @('al') + BCVersion = 28 + Countries = @('w1') + } + $ownCatalog = Invoke-CatalogPages -Arguments ($httpArguments + @{ Domain = $sourceDomains[0] }) + Assert-True (-not @($ownCatalog.candidates | Where-Object { $_.path -like '*/knowledge/web-services/*' }).Count) 'the primary catalog does not silently expand domains' + $httpCatalog = Invoke-CatalogPages -Arguments ($httpArguments + @{ Domain = $sourceDomains[1] }) + $httpRows = @($httpCatalog.candidates | Where-Object { $httpArticleNames -ccontains ($_.path -split '/')[-1] }) + $expectedHttpPaths = @( + $index.articles | + Where-Object { $_.domain -ceq 'web-services' -and $httpArticleNames -ccontains ($_.path -split '/')[-1] } | + ForEach-Object path | + Sort-Object + ) + foreach ($name in $httpArticleNames) { + Assert-True ($expectedHttpPaths -ccontains "microsoft/knowledge/web-services/$name") "canonical owner exists for $name" + } + Assert-Sequence @($httpRows.path | Sort-Object) $expectedHttpPaths 'supplementary selection preserves exact paths across layers' + Test-BodyRoundTrip -Paths $httpRows.path -IndexPath $indexPath + foreach ($excludedContext in @( + @{ EnabledLayers = @() } + @{ Technologies = @('javascript') } + )) { + $arguments = $httpArguments + @{ Domain = $sourceDomains[1] } + foreach ($key in $excludedContext.Keys) { + $arguments[$key] = $excludedContext[$key] + } + $catalog = Invoke-CatalogPages -Arguments $arguments + $selected = @($catalog.candidates | Where-Object { $httpArticleNames -ccontains ($_.path -split '/')[-1] }) + Assert-Equal $selected.Count 0 'supplementary selection respects disabled layers and nonmatching technology' + } + Write-Host 'HTTP source contract and exact-body reachability passed; model routing was not evaluated.' + $diskArticlePaths = @( foreach ($layer in 'microsoft', 'community', 'custom') { $knowledge = Join-Path $Root "$layer\knowledge" diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1 index 1d40058b..adacb361 100644 --- a/tools/Test-ReviewContract.ps1 +++ b/tools/Test-ReviewContract.ps1 @@ -1,12 +1,11 @@ <# .SYNOPSIS - Validates the bounded leaf-range normalization contract. + Validates executable findings-report acceptance and bounded normalization. .DESCRIPTION - BCQuality has no executable findings-report consumer. These assertions keep - the normative DO contract, AL coordinator, and standalone runner aligned - while exercising the exact normalization predicate against representative - safe and ambiguous inputs. + These assertions keep the normative DO contract, executable validator, AL + coordinator, and standalone runner aligned while exercising semantic report + validation and the exact normalization predicate. #> [CmdletBinding()] param( @@ -39,6 +38,24 @@ function Assert-Contains { Assert-True $Text.Contains($Expected) $Message } +function Assert-ThrowsLike { + param( + [scriptblock] $Action, + [string] $Pattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -like $Pattern) { + return + } + throw "Expected error like '$Pattern', received: $($_.Exception.Message)" + } + throw "Expected error like '$Pattern', but no error was thrown." +} + function Test-PositiveInteger { param([object] $Value) @@ -168,4 +185,334 @@ Assert-True (-not ($candidateFinding.location.PSObject.Properties.Name -contains Assert-True ($candidateFinding.location.line -eq $rawFinding.location.line) 'candidate preserves the primary line' Assert-True ($candidateFinding.message -ceq $rawFinding.message) 'candidate preserves all other finding content' -Write-Output "Review contract validation passed ($($cases.Count) normalization cases)." +$validator = Join-Path $Root 'tools/Validate-FindingsReport.ps1' +Assert-True (Test-Path -LiteralPath $validator -PathType Leaf) 'executable report validator exists' +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("reviewcontract_" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + $sourcePath = 'src/codeunit.al' + $sourceFile = Join-Path $tmp 'src/codeunit.al' + New-Item -ItemType Directory -Path (Split-Path -Parent $sourceFile) -Force | Out-Null + Set-Content -LiteralPath $sourceFile -Value @('line one', 'line two', 'line three') -Encoding utf8NoBOM + $articlePath = 'microsoft/knowledge/style/caption-required-on-page-fields.md' + $supportingArticlePath = 'microsoft/knowledge/style/tooltip-required-on-page-fields.md' + $reportPath = Join-Path $tmp 'report.json' + + $validReport = [ordered]@{ + skill = [ordered]@{ id = 'al-style-review'; version = 1 } + outcome = 'completed' + summary = [ordered]@{ + counts = [ordered]@{ blocker = 0; major = 0; minor = 1; info = 0 } + coverage = [ordered]@{ 'worklist-size' = 1; 'items-evaluated' = 1 } + } + findings = @( + [ordered]@{ + id = $articlePath + severity = 'minor' + message = 'A concrete style defect.' + location = [ordered]@{ + file = $sourcePath + line = 2 + range = [ordered]@{ 'start-line' = 2; 'end-line' = 3 } + } + references = @([ordered]@{ path = $articlePath }) + confidence = 'high' + domain = 'Style' + } + ) + suppressed = @() + } + Set-Content -LiteralPath $reportPath -Value ($validReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $accepted = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + Assert-True (-not $accepted.normalized) 'valid report is accepted without normalization' + + $invalidCounts = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $invalidCounts.summary.counts.minor = 0 + Set-Content -LiteralPath $reportPath -Value ($invalidCounts | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*COUNT_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $completedUndercoverage = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $completedUndercoverage.summary.coverage.'items-evaluated' = 0 + Set-Content -LiteralPath $reportPath -Value ($completedUndercoverage | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*COMPLETED_COVERAGE_INCOMPLETE*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $partialFullCoverage = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $partialFullCoverage.outcome = 'partial' + $partialFullCoverage | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'Stopped early.' + Set-Content -LiteralPath $reportPath -Value ($partialFullCoverage | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*PARTIAL_COVERAGE_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $completedLeaf = [ordered]@{ + skill = [ordered]@{ id = 'al-style-review'; version = 1 } + outcome = 'completed' + summary = [ordered]@{ + counts = [ordered]@{ blocker = 0; major = 0; minor = 0; info = 0 } + coverage = [ordered]@{ 'worklist-size' = 1; 'items-evaluated' = 1 } + } + findings = @() + suppressed = @() + } + $leafWithSubResults = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $leafWithSubResults | Add-Member -NotePropertyName 'sub-results' -NotePropertyValue @($completedLeaf) + Set-Content -LiteralPath $reportPath -Value ($leafWithSubResults | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*LEAF_COMPOSITION_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root + } + + $validSuperReport = [ordered]@{ + skill = [ordered]@{ id = 'al-code-review'; version = 1 } + outcome = 'completed' + summary = [ordered]@{ + counts = [ordered]@{ blocker = 0; major = 0; minor = 0; info = 0 } + coverage = [ordered]@{ 'worklist-size' = 2; 'items-evaluated' = 2 } + } + findings = @() + suppressed = @() + 'sub-results' = @($completedLeaf, $completedLeaf) + } + Set-Content -LiteralPath $reportPath -Value ($validSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super + Assert-True (-not $acceptedSuper.normalized) 'valid super-skill report is accepted' + + $styleFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $securityFindingLeaf = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $securityFindingLeaf.skill.id = 'al-security-review' + $rolledFinding = $validReport.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $rolledFinding | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-style-review' + $deduplicatedSuperReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $deduplicatedSuperReport.summary.counts.minor = 1 + $deduplicatedSuperReport.findings = @($rolledFinding) + $deduplicatedSuperReport.'sub-results' = @($styleFindingLeaf, $securityFindingLeaf) + Set-Content -LiteralPath $reportPath -Value ($deduplicatedSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedDeduplicatedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + Assert-True (-not $acceptedDeduplicatedSuper.normalized) 'one top-level finding may deduplicate the same citation from two leaves' + + $twoOccurrenceLeaf = $styleFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $secondOccurrence = $twoOccurrenceLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $secondOccurrence.location.line = 1 + $secondOccurrence.location.range.'start-line' = 1 + $secondOccurrence.location.range.'end-line' = 1 + $twoOccurrenceLeaf.findings = @($twoOccurrenceLeaf.findings[0], $secondOccurrence) + $twoOccurrenceLeaf.summary.counts.minor = 2 + $emptySecurityLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $emptySecurityLeaf.skill.id = 'al-security-review' + $sameIdOccurrenceOmitted = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $sameIdOccurrenceOmitted.'sub-results' = @($twoOccurrenceLeaf, $emptySecurityLeaf) + Set-Content -LiteralPath $reportPath -Value ($sameIdOccurrenceOmitted | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $mergeOwnerLeaf = $styleFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $mergeOwnerLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'Caption = ''Customer name'';' + $supportingFindingLeaf = $securityFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $supportingFindingLeaf.findings[0].id = $supportingArticlePath + $supportingFindingLeaf.findings[0].references[0].path = $supportingArticlePath + $supportingFindingLeaf.findings[0].confidence = 'medium' + $supportingFindingLeaf.findings[0].message = 'The field needs the same mechanical correction for a supporting rule.' + $supportingFindingLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'Caption = ''Customer name'';' + $mergedFinding = $rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $mergedFinding | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'Caption = ''Customer name'';' + $mergedFinding.references = @( + [pscustomobject]@{ path = $articlePath } + [pscustomobject]@{ path = $supportingArticlePath } + ) + $mergedSuperReport = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $mergedSuperReport.findings = @($mergedFinding) + $mergedSuperReport.'sub-results' = @($mergeOwnerLeaf, $supportingFindingLeaf) + Set-Content -LiteralPath $reportPath -Value ($mergedSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedMergedSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + Assert-True (-not $acceptedMergedSuper.normalized) 'overlapping A and B findings may merge into A with B as a supporting reference' + + $textOnlyOwnerLeaf = $mergeOwnerLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $textOnlyOwnerLeaf.findings[0].PSObject.Properties.Remove('suggested-code') + $textOnlySupportingLeaf = $supportingFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $textOnlySupportingLeaf.findings[0].PSObject.Properties.Remove('suggested-code') + $textOnlyMergedFinding = $mergedFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $textOnlyMergedFinding.PSObject.Properties.Remove('suggested-code') + $textOnlyMergedSuper = $mergedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $textOnlyMergedSuper.findings = @($textOnlyMergedFinding) + $textOnlyMergedSuper.'sub-results' = @($textOnlyOwnerLeaf, $textOnlySupportingLeaf) + Set-Content -LiteralPath $reportPath -Value ($textOnlyMergedSuper | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedTextOnlyMerge = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + Assert-True (-not $acceptedTextOnlyMerge.normalized) 'supporting references permit an overlapping A and B merge with different messages and no suggested code' + + $conflictingSupportingLeaf = $supportingFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $conflictingSupportingLeaf.findings[0].'suggested-code' = 'ToolTip = ''Customer name'';' + $conflictingCorrectionMerge = $mergedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $conflictingCorrectionMerge.'sub-results' = @($mergeOwnerLeaf, $conflictingSupportingLeaf) + Set-Content -LiteralPath $reportPath -Value ($conflictingCorrectionMerge | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + } + + $omittedConflictingCorrectionMerge = $conflictingCorrectionMerge | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $omittedConflictingCorrectionMerge.findings[0].PSObject.Properties.Remove('suggested-code') + Set-Content -LiteralPath $reportPath -Value ($omittedConflictingCorrectionMerge | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + } + + $unmergedSupportingFinding = $supportingFindingLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $unmergedSupportingFinding | Add-Member -NotePropertyName 'from-sub-skill' -NotePropertyValue 'al-security-review' + $unmergedDuplicates = $mergedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $unmergedDuplicates.summary.counts.minor = 2 + $unmergedDuplicates.findings = @($mergedFinding, $unmergedSupportingFinding) + Set-Content -LiteralPath $reportPath -Value ($unmergedDuplicates | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_DUPLICATE_FINDINGS*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath, $supportingArticlePath + } + + $omittedLeafFinding = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $omittedLeafFinding.summary.counts.minor = 0 + $omittedLeafFinding.findings = @() + Set-Content -LiteralPath $reportPath -Value ($omittedLeafFinding | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $locationlessLeaf = $styleFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $locationlessLeaf.findings[0].PSObject.Properties.Remove('location') + $secondLocationlessFinding = $locationlessLeaf.findings[0] | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $locationlessLeaf.findings = @($locationlessLeaf.findings[0], $secondLocationlessFinding) + $locationlessLeaf.summary.counts.minor = 2 + $locationlessRolledFinding = $rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $locationlessRolledFinding.PSObject.Properties.Remove('location') + $locationlessOmission = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $locationlessOmission.findings = @($locationlessRolledFinding) + $locationlessOmission.'sub-results' = @($locationlessLeaf, $emptySecurityLeaf) + Set-Content -LiteralPath $reportPath -Value ($locationlessOmission | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISSING*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $completeLocationlessRollup = $locationlessOmission | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $completeLocationlessRollup.summary.counts.minor = 2 + $completeLocationlessRollup.findings = @( + $locationlessRolledFinding + ($locationlessRolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + ) + Set-Content -LiteralPath $reportPath -Value ($completeLocationlessRollup | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedLocationlessRollup = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + Assert-True (-not $acceptedLocationlessRollup.normalized) 'two locationless leaf occurrences require and accept two distinct rolled findings' + + $nonexistentProducer = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $nonexistentProducer.findings[0].'from-sub-skill' = 'al-missing-review' + Set-Content -LiteralPath $reportPath -Value ($nonexistentProducer | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_PRODUCER_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $rewrittenLeafFinding = $deduplicatedSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $rewrittenLeafFinding.findings[0].message = 'A rewritten rollup message.' + Set-Content -LiteralPath $reportPath -Value ($rewrittenLeafFinding | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FINDING_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $failedLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $failedLeaf.skill.id = 'al-security-review' + $failedLeaf.outcome = 'failed' + $failedLeaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'Validation failed.' + $failedLeaf.summary.coverage.'items-evaluated' = 0 + $partialSuperReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $partialSuperReport.outcome = 'partial' + $partialSuperReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue 'One sub-skill failed.' + $partialSuperReport.summary.coverage.'worklist-size' = 1 + $partialSuperReport.summary.coverage.'items-evaluated' = 1 + $partialSuperReport.'sub-results' = @($completedLeaf, $failedLeaf) + Set-Content -LiteralPath $reportPath -Value ($partialSuperReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedPartialSuper = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super + Assert-True (-not $acceptedPartialSuper.normalized) 'partial super-skill excludes failed coverage from its rollup' + + $failedLeafLeakage = $partialSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $failedLeafLeakage.summary.counts.minor = 1 + $failedLeafLeakage.findings = @($rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + $failedLeafLeakage.findings[0].'from-sub-skill' = 'al-security-review' + Set-Content -LiteralPath $reportPath -Value ($failedLeafLeakage | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_FAILED_FINDING_LEAKAGE*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $failedLeafRelabeledAsAgent = $partialSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $failedLeafRelabeledAsAgent.summary.counts.minor = 1 + $failedLeafRelabeledAsAgent.findings = @($rolledFinding | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + $failedLeafRelabeledAsAgent.findings[0].'from-sub-skill' = 'agent' + $failedLeafRelabeledAsAgent.findings[0].domain = 'Agent' + Set-Content -LiteralPath $reportPath -Value ($failedLeafRelabeledAsAgent | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_AGENT_FINDING_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -SourceRoot $tmp -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + + $incorrectOutcome = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $incorrectOutcome.outcome = 'not-applicable' + Set-Content -LiteralPath $reportPath -Value ($incorrectOutcome | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_OUTCOME_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super + } + + $incorrectRollup = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $incorrectRollup.summary.coverage.'worklist-size' = 1 + $incorrectRollup.summary.coverage.'items-evaluated' = 1 + Set-Content -LiteralPath $reportPath -Value ($incorrectRollup | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*SUPER_COVERAGE_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super + } + + Set-Content -LiteralPath $reportPath -Value ($validReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*REFERENCE_NOT_RETRIEVED*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp -SourcePaths $sourcePath + } + + $invalidAgent = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $invalidAgent.findings[0].id = 'agent:uncited-defect' + $invalidAgent.findings[0].references = @() + $invalidAgent.findings[0].confidence = 'high' + Set-Content -LiteralPath $reportPath -Value ($invalidAgent | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*AGENT_CONFIDENCE_INVALID*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp -SourcePaths $sourcePath + } + + $normalizable = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $normalizable.findings[0].location.range.'start-line' = 1 + Set-Content -LiteralPath $reportPath -Value ($normalizable | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + Assert-ThrowsLike -Pattern '*RANGE_START_MISMATCH*' -Action { + & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath + } + $normalized = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath -AllowBoundedNormalization + Assert-True $normalized.normalized 'eligible range mismatch is normalized' + Assert-True ($normalized.removedRanges.Count -eq 1) 'normalization records one removed range' + Assert-True (-not ($normalized.report.findings[0].location.PSObject.Properties.Name -contains 'range')) ` + 'accepted normalized report removes only the optional range' +} +finally { + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Output "Review contract validation passed ($($cases.Count) predicate cases plus executable acceptance cases)." diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index 8cf019f3..2941c022 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -18,7 +18,9 @@ param( [string] $ManifestPath, [string] $PrepareDirectory, [string] $ResultsPath, - [string] $ResultsDirectory + [string] $ResultsDirectory, + [string] $ChangedPathsFile, + [string] $CoverageReportPath ) Set-StrictMode -Version Latest @@ -160,7 +162,23 @@ foreach ($overrideDomain in $overrides.Keys) { } } +$coverageWaivers = @{} +if ($manifest.PSObject.Properties.Name -contains 'coverageWaivers') { + foreach ($waiver in @($manifest.coverageWaivers)) { + if (-not $waiver.path -or -not $waiver.reason) { + $problems.Add('Each coverage waiver requires non-empty path and reason values.') | Out-Null + continue + } + if ($coverageWaivers.ContainsKey([string]$waiver.path)) { + $problems.Add("Duplicate coverage waiver: $($waiver.path)") | Out-Null + continue + } + $coverageWaivers[[string]$waiver.path] = [string]$waiver.reason + } +} + $caseList = [System.Collections.Generic.List[object]]::new() +$pairedArticlesByDomain = @{} foreach ($domain in $leafDomains) { $articleCandidates = @( foreach ($layer in $layers) { @@ -189,6 +207,7 @@ foreach ($domain in $leafDomains) { ForEach-Object { $_.Group | Sort-Object Rank -Descending | Select-Object -First 1 } | Sort-Object BaseName ) + $pairedArticlesByDomain[$domain] = @($articles) if (-not $articles.Count) { $problems.Add("${domain}: no enabled knowledge layer has an article with both .good.al and .bad.al companion samples.") | Out-Null continue @@ -339,6 +358,65 @@ foreach ($domain in $leafDomains) { } } +$selectedArticlePaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($case in $cases) { + foreach ($reference in @($case.expected)) { + $selectedArticlePaths.Add([string]$reference) | Out-Null + } +} +$effectivePairedPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$coverageDomains = @( + foreach ($domain in $leafDomains) { + $paired = @($pairedArticlesByDomain[$domain]) + foreach ($article in $paired) { + $effectivePairedPaths.Add([string]$article.ArticlePath) | Out-Null + } + $selected = @($paired | Where-Object { $selectedArticlePaths.Contains([string]$_.ArticlePath) }).Count + [pscustomobject][ordered]@{ + domain = $domain + pairedArticles = $paired.Count + selectedArticles = $selected + coverage = if ($paired.Count) { $selected / $paired.Count } else { 0 } + } + } +) +$pairedTotal = ($coverageDomains | Measure-Object pairedArticles -Sum).Sum +$selectedTotal = ($coverageDomains | Measure-Object selectedArticles -Sum).Sum +$coverageReport = [pscustomobject][ordered]@{ + pairedArticles = $pairedTotal + selectedArticles = $selectedTotal + coverage = if ($pairedTotal) { $selectedTotal / $pairedTotal } else { 0 } + domains = $coverageDomains +} +if ($CoverageReportPath) { + $coverageParent = Split-Path -Parent $CoverageReportPath + if ($coverageParent -and -not (Test-Path -LiteralPath $coverageParent)) { + New-Item -ItemType Directory -Path $coverageParent -Force | Out-Null + } + $coverageReport | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $CoverageReportPath -Encoding utf8NoBOM +} + +if ($ChangedPathsFile) { + if (-not (Test-Path -LiteralPath $ChangedPathsFile -PathType Leaf)) { + $problems.Add("Changed paths file not found: $ChangedPathsFile") | Out-Null + } + else { + foreach ($changedPathValue in Get-Content -LiteralPath $ChangedPathsFile) { + $changedPath = ([string]$changedPathValue).Trim().Replace('\', '/') + if ($changedPath -notmatch '^(microsoft|community|custom)/knowledge/[^/]+/(.+?)(?:\.(?:good|bad)\.al|\.md)$') { + continue + } + $articlePath = "$($Matches[1])/knowledge/$($changedPath.Split('/')[2])/$($Matches[2]).md" + if (-not $effectivePairedPaths.Contains($articlePath) -or $selectedArticlePaths.Contains($articlePath)) { + continue + } + if (-not $coverageWaivers.ContainsKey($articlePath)) { + $problems.Add("Changed paired article is not selected for evaluation and has no coverage waiver: $articlePath") | Out-Null + } + } + } +} + if ($problems.Count) { Write-Host "Review fixture validation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red $problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } @@ -474,7 +552,7 @@ if ($PrepareDirectory) { if (-not $ResultsPath -and -not $ResultsDirectory) { & (Join-Path $PSScriptRoot 'Test-ReviewContract.ps1') -Root $Root - Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $($leafDomains.Count) leaf domains." -ForegroundColor Green + Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $selectedTotal/$pairedTotal paired articles across $($leafDomains.Count) leaf domains." -ForegroundColor Green exit 0 } diff --git a/tools/Validate-FindingsReport.ps1 b/tools/Validate-FindingsReport.ps1 new file mode 100644 index 00000000..d3442d35 --- /dev/null +++ b/tools/Validate-FindingsReport.ps1 @@ -0,0 +1,540 @@ +<# +.SYNOPSIS + Validates a BCQuality findings-report against its structural and semantic contract. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $ReportPath, + [string] $BCQualityRoot, + [string] $SourceRoot, + [string[]] $SourcePaths = @(), + [string[]] $RetrievedArticlePaths = @(), + [ValidateSet('leaf', 'super')] + [string] $SkillKind = 'leaf', + [switch] $AllowBoundedNormalization +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $BCQualityRoot) { + $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +} +$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path +$schemaPath = Join-Path $BCQualityRoot 'schemas/findings-report.schema.json' +$raw = Get-Content -LiteralPath $ReportPath -Raw +try { + if (-not ($raw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) { + throw 'Report does not satisfy schemas/findings-report.schema.json.' + } + $report = $raw | ConvertFrom-Json -Depth 100 +} +catch { + throw "Invalid findings-report JSON or schema: $($_.Exception.Message)" +} + +$retrieved = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($path in $RetrievedArticlePaths) { + $retrieved.Add($path) | Out-Null +} +$sources = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($path in $SourcePaths) { + $sources.Add($path) | Out-Null +} +$lineCounts = @{} + +function Test-HasProperty { + param([object] $Object, [string] $Name) + return $null -ne $Object -and $Object.PSObject.Properties.Name -ccontains $Name +} + +function Get-SourceLineCount { + param([string] $Path) + + if ($lineCounts.ContainsKey($Path)) { + return $lineCounts[$Path] + } + if (-not $SourceRoot) { + return -1 + } + $fullPath = Join-Path $SourceRoot ($Path -replace '/', [IO.Path]::DirectorySeparatorChar) + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + return -1 + } + $lineCounts[$Path] = [IO.File]::ReadAllLines($fullPath).Count + return $lineCounts[$Path] +} + +function Get-SemanticErrors { + param( + [object] $Candidate, + [switch] $PermitRangeStartMismatch + ) + + $errors = [Collections.Generic.List[object]]::new() + function Add-Error { + param([string] $Code, [string] $Path, [string] $Message) + $errors.Add([pscustomobject]@{ Code = $Code; Path = $Path; Message = $Message }) | Out-Null + } + + function Get-DerivedSuperOutcome { + param([object[]] $SubResults) + + if (-not $SubResults.Count) { + return 'not-applicable' + } + + $outcomes = @($SubResults | ForEach-Object { $_.outcome }) + if (-not @($outcomes | Where-Object { $_ -cne 'failed' }).Count) { + return 'failed' + } + if (($outcomes -ccontains 'partial') -or + (($outcomes -ccontains 'failed') -and @($outcomes | Where-Object { $_ -cne 'failed' }).Count)) { + return 'partial' + } + if (-not @($outcomes | Where-Object { $_ -cne 'not-applicable' }).Count) { + return 'not-applicable' + } + if (($outcomes -ccontains 'no-knowledge') -and + -not @($outcomes | Where-Object { $_ -cnotin @('no-knowledge', 'not-applicable') }).Count) { + return 'no-knowledge' + } + return 'completed' + } + + function Get-SeverityRank { + param([string] $Severity) + return @{'info' = 0; 'minor' = 1; 'major' = 2; 'blocker' = 3}[$Severity] + } + + function Get-ConfidenceRank { + param([string] $Confidence) + return @{'low' = 0; 'medium' = 1; 'high' = 2}[$Confidence] + } + + function Test-LocationsOverlap { + param([object] $First, [object] $Second) + + $firstHasLocation = Test-HasProperty $First 'location' + $secondHasLocation = Test-HasProperty $Second 'location' + if ($firstHasLocation -ne $secondHasLocation) { + return $false + } + if (-not $firstHasLocation) { + return $false + } + if ($First.location.file -cne $Second.location.file) { + return $false + } + $firstEnd = if (Test-HasProperty $First.location 'range') { $First.location.range.'end-line' } else { $First.location.line } + $secondEnd = if (Test-HasProperty $Second.location 'range') { $Second.location.range.'end-line' } else { $Second.location.line } + return $First.location.line -le $secondEnd -and $Second.location.line -le $firstEnd + } + + function Test-SameCorrection { + param([object] $First, [object] $Second) + + $firstHasCode = Test-HasProperty $First 'suggested-code' + $secondHasCode = Test-HasProperty $Second 'suggested-code' + if ($firstHasCode -or $secondHasCode) { + return $firstHasCode -and $secondHasCode -and $First.'suggested-code' -ceq $Second.'suggested-code' + } + return $First.message -ceq $Second.message + } + + function Test-ReferencesInclude { + param([object[]] $RolledReferences, [object[]] $LeafReferences) + + foreach ($leafReference in $LeafReferences) { + $matched = @($RolledReferences | Where-Object { + if ($_.path -cne $leafReference.path) { + return $false + } + $rolledHasSha = Test-HasProperty $_ 'sha' + $leafHasSha = Test-HasProperty $leafReference 'sha' + return $rolledHasSha -eq $leafHasSha -and + (-not $rolledHasSha -or $_.sha -ceq $leafReference.sha) + }).Count + if (-not $matched) { + return $false + } + } + return $true + } + + function Test-RolledFindingRepresents { + param( + [object] $RolledFinding, + [object] $LeafFinding, + [string] $LeafProducerId, + [switch] $RequirePrimaryOwner + ) + + $rolledHasLocation = Test-HasProperty $RolledFinding 'location' + $leafHasLocation = Test-HasProperty $LeafFinding 'location' + $leafReferences = @($LeafFinding.references) + $rolledReferences = @($RolledFinding.references) + if (-not $rolledHasLocation -and -not $leafHasLocation) { + $expectedId = if ($leafReferences.Count) { $LeafFinding.id } else { "${LeafProducerId}:$($LeafFinding.id)" } + if ($RolledFinding.'from-sub-skill' -cne $LeafProducerId -or + $RolledFinding.id -cne $expectedId -or + $RolledFinding.severity -cne $LeafFinding.severity -or + $RolledFinding.confidence -cne $LeafFinding.confidence -or + $RolledFinding.message -cne $LeafFinding.message -or + $rolledReferences.Count -ne $leafReferences.Count -or + -not (Test-ReferencesInclude $rolledReferences $leafReferences)) { + return $false + } + foreach ($name in 'domain', 'suggested-code', 'suggested-code-omission-reason') { + $rolledHasProperty = Test-HasProperty $RolledFinding $name + $leafHasProperty = Test-HasProperty $LeafFinding $name + if ($rolledHasProperty -ne $leafHasProperty -or + ($rolledHasProperty -and $RolledFinding.$name -cne $LeafFinding.$name)) { + return $false + } + } + return $true + } + + if (-not (Test-LocationsOverlap $RolledFinding $LeafFinding) -or + (Get-SeverityRank $RolledFinding.severity) -lt (Get-SeverityRank $LeafFinding.severity) -or + (Get-ConfidenceRank $RolledFinding.confidence) -lt (Get-ConfidenceRank $LeafFinding.confidence)) { + return $false + } + + $sameCorrection = Test-SameCorrection $RolledFinding $LeafFinding + $correctionsConflict = (Test-HasProperty $RolledFinding 'suggested-code') -and + (Test-HasProperty $LeafFinding 'suggested-code') -and + $RolledFinding.'suggested-code' -cne $LeafFinding.'suggested-code' + $explicitCrossRuleMerge = $leafReferences.Count -and + $rolledReferences.Count -gt $leafReferences.Count -and + -not $correctionsConflict -and + (Test-ReferencesInclude $rolledReferences $leafReferences) + if (-not $sameCorrection -and -not $explicitCrossRuleMerge) { + return $false + } + + if (-not $leafReferences.Count) { + return $RolledFinding.'from-sub-skill' -ceq $LeafProducerId -and + $RolledFinding.id -ceq "${LeafProducerId}:$($LeafFinding.id)" -and + -not $rolledReferences.Count + } + if (-not (Test-ReferencesInclude $rolledReferences $leafReferences)) { + return $false + } + if ($RequirePrimaryOwner) { + $rolledHasDomain = Test-HasProperty $RolledFinding 'domain' + $leafHasDomain = Test-HasProperty $LeafFinding 'domain' + return $RolledFinding.'from-sub-skill' -ceq $LeafProducerId -and + $RolledFinding.id -ceq $LeafFinding.id -and + @($RolledFinding.references)[0].path -ceq $leafReferences[0].path -and + $rolledHasDomain -eq $leafHasDomain -and + (-not $rolledHasDomain -or $RolledFinding.domain -ceq $LeafFinding.domain) + } + return $true + } + + function Test-Report { + param( + [object] $Current, + [string] $ReportPathPrefix, + [ValidateSet('leaf', 'super')] + [string] $CurrentSkillKind + ) + + $findings = @($Current.findings) + foreach ($severity in 'blocker', 'major', 'minor', 'info') { + $actual = @($findings | Where-Object severity -CEQ $severity).Count + if ($Current.summary.counts.$severity -ne $actual) { + Add-Error 'COUNT_MISMATCH' "$ReportPathPrefix.summary.counts.$severity" "Expected $actual." + } + } + $worklistSize = $Current.summary.coverage.'worklist-size' + $itemsEvaluated = $Current.summary.coverage.'items-evaluated' + if ($itemsEvaluated -gt $worklistSize) { + Add-Error 'COVERAGE_INVALID' "$ReportPathPrefix.summary.coverage" 'items-evaluated exceeds worklist-size.' + } + elseif ($Current.outcome -ceq 'completed' -and $itemsEvaluated -ne $worklistSize) { + Add-Error 'COMPLETED_COVERAGE_INCOMPLETE' "$ReportPathPrefix.summary.coverage" 'A completed report must evaluate its full worklist.' + } + elseif ($CurrentSkillKind -ceq 'leaf' -and $Current.outcome -ceq 'partial' -and + ($itemsEvaluated -le 0 -or $itemsEvaluated -ge $worklistSize)) { + Add-Error 'PARTIAL_COVERAGE_INVALID' "$ReportPathPrefix.summary.coverage" 'A partial report must evaluate a non-zero proper subset of its worklist.' + } + + $hasSubResults = Test-HasProperty $Current 'sub-results' + $hasSkippedSubSkills = Test-HasProperty $Current 'skipped-sub-skills' + if ($CurrentSkillKind -ceq 'leaf') { + if ($hasSubResults -or $hasSkippedSubSkills) { + Add-Error 'LEAF_COMPOSITION_INVALID' $ReportPathPrefix 'A leaf report must not contain sub-results or skipped-sub-skills.' + } + } + elseif (-not $hasSubResults) { + Add-Error 'SUPER_SUB_RESULTS_REQUIRED' $ReportPathPrefix 'A super-skill report must contain sub-results.' + } + + for ($index = 0; $index -lt $findings.Count; $index++) { + $finding = $findings[$index] + $findingPath = "$ReportPathPrefix.findings[$index]" + $references = @($finding.references) + $hasProducer = Test-HasProperty $finding 'from-sub-skill' + if ($CurrentSkillKind -ceq 'leaf' -and $hasProducer) { + Add-Error 'LEAF_PRODUCER_INVALID' "$findingPath.from-sub-skill" 'A leaf finding must not contain from-sub-skill.' + } + elseif ($CurrentSkillKind -ceq 'super' -and -not $hasProducer) { + Add-Error 'SUPER_PRODUCER_REQUIRED' $findingPath 'A super-skill finding must identify its producer in from-sub-skill.' + } + if (-not $references.Count) { + if ($finding.id -cnotmatch '(^|:)agent:[a-z0-9]+(?:-[a-z0-9]+)*$') { + Add-Error 'AGENT_ID_INVALID' "$findingPath.id" 'An agent finding id must contain an agent: slug marker.' + } + if ($finding.confidence -ceq 'high') { + Add-Error 'AGENT_CONFIDENCE_INVALID' "$findingPath.confidence" 'Agent confidence cannot be high.' + } + if ($finding.severity -cin @('blocker', 'major')) { + Add-Error 'AGENT_SEVERITY_INVALID' "$findingPath.severity" 'Agent severity cannot exceed minor.' + } + } + else { + if ($finding.id -cne $references[0].path) { + Add-Error 'PRIMARY_REFERENCE_MISMATCH' "$findingPath.id" 'Finding id must equal the primary reference path.' + } + foreach ($reference in $references) { + if ($reference.path -cnotmatch '^(microsoft|community|custom)/knowledge/.+\.md$') { + Add-Error 'REFERENCE_PATH_INVALID' "$findingPath.references" "Invalid knowledge path '$($reference.path)'." + continue + } + if (-not (Test-Path -LiteralPath (Join-Path $BCQualityRoot $reference.path) -PathType Leaf)) { + Add-Error 'REFERENCE_MISSING' "$findingPath.references" "Knowledge path '$($reference.path)' does not exist." + } + if (-not $retrieved.Contains($reference.path)) { + Add-Error 'REFERENCE_NOT_RETRIEVED' "$findingPath.references" "Knowledge path '$($reference.path)' was not retrieved in full." + } + } + } + + if (Test-HasProperty $finding 'location') { + $location = $finding.location + if (-not $sources.Contains($location.file)) { + Add-Error 'SOURCE_OUT_OF_SCOPE' "$findingPath.location.file" "Source path '$($location.file)' is outside the supplied scope." + } + $lineCount = Get-SourceLineCount $location.file + if ($lineCount -lt 0) { + Add-Error 'SOURCE_MISSING' "$findingPath.location.file" "Source path '$($location.file)' does not exist." + } + elseif ($location.line -gt $lineCount) { + Add-Error 'SOURCE_LINE_INVALID' "$findingPath.location.line" "Line exceeds the file's $lineCount lines." + } + + if (Test-HasProperty $location 'range') { + $range = $location.range + if ($range.'start-line' -ne $location.line) { + Add-Error 'RANGE_START_MISMATCH' "$findingPath.location.range.start-line" 'start-line must equal line.' + } + if ($range.'end-line' -lt $range.'start-line' -or + ($lineCount -ge 0 -and $range.'end-line' -gt $lineCount)) { + Add-Error 'SOURCE_RANGE_INVALID' "$findingPath.location.range" 'Range is reversed or exceeds the source file.' + } + } + } + } + + if ($CurrentSkillKind -ceq 'super' -and $hasSubResults) { + $subResults = @($Current.'sub-results') + for ($index = 0; $index -lt $subResults.Count; $index++) { + Test-Report $subResults[$index] "$ReportPathPrefix.sub-results[$index]" 'leaf' + } + + $expectedOutcome = Get-DerivedSuperOutcome $subResults + if ($Current.outcome -cne $expectedOutcome) { + Add-Error 'SUPER_OUTCOME_MISMATCH' "$ReportPathPrefix.outcome" "Expected '$expectedOutcome' from sub-results." + } + + $includedSubResults = @($subResults | Where-Object outcome -CNE 'failed') + $expectedWorklistSize = ($includedSubResults | Measure-Object -Property { $_.summary.coverage.'worklist-size' } -Sum).Sum + $expectedItemsEvaluated = ($includedSubResults | Measure-Object -Property { $_.summary.coverage.'items-evaluated' } -Sum).Sum + if ($null -eq $expectedWorklistSize) { $expectedWorklistSize = 0 } + if ($null -eq $expectedItemsEvaluated) { $expectedItemsEvaluated = 0 } + if ($worklistSize -ne $expectedWorklistSize -or $itemsEvaluated -ne $expectedItemsEvaluated) { + Add-Error 'SUPER_COVERAGE_MISMATCH' "$ReportPathPrefix.summary.coverage" ` + "Expected worklist-size $expectedWorklistSize and items-evaluated $expectedItemsEvaluated from non-failed sub-results." + } + + $failedProducerIds = @($subResults | Where-Object outcome -CEQ 'failed' | ForEach-Object { $_.skill.id }) + $includedProducerIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $eligibleFindings = [Collections.Generic.List[object]]::new() + foreach ($subResult in $includedSubResults) { + $includedProducerIds.Add([string]$subResult.skill.id) | Out-Null + foreach ($finding in @($subResult.findings)) { + $eligibleFindings.Add([pscustomobject]@{ + ProducerId = [string]$subResult.skill.id + Finding = $finding + }) | Out-Null + } + } + + for ($index = 0; $index -lt $findings.Count; $index++) { + $finding = $findings[$index] + $producerId = [string]$finding.'from-sub-skill' + if ($producerId -ceq 'agent') { + if (@($finding.references).Count -or + $finding.id -cnotmatch '^agent:[a-z0-9]+(?:-[a-z0-9]+)*$' -or + -not (Test-HasProperty $finding 'domain') -or + $finding.domain -cne 'Agent') { + Add-Error 'SUPER_AGENT_FINDING_INVALID' "$ReportPathPrefix.findings[$index]" ` + 'A super-skill agent finding must use an agent: id, Agent domain, and no references.' + } + continue + } + if ($producerId -cin $failedProducerIds) { + Add-Error 'SUPER_FAILED_FINDING_LEAKAGE' "$ReportPathPrefix.findings[$index].from-sub-skill" ` + "Finding is attributed to failed sub-skill '$producerId'." + continue + } + if (-not $includedProducerIds.Contains($producerId)) { + Add-Error 'SUPER_PRODUCER_INVALID' "$ReportPathPrefix.findings[$index].from-sub-skill" ` + "Sub-skill '$producerId' has no non-failed result." + continue + } + $ownedLeafFindings = @($eligibleFindings | Where-Object { + $_.ProducerId -ceq $producerId -and + (Test-RolledFindingRepresents $finding $_.Finding $_.ProducerId -RequirePrimaryOwner) + }) + if (-not $ownedLeafFindings.Count) { + Add-Error 'SUPER_FINDING_MISMATCH' "$ReportPathPrefix.findings[$index]" ` + "Rolled-up finding '$($finding.id)' is not owned by a matching finding from '$producerId'." + continue + } + $representedLeafFindings = @($eligibleFindings | Where-Object { + Test-RolledFindingRepresents $finding $_.Finding $_.ProducerId + }) + $representedCorrections = @( + $representedLeafFindings | + Where-Object { Test-HasProperty $_.Finding 'suggested-code' } | + ForEach-Object { $_.Finding.'suggested-code' } | + Sort-Object -CaseSensitive -Unique + ) + if ($representedCorrections.Count -gt 1) { + Add-Error 'SUPER_FINDING_MISMATCH' "$ReportPathPrefix.findings[$index]" ` + "Rolled-up finding '$($finding.id)' represents leaf findings with conflicting suggested-code replacements." + continue + } + $expectedSeverityRank = ($representedLeafFindings | ForEach-Object { Get-SeverityRank $_.Finding.severity } | Measure-Object -Maximum).Maximum + $expectedConfidenceRank = ($representedLeafFindings | ForEach-Object { Get-ConfidenceRank $_.Finding.confidence } | Measure-Object -Maximum).Maximum + if ((Get-SeverityRank $finding.severity) -ne $expectedSeverityRank -or + (Get-ConfidenceRank $finding.confidence) -ne $expectedConfidenceRank) { + Add-Error 'SUPER_FINDING_MISMATCH' "$ReportPathPrefix.findings[$index]" ` + "Rolled-up finding '$($finding.id)' must retain the highest severity and confidence justified by its represented leaf findings." + } + } + + for ($firstIndex = 0; $firstIndex -lt $findings.Count; $firstIndex++) { + for ($secondIndex = $firstIndex + 1; $secondIndex -lt $findings.Count; $secondIndex++) { + if ((Test-HasProperty $findings[$firstIndex] 'location') -and + (Test-HasProperty $findings[$secondIndex] 'location') -and + (Test-LocationsOverlap $findings[$firstIndex] $findings[$secondIndex]) -and + (Test-SameCorrection $findings[$firstIndex] $findings[$secondIndex])) { + Add-Error 'SUPER_DUPLICATE_FINDINGS' "$ReportPathPrefix.findings[$secondIndex]" ` + "Top-level findings $firstIndex and $secondIndex overlap and prescribe the same correction; they must be merged." + } + } + } + + $usedLocationlessFindings = [Collections.Generic.HashSet[int]]::new() + foreach ($eligibleFinding in @($eligibleFindings | Where-Object { -not (Test-HasProperty $_.Finding 'location') })) { + $matchingIndex = -1 + for ($index = 0; $index -lt $findings.Count; $index++) { + if (-not $usedLocationlessFindings.Contains($index) -and + -not (Test-HasProperty $findings[$index] 'location') -and + (Test-RolledFindingRepresents $findings[$index] $eligibleFinding.Finding $eligibleFinding.ProducerId)) { + $matchingIndex = $index + break + } + } + if ($matchingIndex -lt 0) { + Add-Error 'SUPER_FINDING_MISSING' "$ReportPathPrefix.findings" ` + "No distinct rolled-up finding represents a locationless finding from '$($eligibleFinding.ProducerId)'." + } + else { + $usedLocationlessFindings.Add($matchingIndex) | Out-Null + } + } + for ($index = 0; $index -lt $findings.Count; $index++) { + if ($findings[$index].'from-sub-skill' -cne 'agent' -and + -not (Test-HasProperty $findings[$index] 'location') -and + -not $usedLocationlessFindings.Contains($index)) { + Add-Error 'SUPER_FINDING_MISMATCH' "$ReportPathPrefix.findings[$index]" ` + 'No distinct locationless leaf finding corresponds to this rolled-up finding.' + } + } + + foreach ($eligibleFinding in @($eligibleFindings | Where-Object { Test-HasProperty $_.Finding 'location' })) { + $represented = @($findings | Where-Object { + $_.'from-sub-skill' -cne 'agent' -and + (Test-RolledFindingRepresents $_ $eligibleFinding.Finding $eligibleFinding.ProducerId) + }).Count + if (-not $represented) { + Add-Error 'SUPER_FINDING_MISSING' "$ReportPathPrefix.findings" ` + "No valid rolled-up finding represents a finding from '$($eligibleFinding.ProducerId)' at its source location." + } + } + } + } + + Test-Report $Candidate '$' $SkillKind + if ($PermitRangeStartMismatch) { + return @($errors | Where-Object Code -CNE 'RANGE_START_MISMATCH') + } + return @($errors) +} + +$errors = @(Get-SemanticErrors $report) +$normalized = $false +$removedRanges = [Collections.Generic.List[object]]::new() +if ($errors.Count -and $AllowBoundedNormalization) { + $otherErrors = @($errors | Where-Object Code -CNE 'RANGE_START_MISMATCH') + $rangeErrors = @($errors | Where-Object Code -CEQ 'RANGE_START_MISMATCH') + if (-not $otherErrors.Count -and $rangeErrors.Count) { + $candidate = $report | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 + $eligible = $true + foreach ($finding in @($candidate.findings)) { + if (-not (Test-HasProperty $finding 'location') -or + -not (Test-HasProperty $finding.location 'range') -or + $finding.location.range.'start-line' -eq $finding.location.line) { + continue + } + $range = $finding.location.range + if ($range.'start-line' -gt $finding.location.line -or + $finding.location.line -gt $range.'end-line' -or + (Test-HasProperty $finding 'suggested-code')) { + $eligible = $false + break + } + $removedRanges.Add([pscustomobject]@{ + findingId = $finding.id + file = $finding.location.file + line = $finding.location.line + startLine = $range.'start-line' + endLine = $range.'end-line' + }) | Out-Null + $finding.location.PSObject.Properties.Remove('range') + } + if ($eligible -and -not @(Get-SemanticErrors $candidate).Count) { + $report = $candidate + $normalized = $true + $errors = @() + } + } +} + +if ($errors.Count) { + $details = @($errors | ForEach-Object { "$($_.Code) at $($_.Path): $($_.Message)" }) -join '; ' + throw "Findings-report acceptance failed: $details" +} + +return [pscustomobject][ordered]@{ + normalized = $normalized + report = $report + removedRanges = @($removedRanges) +} \ No newline at end of file