)` when the flow must not show UI. No path should issue the request without approval.
-See sample: `privacy-notice-consent-for-external-data-transfer.good.al`.
+See sample: [`privacy-notice-consent-for-external-data-transfer.good.al`](privacy-notice-consent-for-external-data-transfer.good.al).
## Anti Pattern
A custom integration that posts data without checking its own notice, or that gates the call with a built-in ID such as the Exchange privacy notice ID. Consent for one service does not authorize another.
-See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`.
+See sample: [`privacy-notice-consent-for-external-data-transfer.bad.al`](privacy-notice-consent-for-external-data-transfer.bad.al).
diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md
index 4e76779..a9f12ff 100644
--- a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md
+++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md
@@ -17,7 +17,7 @@ The current extension point is `Codeunit "Privacy Notice"`. Extensions can subsc
Choose a stable ID owned by the extension. Register it through `OnRegisterPrivacyNotices`, or call `PrivacyNotice.CreatePrivacyNotice` during an intentional setup or upgrade path. Use that same ID for consent checks described in `privacy-notice-consent-for-external-data-transfer.md`.
-See sample: `register-integration-in-privacy-notice-registrations.good.al`.
+See sample: [`register-integration-in-privacy-notice-registrations.good.al`](register-integration-in-privacy-notice-registrations.good.al).
## Anti Pattern
diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md
index 67381f7..2febcae 100644
--- a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md
+++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md
@@ -17,10 +17,10 @@ application-area: [all]
Use the overload that takes `Verbosity`, `DataClassification`, and `TelemetryScope`. For payload-free operational telemetry that does not embed customer data, `DataClassification::SystemMetadata` is the right value. Choose `TelemetryScope::ExtensionPublisher` for telemetry meant for the publishing partner only; `TelemetryScope::All` also forwards to the customer's tenant telemetry.
-See sample: `session-logmessage-requires-dataclassification.good.al`.
+See sample: [`session-logmessage-requires-dataclassification.good.al`](session-logmessage-requires-dataclassification.good.al).
## Anti Pattern
Calling `Session.LogMessage('0003', 'Operation completed', Verbosity::Normal)` โ the overload omits `DataClassification` and leaves the platform without the information needed to classify the entry. Detection signal: a `Session.LogMessage` call whose argument list ends at `Verbosity`.
-See sample: `session-logmessage-requires-dataclassification.bad.al`.
+See sample: [`session-logmessage-requires-dataclassification.bad.al`](session-logmessage-requires-dataclassification.bad.al).
diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
index 253d2cc..f41dc11 100644
--- a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
+++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md
@@ -17,7 +17,7 @@ A valid table-level `DataClassification` is the effective default for the Normal
Use a table-level classification when it accurately describes the table's fields, and add a field-level classification only where a field stores a different kind of data. Do not flag a Normal field solely because it omits an explicit property when its own table supplies a valid default; verify whether the inherited value matches the field's data instead. A `tableextension` has no default to inherit, so require an explicit `DataClassification` on every Normal field it adds.
-See sample: `table-level-data-classification-cascades.good.al`.
+See sample: [`table-level-data-classification-cascades.good.al`](table-level-data-classification-cascades.good.al).
## Anti Pattern
diff --git a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md
index 4bc8816..0d7ca47 100644
--- a/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md
+++ b/microsoft/knowledge/query/reopening-query-resets-cursor-but-keeps-filters.md
@@ -17,10 +17,10 @@ Calling `Open()` on an already open query first closes the current dataset and o
Open once for one read pass. Close after the pass, and call `Clear(QueryVariable)` before reusing the variable for a logically independent query whose filters must start empty. Set the next pass's filters explicitly before reopening.
-See sample: `reopening-query-resets-cursor-but-keeps-filters.good.al`.
+See sample: [`reopening-query-resets-cursor-but-keeps-filters.good.al`](reopening-query-resets-cursor-but-keeps-filters.good.al).
## Anti Pattern
Calling `Open()` inside or between reads to "advance" or "start fresh", or reusing the same query variable for a new operation while assuming `Open()` cleared old filters. The code compiles but can repeatedly process the first row or silently omit rows behind a retained filter.
-See sample: `reopening-query-resets-cursor-but-keeps-filters.bad.al`.
+See sample: [`reopening-query-resets-cursor-but-keeps-filters.bad.al`](reopening-query-resets-cursor-but-keeps-filters.bad.al).
diff --git a/microsoft/knowledge/query/set-query-filters-before-open.md b/microsoft/knowledge/query/set-query-filters-before-open.md
index f999456..bb82e9d 100644
--- a/microsoft/knowledge/query/set-query-filters-before-open.md
+++ b/microsoft/knowledge/query/set-query-filters-before-open.md
@@ -17,10 +17,10 @@ application-area: [all]
Apply every filter before `Open()`, then read the dataset to completion and call `Close()`. When a later branch needs different filters, close or clear the query, set the new filters, and open a new dataset deliberately.
-See sample: `set-query-filters-before-open.good.al`.
+See sample: [`set-query-filters-before-open.good.al`](set-query-filters-before-open.good.al).
## Anti Pattern
`Query.Open()` followed by `SetFilter` or `SetRange` and then `Read()` under the assumption that the filter updates the open cursor. Refiltering after `Open()` is valid only when the code intentionally opens a fresh dataset afterward.
-See sample: `set-query-filters-before-open.bad.al`.
+See sample: [`set-query-filters-before-open.bad.al`](set-query-filters-before-open.bad.al).
diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md
index 7441712..649c384 100644
--- a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md
+++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md
@@ -15,8 +15,8 @@ AL does not ship a built-in `HtmlEncode` (or equivalent) function. Code that bui
## Best Practice
-Replace the four characters by hand before concatenating user content into HTML: `&` โ `&` first, then `<` โ `<`, `>` โ `>`, `"` โ `"`. Centralize the substitution in one helper so every HTML producer in the extension uses the same encoder. Better still, do not build raw HTML at all โ use a structured format (JSON for an API payload, a report layout for a printed document) and let the renderer do the encoding. See sample: `al-has-no-built-in-htmlencode.good.al`.
+Replace the four characters by hand before concatenating user content into HTML: `&` โ `&` first, then `<` โ `<`, `>` โ `>`, `"` โ `"`. Centralize the substitution in one helper so every HTML producer in the extension uses the same encoder. Better still, do not build raw HTML at all โ use a structured format (JSON for an API payload, a report layout for a printed document) and let the renderer do the encoding. See sample: [`al-has-no-built-in-htmlencode.good.al`](al-has-no-built-in-htmlencode.good.al).
## Anti Pattern
-`HtmlContent := 'Welcome ' + UserName + '!
'` โ any record-field value or user input concatenated directly into an HTML string. Reviewers should flag any string concatenation whose right-hand operand is a field, a parameter, or any non-literal value, and whose surrounding context contains HTML tags (`<`, ``, `
'` โ any record-field value or user input concatenated directly into an HTML string. Reviewers should flag any string concatenation whose right-hand operand is a field, a parameter, or any non-literal value, and whose surrounding context contains HTML tags (`<`, ``, `
0)and(Quantity>0) then
- exit(Price);
- exit(0);
- end;
-}
diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.good.al b/microsoft/knowledge/style/single-space-around-binary-operators.good.al
deleted file mode 100644
index 55793d3..0000000
--- a/microsoft/knowledge/style/single-space-around-binary-operators.good.al
+++ /dev/null
@@ -1,12 +0,0 @@
-codeunit 50228 "Sample Spaces Op Good"
-{
- procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal
- var
- Price: Decimal;
- begin
- Price := Amount * Quantity;
- if (Amount > 0) and (Quantity > 0) then
- exit(Price);
- exit(0);
- end;
-}
diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.md b/microsoft/knowledge/style/single-space-around-binary-operators.md
deleted file mode 100644
index a09fef9..0000000
--- a/microsoft/knowledge/style/single-space-around-binary-operators.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [spacing, binary-operator, aa0001, codecop, formatting]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# One space on each side of every binary operator (CodeCop AA0001)
-
-## Description
-
-CodeCop AA0001 requires exactly one space on each side of every binary operator: assignment (`:=`), arithmetic (`+`, `-`, `*`, `/`, `mod`, `div`), comparison (`=`, `<>`, `<`, `<=`, `>`, `>=`), logical (`and`, `or`, `xor`), and string concatenation. `x:=1+2`, `Price:=Amount*Quantity`, `if a=b then`, and `if a and b then` all violate the rule. The rule applies to the binary use of `-` (subtraction); the unary minus (`-Profit`) takes no leading space.
-
-## Best Practice
-
-Write `x := 1 + 2`, `Price := Amount * Quantity`, `if a = b then`, `if a and b then`. The standard AL formatter inserts these spaces automatically; running `Alt+Shift+F` (Format Document) in the AL extension is the simplest way to bring an entire file into compliance.
-
-See sample: `single-space-around-binary-operators.good.al`.
-
-## Anti Pattern
-
-`x:=1+2;`, `Price:=Amount*Quantity;`, `if a=b then`, `if a and b then`. All trip AA0001.
-
-See sample: `single-space-around-binary-operators.bad.al`.
diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.md b/microsoft/knowledge/style/temporary-variable-temp-prefix.md
index 16e85c2..2df9636 100644
--- a/microsoft/knowledge/style/temporary-variable-temp-prefix.md
+++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.md
@@ -17,10 +17,10 @@ A `Record` variable declared with the `temporary` modifier behaves nothing like
Every local or global variable of type `Record X temporary` must start with `Temp`. Ordinary procedure parameters follow the same convention. Event publisher parameters are owned by the events-domain rule `prefix-temporary-record-event-parameters-with-temp.md`; the style leaf must not emit a second finding for the same event parameter.
-See sample: `temporary-variable-temp-prefix.good.al`.
+See sample: [`temporary-variable-temp-prefix.good.al`](temporary-variable-temp-prefix.good.al).
## Anti Pattern
`WIPBuffer: Record "Job WIP Buffer" temporary;` as a local, global, or ordinary procedure parameter reads at the call site as if it were a database operation. Exclude event publisher parameters here so the events leaf remains their single owner.
-See sample: `temporary-variable-temp-prefix.bad.al`.
+See sample: [`temporary-variable-temp-prefix.bad.al`](temporary-variable-temp-prefix.bad.al).
diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al
deleted file mode 100644
index 7fd03a1..0000000
--- a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al
+++ /dev/null
@@ -1,14 +0,0 @@
-codeunit 50215 "Sample This Bad"
-{
- procedure ProcessRecord(Customer: Record Customer)
- var
- Helper: Codeunit "Sample This Helper";
- begin
- ValidateCustomer(Customer);
- Helper.DoWork();
- end;
-
- local procedure ValidateCustomer(Customer: Record Customer)
- begin
- end;
-}
diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al b/microsoft/knowledge/style/this-keyword-in-codeunits.good.al
deleted file mode 100644
index 392c042..0000000
--- a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al
+++ /dev/null
@@ -1,14 +0,0 @@
-codeunit 50214 "Sample This Good"
-{
- procedure ProcessRecord(Customer: Record Customer)
- var
- Helper: Codeunit "Sample This Helper";
- begin
- this.ValidateCustomer(Customer);
- Helper.DoWork(this);
- end;
-
- local procedure ValidateCustomer(Customer: Record Customer)
- begin
- end;
-}
diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.md b/microsoft/knowledge/style/this-keyword-in-codeunits.md
deleted file mode 100644
index b38cc24..0000000
--- a/microsoft/knowledge/style/this-keyword-in-codeunits.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [25..]
-domain: style
-keywords: [this, codeunit, self-reference, aa0248, scope]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Use the `this` keyword for self-reference inside codeunits (CodeCop AA0248)
-
-## Description
-
-CodeCop AA0248 recommends prefixing self-references inside a codeunit with `this`. `this.ValidateCustomer(Customer)` is unambiguous: the call resolves to a procedure on the current codeunit, not to a local variable or a procedure on a passed-in object. Without the prefix, a reader of a 200-line procedure has to scan the whole codeunit to confirm whether `ValidateCustomer` is local. `this` also makes it possible to pass the current codeunit as an argument โ `SomeOtherCodeunit.DoWork(this)` โ which is the only way to expose the running codeunit instance to a collaborator. The rule applies only to codeunits, not to pages, reports, queries, or tables โ those object types do not have a `this` reference in AL.
-
-## Best Practice
-
-Inside a codeunit, prefix calls to procedures and accesses to global variables on the same codeunit with `this.`, and pass `this` when an external codeunit needs a reference to the running instance.
-
-See sample: `this-keyword-in-codeunits.good.al`.
-
-## Anti Pattern
-
-Calling a codeunit-local procedure as a bare identifier (`ValidateCustomer(Customer)`) when other readings are possible. The ambiguity costs reading time on every encounter and grows with codeunit size.
-
-See sample: `this-keyword-in-codeunits.bad.al`.
diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al
index e6b356c..6f5f269 100644
--- a/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al
+++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al
@@ -1,23 +1,29 @@
page 50251 "Sample Tooltip Bad"
{
PageType = Card;
- SourceTable = Customer;
layout
{
area(Content)
{
group(General)
{
- field("No."; Rec."No.")
+ Caption = 'General';
+ field(CustomerNoValue; CustomerNoValue)
{
ApplicationArea = All;
+ Caption = 'Customer No.';
}
- field(Amount; Rec."Balance (LCY)")
+ field(PreviewAmount; PreviewAmount)
{
ApplicationArea = All;
+ Caption = 'Preview Amount';
ToolTip = '';
}
}
}
}
+
+ var
+ CustomerNoValue: Code[20];
+ PreviewAmount: Decimal;
}
diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al
index 1816de5..cd1fc8c 100644
--- a/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al
+++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al
@@ -1,24 +1,62 @@
+// BC24 / runtime 13.0 or later.
+table 50250 "Sample Tooltip Source"
+{
+ Caption = 'Tooltip Source';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+ ToolTip = 'Specifies the unique number used to distinguish this entry from other entries.';
+ }
+ field(2; Amount; Decimal)
+ {
+ Caption = 'Amount';
+ ToolTip = 'Specifies the monetary value recorded for this entry; changing it updates the saved entry.';
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+}
+
page 50250 "Sample Tooltip Good"
{
PageType = Card;
- SourceTable = Customer;
+ SourceTable = "Sample Tooltip Source";
layout
{
area(Content)
{
group(General)
{
+ Caption = 'General';
field("No."; Rec."No.")
{
ApplicationArea = All;
- ToolTip = 'Specifies the number that identifies the customer.';
}
- field(Amount; Rec."Balance (LCY)")
+ field(Amount; Rec.Amount)
{
ApplicationArea = All;
- ToolTip = 'Shows the total balance in local currency.';
+ ToolTip = 'Specifies the recorded amount to compare with the temporary preview amount.';
+ }
+ field(PreviewAmount; PreviewAmount)
+ {
+ ApplicationArea = All;
+ Caption = 'Preview Amount';
+ ToolTip = 'Specifies a temporary amount to compare with the recorded entry amount; this value is not saved.';
}
}
}
}
+
+ var
+ PreviewAmount: Decimal;
}
diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.md b/microsoft/knowledge/style/tooltip-required-on-page-fields.md
index a11d4a9..34f74c5 100644
--- a/microsoft/knowledge/style/tooltip-required-on-page-fields.md
+++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.md
@@ -1,30 +1,44 @@
---
bc-version: [all]
domain: style
-keywords: [tooltip, page-field, aa0218, codecop, accessibility, specifies]
+keywords: [tooltip, page-field, source-field, inheritance, aa0218, codecop, accessibility, specifies]
technologies: [al]
countries: [w1]
application-area: [all]
---
-# Every page field needs a `ToolTip` (CodeCop AA0218)
+# Page fields need an explicit or inherited `ToolTip` (CodeCop AA0218)
## Description
-CodeCop AA0218 requires a non-empty `ToolTip` property on every field control on a page. The tooltip is what users see on hover and is what screen readers announce; an empty or missing tooltip removes a piece of UI affordance that is part of BC's accessibility baseline. AppSource technical validation rejects pages with missing tooltips. The companion rules AA0219 and AA0220 push the wording further โ tooltips should describe what the field shows, conventionally starting with `'Specifies โฆ'`, though `'Shows โฆ'` and similar variants are acceptable when they clearly describe the field's purpose.
+User-facing page fields need tooltip text, but it does not have to be declared on each page control. Starting with BC24 (2024 release wave 1), runtime 13.0 supports `ToolTip` on table fields, and bound page fields inherit it unless they override it. A non-empty inherited tooltip satisfies the requirement; do not interpret CodeCop AA0218 as a requirement to repeat it on the page.
-Acceptable exceptions: table fields inside `Upgrade`, `Migration`, `HybridBC14`, `HybridSL`, and `HybridGP` codeunits and tables are allowed to omit the tooltip โ those types are not surfaced to users.
+For targets before runtime 13.0, table-field tooltip inheritance is not available, so user-facing page fields need page-level tooltips. Controls bound to variables or expressions also need page-level tooltips because they have no table field to inherit from. This is UI guidance, not a blanket requirement to add tooltips to every table field, including fields never exposed to users.
-AA0218 is a compiler analyzer, but its severity is configured per app in the ruleset and is frequently downgraded to `info`/`None` or disabled entirely. PR review therefore cannot assume the compiler will surface the gap: it is the last line of defence for a missing tooltip and should flag it independently. The one case review must *not* flag is a bound field that inherits a `ToolTip` from its source table field โ see `bound-page-field-inherits-source-field-tooltip`.
+AA0218's severity is configured per app and may be downgraded or disabled. Review should still report a genuinely missing tooltip, but absence of a page-level declaration alone is not evidence of a gap. See [bound page-field tooltip inheritance](../ui/bound-page-field-inherits-source-field-tooltip.md).
## Best Practice
-Every field control on a regular page carries `ToolTip = 'Specifies โฆ';` (or a clear alternative phrasing). Compose the text in the form "what this value shows" rather than "what the user does with it". In review, raise a `medium`-severity finding for a field that has neither an inline nor an inherited tooltip, independently of whether AA0218 is active in the app's ruleset.
+On runtime 13.0 or later, define shared tooltip text on the table field and omit duplicate page-level properties. Add a page-level `ToolTip` when no tooltip can be inherited or when the page needs different, context-specific help. Describe what the value shows, conventionally starting with "Specifies" or another clear phrasing.
-See sample: `tooltip-required-on-page-fields.good.al`.
+Make the text answer a question the caption does not: what the value is used for, which values or units are expected, or what changing it affects. Do not mechanically generate "Specifies the ." and consider the help complete. Use behavior established by the implementation or requirements; do not invent effects, defaults, or constraints to make a tooltip sound useful. Keep shared table-field help applicable to all pages that inherit it, and improve that shared text rather than duplicating it on each page.
+
+Before raising a `medium`-severity finding, check the target runtime, the control's binding, and the source field's tooltip, including dependency symbols when needed. Report a field with neither an explicit nor an inherited tooltip independently of whether AA0218 is active. If the source definition or target runtime is unavailable, do not assume a missing page property means missing tooltip text.
+
+See sample: [`tooltip-required-on-page-fields.good.al`](tooltip-required-on-page-fields.good.al) (BC24/runtime 13.0 or later).
## Anti Pattern
-A field control with no `ToolTip` property at all, or `ToolTip = '';`. AA0218 flags both; the hover state is blank and the screen reader has nothing to announce.
+A user-facing control with no page-level `ToolTip` and no non-empty source tooltip it can inherit, or a page-level `ToolTip = '';` that leaves the effective tooltip empty.
-See sample: `tooltip-required-on-page-fields.bad.al`.
+Flagging a bound field that already inherits its tooltip, or adding the same tooltip to every page, is also incorrect: duplicate overrides add maintenance and translation work and prevent source-field tooltip changes from reaching those pages.
+
+Treating a non-empty tooltip that merely repeats the caption as useful help is a separate quality issue, not a missing-tooltip finding. Point out the concrete information users need rather than demanding longer wording or a page-level override for its own sake.
+
+See sample: [`tooltip-required-on-page-fields.bad.al`](tooltip-required-on-page-fields.bad.al).
+
+## References
+
+[ToolTip property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property).
+
+[Guidelines for tooltip text](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/user-assistance#guidelines-for-tooltip-text).
diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al b/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al
deleted file mode 100644
index 3131fa6..0000000
--- a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al
+++ /dev/null
@@ -1,13 +0,0 @@
-codeunit 50247 "Sample Var Order Bad"
-{
- procedure Run()
- var
- CustomerNo: Code[20];
- TempBuffer: Record "Integer" temporary;
- Amount: Decimal;
- Customer: Record Customer;
- IsValid: Boolean;
- begin
- IsValid := Customer.Get(CustomerNo);
- end;
-}
diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al b/microsoft/knowledge/style/variable-declaration-order-by-type.good.al
deleted file mode 100644
index 590ed25..0000000
--- a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al
+++ /dev/null
@@ -1,13 +0,0 @@
-codeunit 50246 "Sample Var Order Good"
-{
- procedure Run()
- var
- Customer: Record Customer;
- TempBuffer: Record "Integer" temporary;
- CustomerNo: Code[20];
- Amount: Decimal;
- IsValid: Boolean;
- begin
- IsValid := Customer.Get(CustomerNo);
- end;
-}
diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.md b/microsoft/knowledge/style/variable-declaration-order-by-type.md
deleted file mode 100644
index 3435726..0000000
--- a/microsoft/knowledge/style/variable-declaration-order-by-type.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [variable-declaration, order, var, complex-types, aa0021]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Order variable declarations by type, complex types first (CodeCop AA0021)
-
-## Description
-
-CodeCop AA0021 requires that variable declarations inside a `var` block follow a fixed ordering by type, with complex (composite) types appearing before primitive types. The canonical order is `Record`, then `Report`, `Codeunit`, `XmlPort`, `Page`, `Query`, `Notification`, `BigText`, `DateFormula`, `RecordId`, `RecordRef`, `FieldRef`, `FilterPageBuilder`, then the simple types `Text`, `Code`, `Integer`, `Decimal`, `Boolean`, `Date`, `Time`, `DateTime`, `Char`, `Byte`. Inside each type group the variables can be alphabetical or in usage order. Temporary records still sort under `Record`.
-
-## Best Practice
-
-Declare all `Record` variables first, then other complex types, then primitives. A consistent order makes diffs review-friendly and matches the convention enforced by the AL formatter and CodeCop.
-
-See sample: `variable-declaration-order-by-type.good.al`.
-
-## Anti Pattern
-
-A `var` block where records and primitives are interleaved โ `CustomerNo: Code[20];` between two `Record` variables, or `Amount: Decimal;` declared above the `Customer: Record Customer;` it is computed from. AA0021 flags it and the block is harder to scan; readers expect composite types at the top.
-
-See sample: `variable-declaration-order-by-type.bad.al`.
diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al b/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al
deleted file mode 100644
index 65c8223..0000000
--- a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al
+++ /dev/null
@@ -1,19 +0,0 @@
-codeunit 50249 "Sample Shadow Bad"
-{
- var
- Customer: Record Customer;
-
- procedure ProcessSales()
- var
- Customer: Text;
- Amount: Decimal;
- begin
- Customer := 'C-100';
- Amount := 0;
- end;
-
- procedure Amount(): Decimal
- begin
- exit(0);
- end;
-}
diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al b/microsoft/knowledge/style/variable-name-must-not-shadow.good.al
deleted file mode 100644
index f5391ef..0000000
--- a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al
+++ /dev/null
@@ -1,19 +0,0 @@
-codeunit 50248 "Sample No Shadow Good"
-{
- var
- CustomerRec: Record Customer;
-
- procedure ProcessSales()
- var
- CustomerName: Text;
- SalesAmount: Decimal;
- begin
- CustomerName := CustomerRec.Name;
- SalesAmount := GetAmount();
- end;
-
- procedure GetAmount(): Decimal
- begin
- exit(0);
- end;
-}
diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.md b/microsoft/knowledge/style/variable-name-must-not-shadow.md
deleted file mode 100644
index 4fee2da..0000000
--- a/microsoft/knowledge/style/variable-name-must-not-shadow.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-bc-version: [all]
-domain: style
-keywords: [variable-name, shadow, conflict, aa0198, aa0202, aa0204, codecop]
-technologies: [al]
-countries: [w1]
-application-area: [all]
----
-
-# Local variable names must not shadow globals, fields, methods, or actions (CodeCop AA0198/AA0202/AA0204)
-
-## Description
-
-Three CodeCop rules โ AA0198, AA0202, AA0204 โ together forbid a local variable from sharing a name with a global variable on the same object, with a field on the same table or page source, with a procedure on the same object, or with an action on the same page. The compiler resolves the conflict by binding the closer scope, so a local `Customer: Text` will silently override a global `Customer: Record Customer` for the duration of a procedure โ every call site reading `Customer.Name` from inside that procedure refers to the text, and the breakage is invisible to a reader who has both declarations on screen.
-
-## Best Practice
-
-Differentiate every local declaration from globals, fields, procedures, and actions on the same object. `Customer` global plus `CustomerName` local; method `GetAmount` plus local `SalesAmount`. The standard pattern is to attach a noun suffix to the local (`CustomerName`, `CustomerRec`, `CustomerNo`) rather than to the global.
-
-See sample: `variable-name-must-not-shadow.good.al`.
-
-## Anti Pattern
-
-A procedure that declares a local `Customer: Text` inside a codeunit that already has a global `Customer: Record Customer`. The local wins and the global becomes unreachable inside the procedure. AA0198/AA0202/AA0204 flag this category of conflict whether the colliding entity is a global, a field, a method, or an action.
-
-See sample: `variable-name-must-not-shadow.bad.al`.
diff --git a/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md
index 940c9c8..c60887c 100644
--- a/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md
+++ b/microsoft/knowledge/telemetry/choose-telemetry-scope-by-audience.md
@@ -17,10 +17,10 @@ application-area: [all]
Use `ExtensionPublisher` for internal diagnostics that only the app publisher can interpret, such as cache behavior or private algorithm state. Use `All` for signals the tenant operator can act on, such as an integration failure, quota warning, or setup problem. Decide the audience independently from `DataClassification`; privacy guidance still governs whether the payload may be emitted at all.
-See sample: `choose-telemetry-scope-by-audience.good.al`.
+See sample: [`choose-telemetry-scope-by-audience.good.al`](choose-telemetry-scope-by-audience.good.al).
## Anti Pattern
Defaulting every call to `All`, including low-level implementation diagnostics, or defaulting every call to `ExtensionPublisher` and thereby hiding customer-actionable failures from environment telemetry. Review only when the message and surrounding branch make the intended audience clear; an ambiguous diagnostic is not enough to infer the wrong scope.
-See sample: `choose-telemetry-scope-by-audience.bad.al`.
+See sample: [`choose-telemetry-scope-by-audience.bad.al`](choose-telemetry-scope-by-audience.bad.al).
diff --git a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md
index eb05742..ae632f9 100644
--- a/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md
+++ b/microsoft/knowledge/telemetry/feature-uptake-transitions-in-order.md
@@ -17,10 +17,10 @@ application-area: [all]
Log `Discovered` when the user encounters the feature, `Set up` after its setup is completed, and `Used` when the user attempts it. Keep the same feature name throughout the funnel. Review ordering only when the changed repository context shows the feature's lifecycle; a single isolated `Used` call cannot prove that earlier states are absent elsewhere.
-See sample: `feature-uptake-transitions-in-order.good.al`.
+See sample: [`feature-uptake-transitions-in-order.good.al`](feature-uptake-transitions-in-order.good.al).
## Anti Pattern
Introducing a feature whose only uptake call jumps directly to `Set up` or `Used`, or using different feature-name literals for successive states. The calls compile and run, but the funnel silently omits the invalid transition.
-See sample: `feature-uptake-transitions-in-order.bad.al`.
+See sample: [`feature-uptake-transitions-in-order.bad.al`](feature-uptake-transitions-in-order.bad.al).
diff --git a/microsoft/knowledge/telemetry/feature-usage-only-after-success.md b/microsoft/knowledge/telemetry/feature-usage-only-after-success.md
index a1f118b..cc642d9 100644
--- a/microsoft/knowledge/telemetry/feature-usage-only-after-success.md
+++ b/microsoft/knowledge/telemetry/feature-usage-only-after-success.md
@@ -17,10 +17,10 @@ application-area: [all]
Call `LogUsage` only after the operation has completed successfully. On a failure path, call `LogError` with the captured error text and call stack when the failure must be emitted explicitly. Use a past-tense event name for usage and a present-tense scenario name for errors.
-See sample: `feature-usage-only-after-success.good.al`.
+See sample: [`feature-usage-only-after-success.good.al`](feature-usage-only-after-success.good.al).
## Anti Pattern
Calling `LogUsage` before a Boolean result, `TryFunction`, `Codeunit.Run`, or HTTP status has been checked, or calling it in both success and failure branches. Do not flag an attempt recorded with `LogUptake(...Used)`; unlike `LogUsage`, that state intentionally records an attempt.
-See sample: `feature-usage-only-after-success.bad.al`.
+See sample: [`feature-usage-only-after-success.bad.al`](feature-usage-only-after-success.bad.al).
diff --git a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md
index 4c2c41a..9291219 100644
--- a/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md
+++ b/microsoft/knowledge/telemetry/keep-custom-dimension-schema-stable.md
@@ -17,10 +17,10 @@ Business Central prefixes AL custom-dimension keys with `al` in Application Insi
Choose stable PascalCase keys such as `Operation`, `Result`, and `RecordCount`. Keep the key set and meaning stable for a shipped event ID; add a new event ID or coordinate a schema migration when the meaning must change. Privacy guidance separately governs whether a dimension value may contain customer data.
-See sample: `keep-custom-dimension-schema-stable.good.al`.
+See sample: [`keep-custom-dimension-schema-stable.good.al`](keep-custom-dimension-schema-stable.good.al).
## Anti Pattern
Keys such as `'order no'` or `'result_code'`, or renaming/removing a key while retaining the same shipped event ID. A naming-only issue is advisory; changing an existing event's schema is the material compatibility defect. New keys on a new event ID are not a breaking change.
-See sample: `keep-custom-dimension-schema-stable.bad.al`.
+See sample: [`keep-custom-dimension-schema-stable.bad.al`](keep-custom-dimension-schema-stable.bad.al).
diff --git a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md
index 4ff780a..cec444a 100644
--- a/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md
+++ b/microsoft/knowledge/telemetry/match-verbosity-to-signal-severity.md
@@ -17,10 +17,10 @@ application-area: [all]
Use `Error` for failed operations that need investigation and `Critical` only for abnormal termination or equivalent loss of service. Use `Warning` for degraded but completed behavior, `Normal` for successful business events, and `Verbose` for detailed diagnostics. Judge the outcome, not the procedure name: an expected optional lookup miss can legitimately remain `Normal` or `Verbose`.
-See sample: `match-verbosity-to-signal-severity.good.al`.
+See sample: [`match-verbosity-to-signal-severity.good.al`](match-verbosity-to-signal-severity.good.al).
## Anti Pattern
A `Session.LogMessage` in a failed `TryFunction`, failed `Codeunit.Run`, unsuccessful HTTP response, or other explicit failure branch that uses `Verbosity::Normal` or `Verbose` without evidence that the failure is expected and benign.
-See sample: `match-verbosity-to-signal-severity.bad.al`.
+See sample: [`match-verbosity-to-signal-severity.bad.al`](match-verbosity-to-signal-severity.bad.al).
diff --git a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md
index d88c020..d282dc4 100644
--- a/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md
+++ b/microsoft/knowledge/telemetry/register-one-telemetry-logger-per-publisher.md
@@ -17,10 +17,10 @@ The `Telemetry` and `Feature Telemetry` codeunits reach an extension publisher's
Place one internal logger implementation in one app for the publisher, forward its `LogMessage` method to `Session.LogMessage`, and register it from one event subscriber. Companion apps with the same publisher reuse that registration instead of each adding another. Evaluate absence only with repository or app-family context; a single-file diff cannot prove that no logger exists elsewhere.
-See sample: `register-one-telemetry-logger-per-publisher.good.al`.
+See sample: [`register-one-telemetry-logger-per-publisher.good.al`](register-one-telemetry-logger-per-publisher.good.al).
## Anti Pattern
Adding `FeatureTelemetry` calls to a complete app with no logger registration, or registering two logger implementations for apps that share the same publisher. The calls compile, but the telemetry module reports the missing or duplicate registration instead of behaving as intended.
-See sample: `register-one-telemetry-logger-per-publisher.bad.al`.
+See sample: [`register-one-telemetry-logger-per-publisher.bad.al`](register-one-telemetry-logger-per-publisher.bad.al).
diff --git a/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md
index ca6d2d6..4850db5 100644
--- a/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md
+++ b/microsoft/knowledge/telemetry/telemetry-event-id-stable-unique.md
@@ -23,10 +23,10 @@ The convention used by Microsoft first-party AL code is a short prefix identifyi
Assign each `Session.LogMessage` call a real, registered event ID drawn from the extension's catalogue. Treat the ID as part of the public contract of the event โ renaming it is a breaking change for consumers. Keep IDs short, deterministic, and free of personal or environment-specific tokens.
-See sample: `telemetry-event-id-stable-unique.good.al`.
+See sample: [`telemetry-event-id-stable-unique.good.al`](telemetry-event-id-stable-unique.good.al).
## Anti Pattern
Calling `Session.LogMessage('0000', ...)` (or `'1234'`, `'TODO'`, an empty string, a GUID generated at runtime, or any other placeholder) leaves the event unsearchable and indistinguishable from every other event using the same placeholder. The catalogue entry never gets created because the developer "will fix it later", and the placeholder ships.
-See sample: `telemetry-event-id-stable-unique.bad.al`.
+See sample: [`telemetry-event-id-stable-unique.bad.al`](telemetry-event-id-stable-unique.bad.al).
diff --git a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
index 0dee645..4560a18 100644
--- a/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
+++ b/microsoft/knowledge/testing/asserterror-needs-expectederror-and-code.md
@@ -17,10 +17,10 @@ application-area: [all]
Follow every `asserterror` with a verification of the error it expects, and prefer the reusable `Library Assert` helpers over hardcoded literals. For a mandatory-field check, `Assert.ExpectedTestFieldError(FieldCaption, ExpectedValue)` encapsulates both the message and the `TestField` code, so the test survives caption or code changes and does not repeat that knowledge in every method. For other errors, pair `Assert.ExpectedError` with a stable substring โ ideally a shared `Label`, not an inline sentence โ and, where known, `Assert.ExpectedErrorCode`. When a needed check is missing from the shared library, extend `Library Assert` (or your own assert library) with a helper rather than hardcoding message text and codes across tests; matching on a code or an invariant fragment keeps the test from going blind to the wrong error when a caption is localized.
-See sample: `asserterror-needs-expectederror-and-code.good.al`.
+See sample: [`asserterror-needs-expectederror-and-code.good.al`](asserterror-needs-expectederror-and-code.good.al).
## Anti Pattern
`asserterror DoInvalid();` with nothing after it. The test asserts only that the call failed somehow; swap the validation for a different bug and the test still passes, certifying a guard that may no longer fire. A negative test that cannot tell one error from another verifies almost nothing.
-See sample: `asserterror-needs-expectederror-and-code.bad.al`.
+See sample: [`asserterror-needs-expectederror-and-code.bad.al`](asserterror-needs-expectederror-and-code.bad.al).
diff --git a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md
index e53986b..1722578 100644
--- a/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md
+++ b/microsoft/knowledge/testing/permission-tests-must-lower-the-execution-context.md
@@ -19,10 +19,10 @@ What matters is the effective permission context at the moment the protected ope
Use `TestPermissions::Restrictive` for a permission-sensitive test and lower the current test user with the test framework's `"Permissions Mock"` or `"Library - Lower Permissions"` before invoking the protected operation. Assign a permission context that actually contains the rights the scenario tests โ either the permission set itself or a role that includes it โ and restore or stop the mock afterward. Use `Disabled` only for suites that do not assert permission behavior, or where the test lowers the context explicitly through the test libraries instead of relying on the runner. Do not require a test to apply the permission set under test directly when it reaches the same rights through a composed role and then asserts the boundary.
-See sample: `permission-tests-must-lower-the-execution-context.good.al`.
+See sample: [`permission-tests-must-lower-the-execution-context.good.al`](permission-tests-must-lower-the-execution-context.good.al).
## Anti Pattern
Setting `TestPermissions = Disabled` or leaving the effective D365 Full Access context in place while asserting that a limited user is denied, or adding a `[TestPermissions(...)]` attribute without any runner/test-library code that applies the intended permission set. Do not report the mirror image: a test that lowers the context through a role including the permission set under test, and then asserts the boundary, has exercised that permission set and is not a coverage gap.
-See sample: `permission-tests-must-lower-the-execution-context.bad.al`.
+See sample: [`permission-tests-must-lower-the-execution-context.bad.al`](permission-tests-must-lower-the-execution-context.bad.al).
diff --git a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md
index 03d8854..f296d51 100644
--- a/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md
+++ b/microsoft/knowledge/testing/testisolation-belongs-on-the-test-runner.md
@@ -17,10 +17,10 @@ application-area: [all]
Run independent suites with `TestIsolation = Codeunit` or `Function`, choosing the narrowest boundary the runner supports. Pair this with the appropriate method-level `TransactionModel`: `AutoCommit` permits code under test to commit, while runner isolation still restores the database afterward. Keep isolation disabled only for an intentionally shared-state suite whose ordering and cleanup are explicit.
-See sample: `testisolation-belongs-on-the-test-runner.good.al`.
+See sample: [`testisolation-belongs-on-the-test-runner.good.al`](testisolation-belongs-on-the-test-runner.good.al).
## Anti Pattern
An `AutoCommit` test exercises committed writes under a test runner that omits `TestIsolation` or sets it to `Disabled`, then assumes the database is restored automatically. This article owns runner-level rollback; `transactionmodel-attribute-governs-test-transactions.md` separately owns the method attribute.
-See sample: `testisolation-belongs-on-the-test-runner.bad.al`.
+See sample: [`testisolation-belongs-on-the-test-runner.bad.al`](testisolation-belongs-on-the-test-runner.bad.al).
diff --git a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
index ab89a96..c9c159a 100644
--- a/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
+++ b/microsoft/knowledge/testing/transactionmodel-attribute-governs-test-transactions.md
@@ -17,10 +17,10 @@ application-area: [all]
Default to `AutoRollback`: it opens a write transaction at the start of the test, runs the test body, and rolls back at the end, leaving the database in its original state. Pick `AutoCommit` only when the code under test genuinely calls `Commit` โ posting routines, job-queue handlers, integration flows โ and make the test exercise that commit path. Pair the test codeunit with a `TestIsolation`-enabled test runner so committed changes are reverted at a higher scope. Pick `None` only for read-only tests or tests that drive UI code without writing from the test method itself.
-See sample: `transactionmodel-attribute-governs-test-transactions.good.al`.
+See sample: [`transactionmodel-attribute-governs-test-transactions.good.al`](transactionmodel-attribute-governs-test-transactions.good.al).
## Anti Pattern
Applying `AutoRollback` to every test method without checking whether the tested business logic calls `Commit`. The test throws at the first Commit, leaving no verdict on the behavior it intended to verify; in a CI run this looks like a flake or a setup bug, not a specification mismatch. The mirror-image anti-pattern is defaulting to `AutoCommit` across the suite "to avoid the error" โ without a `TestIsolation` runner this permanently dirties the test database between runs and produces order-dependent test outcomes.
-See sample: `transactionmodel-attribute-governs-test-transactions.bad.al`.
+See sample: [`transactionmodel-attribute-governs-test-transactions.bad.al`](transactionmodel-attribute-governs-test-transactions.bad.al).
diff --git a/microsoft/knowledge/testing/ui-handlers-in-tests.md b/microsoft/knowledge/testing/ui-handlers-in-tests.md
index d451301..9ca1ce1 100644
--- a/microsoft/knowledge/testing/ui-handlers-in-tests.md
+++ b/microsoft/knowledge/testing/ui-handlers-in-tests.md
@@ -21,10 +21,10 @@ Beyond that wiring guarantee, the test must verify the behavior it cares about.
List the handlers the scenario triggers, keep an optional notification handler listed for a notification the scenario may conditionally raise, and make each executed handler contribute meaningful evidence. For a single modal page, reset a capture variable before the action, capture a concrete value from the page in the handler, and assert the expected value after `RunModal`. For ordered or repeated interactions, let the test enqueue expectations, let handlers dequeue and verify them, clear storage during initialization, and finish with `AssertEmpty`.
-See sample: `ui-handlers-in-tests.good.al`.
+See sample: [`ui-handlers-in-tests.good.al`](ui-handlers-in-tests.good.al).
## Anti Pattern
Omitting a handler for a UI call, listing a nonoptional handler the path never reaches, or claiming action success from a Boolean set before the action runs. A handler that only closes a page can also leave the test without a semantic assertion. Do not flag the absence of queue storage by itself; require it only when the test needs to prove interaction order, count, text, replies, or a scripted sequence. Do not flag a listed `[SendNotificationHandler(true)]` or `[RecallNotificationHandler(true)]` that the run does not reach, and never propose removing one: the entry is what keeps the test passing on the runs where the notification does fire.
-See sample: `ui-handlers-in-tests.bad.al`.
+See sample: [`ui-handlers-in-tests.bad.al`](ui-handlers-in-tests.bad.al).
diff --git a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
index 270a146..9c8f092 100644
--- a/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
+++ b/microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md
@@ -17,10 +17,10 @@ BC ships a layer of test Library codeunits โ `LibrarySales`, `LibraryPurchase`
Reach for the matching Library codeunit before writing manual record setup: `LibrarySales.CreateCustomer`, `LibrarySales.CreateSalesHeader`/`CreateSalesLine`, `LibraryInventory.CreateItem`, `LibraryERM.CreateGLAccount`, and `LibraryRandom.RandInt`/`RandDec` for values. Create the prerequisite parents first and reference their primary keys from dependent records, and `Validate` the foreign-key field so the `TableRelation` โ and any field-validation logic โ runs exactly as it would in production. Pass the records they return into the code under test. The fixtures stay valid across upgrades because the library โ not your test โ owns the knowledge of what a well-formed record requires.
-See sample: `use-library-codeunits-for-test-fixtures.good.al`.
+See sample: [`use-library-codeunits-for-test-fixtures.good.al`](use-library-codeunits-for-test-fixtures.good.al).
## Anti Pattern
`Customer.Init(); Customer."No." := 'X'; Customer.Insert();` โ a record with a hand-picked primary key, no number-series entry, and none of the mandatory fields a real customer needs. It compiles and may even insert, but it bypasses setup the production code assumes, and it breaks the first time the schema gains a required field the test does not know about.
-See sample: `use-library-codeunits-for-test-fixtures.bad.al`.
+See sample: [`use-library-codeunits-for-test-fixtures.bad.al`](use-library-codeunits-for-test-fixtures.bad.al).
diff --git a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md
index 87b539b..00dc9b8 100644
--- a/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md
+++ b/microsoft/knowledge/ui/bound-page-field-inherits-source-field-tooltip.md
@@ -1,5 +1,5 @@
---
-bc-version: [all]
+bc-version: [24..]
domain: ui
keywords: [tooltip, page-field, source-field, inheritance, aa0218, false-positive]
technologies: [al]
@@ -11,14 +11,20 @@ application-area: [all]
## Description
-A page field bound to a table field inherits the source field's `ToolTip` at runtime: the control shows the table field's `ToolTip` even when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect โ the text may be supplied by the bound source field.
+Starting with BC24 (2024 release wave 1), runtime 13.0 supports `ToolTip` on table fields. A page field bound to a table field inherits the source field's `ToolTip` when the page control declares none of its own. A page field without an inline `ToolTip` is therefore not, by itself, a missing-tooltip defect. This inheritance is not available when targeting earlier runtimes.
-The genuinely-missing case is different: a bound field whose source table field *also* carries no `ToolTip`, or an unbound control, has no text to inherit and is a real accessibility gap. The compiler analyzer AA0218 detects this mechanically, but its severity is set by each app's ruleset and is routinely downgraded or disabled โ so it cannot be relied on as the only net. PR review is the last line of defence and should raise this case independently.
+The genuinely-missing case is different: a control with no inline `ToolTip` also has no text to inherit when it is unbound or its source table field carries no non-empty `ToolTip`. This leaves a real user-assistance gap. The compiler analyzer AA0218 detects this mechanically, but its severity is set by each app's ruleset and may be downgraded or disabled, so review should raise the genuine gap independently.
## Best Practice
-Do not raise a missing-`ToolTip` finding for a bound page field whose source table field supplies a `ToolTip`; assume the control inherits it. Do raise a `medium`-severity finding when the field has no inline `ToolTip` **and** no inherited one โ that is, a bound field whose source field is also tooltip-less, or an unbound control โ rather than assuming AA0218 will catch it downstream.
+Check the target runtime and inspect the source field, including dependency symbols when needed. On runtime 13.0 or later, do not raise a missing-`ToolTip` finding for a bound page field whose source table field supplies a non-empty `ToolTip`, and do not add a duplicate page-level property. A page-level override is appropriate only when the page needs different help text or no tooltip can be inherited.
+
+Do raise a `medium`-severity finding when the field has no inline `ToolTip` **and** no inherited one, rather than assuming AA0218 will catch it downstream. If the source definition is unavailable, do not infer that its tooltip is missing. See [tooltip requirements across target versions](../style/tooltip-required-on-page-fields.md).
## Anti Pattern
Two opposite failures: (1) flagging every page field that has no inline `ToolTip` as a violation, ignoring that a bound field inherits its source field's tooltip; and (2) staying silent on a field that has neither an inline nor an inherited tooltip on the assumption that the compiler's AA0218 will report it โ a ruleset that downgrades or disables AA0218 then lets a genuine gap ship unflagged.
+
+## References
+
+[ToolTip property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-tooltip-property).
diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md
index f0d3f8c..648a138 100644
--- a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md
+++ b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md
@@ -19,7 +19,7 @@ Noun-phrase captions (object names, field labels such as `'Source Document No.'`
For an action `Caption` that reads as a sentence or verb phrase, capitalize only the first word and proper nouns (sentence case). Do not require every significant word to be capitalized. Before flagging a caption as "should be title case", confirm it is a noun phrase; leave imperative/sentence-phrase action captions in sentence case.
-See sample: `caption-capitalization-noun-phrase-vs-sentence-phrase.good.al`.
+See sample: [`caption-capitalization-noun-phrase-vs-sentence-phrase.good.al`](caption-capitalization-noun-phrase-vs-sentence-phrase.good.al).
## Anti Pattern
diff --git a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md
index d5edd39..f0b5314 100644
--- a/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md
+++ b/microsoft/knowledge/ui/control-addin-package-resource-ajax-needs-withcredentials.md
@@ -17,13 +17,13 @@ JavaScript in a Business Central control add-in can load a static resource from
Use an AJAX form that explicitly enables `withCredentials` whenever a control add-in requests a packaged static resource. Keep this rule scoped to resources served from the add-in package; it is not generic advice to attach credentials to arbitrary external requests.
-See sample: `control-addin-package-resource-ajax-needs-withcredentials.good.js`.
+See sample: [`control-addin-package-resource-ajax-needs-withcredentials.good.js`](control-addin-package-resource-ajax-needs-withcredentials.good.js).
## Anti Pattern
Using `$.get(url)` or an `XMLHttpRequest` without `withCredentials = true` to retrieve package content. The request can lack the context and cookies required by the Business Central service.
-See sample: `control-addin-package-resource-ajax-needs-withcredentials.bad.js`.
+See sample: [`control-addin-package-resource-ajax-needs-withcredentials.bad.js`](control-addin-package-resource-ajax-needs-withcredentials.bad.js).
## Source
diff --git a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md
index 987eedb..92d29b5 100644
--- a/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md
+++ b/microsoft/knowledge/ui/control-addin-throttle-al-calls-and-payload-size.md
@@ -17,13 +17,13 @@ application-area: [all]
Send byte-bounded chunks and invoke the next AL event only from the previous call's completion callback. Handle the error callback and stop until the caller explicitly retries or discards the failed chunk. There is no universal safe threshold, so measure the serialized argument array, reserve transport headroom below the server's `ClientServicesMaxUploadSize`, and reject an individual item that exceeds the configured budget.
-See sample: `control-addin-throttle-al-calls-and-payload-size.good.js`.
+See sample: [`control-addin-throttle-al-calls-and-payload-size.good.js`](control-addin-throttle-al-calls-and-payload-size.good.js).
## Anti Pattern
Calling `InvokeExtensibilityMethod` on an interval without tracking completion, recursively creating intervals, or serializing an entire unbounded dataset into one call. These patterns can overwhelm the client-service channel or exceed the upload limit.
-See sample: `control-addin-throttle-al-calls-and-payload-size.bad.js`.
+See sample: [`control-addin-throttle-al-calls-and-payload-size.bad.js`](control-addin-throttle-al-calls-and-payload-size.bad.js).
## Source
diff --git a/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
index 185e56f..a3607c3 100644
--- a/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
+++ b/microsoft/knowledge/ui/default-descending-sort-on-historical-pages.md
@@ -15,9 +15,9 @@ Historical list pages should default to showing the newest records first. On pag
## Best Practice
Set descending sort as the default on list pages whose primary purpose is to present historical records. This is the expected default for entry, log, archive, and posted-history pages unless there is a specific requirement to begin with the oldest record.
-See sample: `default-descending-sort-on-historical-pages.good.al`.
+See sample: [`default-descending-sort-on-historical-pages.good.al`](default-descending-sort-on-historical-pages.good.al).
## Anti Pattern
Using an oldest-first default order on a historical list page where users are primarily interested in recent activity. Typical signs include history, log, or entry pages that regularly need to be re-sorted to descending during normal use.
-See sample: `default-descending-sort-on-historical-pages.bad.al`.
\ No newline at end of file
+See sample: [`default-descending-sort-on-historical-pages.bad.al`](default-descending-sort-on-historical-pages.bad.al).
\ No newline at end of file
diff --git a/microsoft/knowledge/ui/grid-data-table-heuristic.md b/microsoft/knowledge/ui/grid-data-table-heuristic.md
index 2cd6b63..1f82a52 100644
--- a/microsoft/knowledge/ui/grid-data-table-heuristic.md
+++ b/microsoft/knowledge/ui/grid-data-table-heuristic.md
@@ -25,4 +25,4 @@ Any grid or fixed layout that does not meet all three conditions renders as a la
If you intend a grid or fixed layout to render as a data table, satisfy all three conditions and verify the resulting markup matches your intent. If you do not need tabular semantics, prefer simple groups over grid or fixed layouts โ they reflow better and produce correct semantic markup automatically.
-See sample: `grid-data-table-heuristic.good.al`.
+See sample: [`grid-data-table-heuristic.good.al`](grid-data-table-heuristic.good.al).
diff --git a/microsoft/knowledge/ui/group-labeled-first-child-exception.md b/microsoft/knowledge/ui/group-labeled-first-child-exception.md
index 2f47ae3..1acdb36 100644
--- a/microsoft/knowledge/ui/group-labeled-first-child-exception.md
+++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.md
@@ -23,10 +23,10 @@ When these three conditions hold, the group caption becomes the accessible label
Do not second-guess this exception. If the three conditions are met, the pattern is acceptable โ even if the group caption seems generic (e.g. "General Information") or does not exactly match the field name.
-See sample: `group-labeled-first-child-exception.good.al`.
+See sample: [`group-labeled-first-child-exception.good.al`](group-labeled-first-child-exception.good.al).
## Anti Pattern
If the parent group has `ShowCaption = false` or no `Caption`, the first-child exception does not apply: the field has no accessible label anywhere.
-See sample: `group-labeled-first-child-exception.bad.al`.
+See sample: [`group-labeled-first-child-exception.bad.al`](group-labeled-first-child-exception.bad.al).
diff --git a/microsoft/knowledge/ui/no-nested-grids.md b/microsoft/knowledge/ui/no-nested-grids.md
index 9de7ba4..d678809 100644
--- a/microsoft/knowledge/ui/no-nested-grids.md
+++ b/microsoft/knowledge/ui/no-nested-grids.md
@@ -19,4 +19,4 @@ Always flag a nested grid as a violation. The fix is to restructure the page so
Wrapping a working data-table grid inside another grid in an attempt to compose two tabular regions side by side. The outer grid silently degrades to layout-table rendering, the inner grid's headers are no longer associated with the outer structure, and editable fields with `ShowCaption = false` lose their labels.
-See sample: `no-nested-grids.bad.al`.
+See sample: [`no-nested-grids.bad.al`](no-nested-grids.bad.al).
diff --git a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md
index 5f26333..0fac910 100644
--- a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md
+++ b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md
@@ -19,4 +19,4 @@ This is a narrow platform exception to `semantic-styles-need-independent-textual
You may apply `Favorable`, `Unfavorable`, or `Ambiguous` to fields inside a `cuegroup` without supplying a redundant textual indicator โ the platform supplies the screen-reader text. Reserve this shortcut for cue tiles only; do not extend it to other layout containers.
-See sample: `semantic-style-in-cuegroup-exception.good.al`.
+See sample: [`semantic-style-in-cuegroup-exception.good.al`](semantic-style-in-cuegroup-exception.good.al).
diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md
index 9d58d41..de03248 100644
--- a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md
+++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md
@@ -27,8 +27,8 @@ The rule applies equally whether `Style` is set to a literal value or to a varia
## Best Practice
-When you reach for `Favorable`, `Unfavorable`, or `Ambiguous`, verify that the caption, value, or an adjacent column already conveys the same meaning. See sample: `semantic-styles-need-independent-textual-meaning.good.al`.
+When you reach for `Favorable`, `Unfavorable`, or `Ambiguous`, verify that the caption, value, or an adjacent column already conveys the same meaning. See sample: [`semantic-styles-need-independent-textual-meaning.good.al`](semantic-styles-need-independent-textual-meaning.good.al).
## Anti Pattern
-Applying a semantic style for purely cosmetic emphasis (e.g. green company name for aesthetics), or using semantic colors where only the color reveals the threshold (e.g. confidence percentages with no qualitative label). See sample: `semantic-styles-need-independent-textual-meaning.bad.al`.
+Applying a semantic style for purely cosmetic emphasis (e.g. green company name for aesthetics), or using semantic colors where only the color reveals the threshold (e.g. confidence percentages with no qualitative label). See sample: [`semantic-styles-need-independent-textual-meaning.bad.al`](semantic-styles-need-independent-textual-meaning.bad.al).
diff --git a/microsoft/knowledge/ui/set-selection-filter-list-scope.md b/microsoft/knowledge/ui/set-selection-filter-list-scope.md
index 513590c..8d0fc45 100644
--- a/microsoft/knowledge/ui/set-selection-filter-list-scope.md
+++ b/microsoft/knowledge/ui/set-selection-filter-list-scope.md
@@ -17,11 +17,11 @@ The base platform avoids this ambiguity by routing batch list actions through Re
## Best Practice
-After calling `SetSelectionFilter`, test `MarkedOnly`. When it is false โ meaning the user made no explicit selection, or selected all rows with Ctrl+A โ discard the single-row primary key filter by copying the page source record (`Copy(Rec)`), which carries the full page view including all active filter groups. When `MarkedOnly` is true the user made a deliberate selection and that filter should be respected as-is. Refer to `set-selection-filter-list-scope.good.al` for the pattern.
+After calling `SetSelectionFilter`, test `MarkedOnly`. When it is false โ meaning the user made no explicit selection, or selected all rows with Ctrl+A โ discard the single-row primary key filter by copying the page source record (`Copy(Rec)`), which carries the full page view including all active filter groups. When `MarkedOnly` is true the user made a deliberate selection and that filter should be respected as-is. Refer to [`set-selection-filter-list-scope.good.al`](set-selection-filter-list-scope.good.al) for the pattern.
## Anti Pattern
-Passing the result of `SetSelectionFilter` directly to a processing codeunit without checking `MarkedOnly`. When the user runs the action with the cursor on row three and no rows highlighted, the codeunit receives a filter that matches only row three. The action appears to succeed but processes a fraction of the intended scope. The defect is hard to notice because no error is raised and the single-row run completes without complaint. See `set-selection-filter-list-scope.bad.al`.
+Passing the result of `SetSelectionFilter` directly to a processing codeunit without checking `MarkedOnly`. When the user runs the action with the cursor on row three and no rows highlighted, the codeunit receives a filter that matches only row three. The action appears to succeed but processes a fraction of the intended scope. The defect is hard to notice because no error is raised and the single-row run completes without complaint. See [`set-selection-filter-list-scope.bad.al`](set-selection-filter-list-scope.bad.al).
## See also
diff --git a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md
index dcf4d95..2b830bc 100644
--- a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md
+++ b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md
@@ -19,4 +19,4 @@ This exception does **not** extend to dynamically editable fields. A field with
If you want to hide a field's caption, pair `ShowCaption = false` with a literal `Editable = false`. Use this pattern only for content fields that do not act as labels for other fields in the same layout container.
-See sample: `show-caption-false-allowed-on-non-editable-fields.good.al`.
+See sample: [`show-caption-false-allowed-on-non-editable-fields.good.al`](show-caption-false-allowed-on-non-editable-fields.good.al).
diff --git a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md
index 9b2e43b..b170205 100644
--- a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md
+++ b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md
@@ -19,4 +19,4 @@ Fields in the `area(Content)` section of the same PromptDialog page are **not**
In a PromptDialog, give the page a meaningful `Caption` (the dialog heading) and let prompt-area input fields hide their own captions. Treat content-area fields like any other editable field โ keep their captions.
-See sample: `show-caption-in-promptdialog-prompt-area.good.al`.
+See sample: [`show-caption-in-promptdialog-prompt-area.good.al`](show-caption-in-promptdialog-prompt-area.good.al).
diff --git a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md
index b161149..f81762f 100644
--- a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md
+++ b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md
@@ -19,4 +19,4 @@ This is the explicit behaviour of the Business Central client: a repeater render
Inside a repeater, you may set `ShowCaption = false` on fields without losing accessibility. The column header still provides the label for every cell in that column. Outside a repeater, the rules in `show-caption-on-editable-fields.md` apply.
-See sample: `show-caption-in-repeater-allowed.good.al`.
+See sample: [`show-caption-in-repeater-allowed.good.al`](show-caption-in-repeater-allowed.good.al).
diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.md b/microsoft/knowledge/ui/show-caption-on-editable-fields.md
index 23075fd..604ace0 100644
--- a/microsoft/knowledge/ui/show-caption-on-editable-fields.md
+++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.md
@@ -19,10 +19,10 @@ A field whose `Editable` property is a Boolean expression (e.g. `Editable = IsEd
Leave `ShowCaption` at its default on editable fields. If a caption would be visually redundant, rely on one of the documented magic patterns (group-labeled first child, repeater column, PromptDialog prompt input) rather than removing the caption.
-See sample: `show-caption-on-editable-fields.good.al`.
+See sample: [`show-caption-on-editable-fields.good.al`](show-caption-on-editable-fields.good.al).
## Anti Pattern
The `InstructionalText` property on a field renders as HTML placeholder text and is **not** a substitute for a caption โ it disappears once the user types and is not reliably announced by screen readers.
-See sample: `show-caption-on-editable-fields.bad.al`.
+See sample: [`show-caption-on-editable-fields.bad.al`](show-caption-on-editable-fields.bad.al).
diff --git a/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md
index 91fc65e..eeffc07 100644
--- a/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md
+++ b/microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md
@@ -17,11 +17,11 @@ application-area: [all]
## Best Practice
-Set `ShowMandatory = true` on every visible, editable page field whose value the user must supply before the record can be committed or an action can complete, and leave the enforcement in place: the property is presentation, `TestField`/`Error` is the guarantee, and the two belong together in the same change. When the requirement is conditional, bind `ShowMandatory` to a Boolean variable or field that mirrors the condition the enforcement checks โ the base application drives `Vendor Invoice No.` on the Purchase Invoice page from an `Ext. Doc. No. Mandatory` setup flag this way. Two expression limits are worth knowing: the property cannot call an AL method, so compute the value into a variable first, and a numeric field that has a default value counts as filled, so it never shows the asterisk. See sample: `showmandatory-on-code-required-page-fields.good.al`.
+Set `ShowMandatory = true` on every visible, editable page field whose value the user must supply before the record can be committed or an action can complete, and leave the enforcement in place: the property is presentation, `TestField`/`Error` is the guarantee, and the two belong together in the same change. When the requirement is conditional, bind `ShowMandatory` to a Boolean variable or field that mirrors the condition the enforcement checks โ the base application drives `Vendor Invoice No.` on the Purchase Invoice page from an `Ext. Doc. No. Mandatory` setup flag this way. Two expression limits are worth knowing: the property cannot call an AL method, so compute the value into a variable first, and a numeric field that has a default value counts as filled, so it never shows the asterisk. See sample: [`showmandatory-on-code-required-page-fields.good.al`](showmandatory-on-code-required-page-fields.good.al).
## Anti Pattern
-A required field with no mandatory marker: the table's `OnInsert` or the page's `OnInsertRecord` calls `TestField` on a field, or `NotBlank` is expected to force entry, while the page field bound to it carries no `ShowMandatory`. On a `DelayedInsert = true` list page the user fills the row, leaves it, and gets an error naming a field that never looked different from the optional ones. Reviewer signal: code on the relevant commit or action path requires the user to supply a field, the corresponding page control is visible and editable, and its `ShowMandatory` property is missing or does not mirror the same condition. A `TestField` or `Error` elsewhere in `OnValidate` or `OnModify` is not sufficient evidence: the field may be populated by code, non-editable, or required only for another path. Setting `ShowMandatory = false` on a field that is unconditionally required on the current path is the same defect stated explicitly, and per the documentation it also overrides any marking `NotBlank` would otherwise contribute. See sample: `showmandatory-on-code-required-page-fields.bad.al`.
+A required field with no mandatory marker: the table's `OnInsert` or the page's `OnInsertRecord` calls `TestField` on a field, or `NotBlank` is expected to force entry, while the page field bound to it carries no `ShowMandatory`. On a `DelayedInsert = true` list page the user fills the row, leaves it, and gets an error naming a field that never looked different from the optional ones. Reviewer signal: code on the relevant commit or action path requires the user to supply a field, the corresponding page control is visible and editable, and its `ShowMandatory` property is missing or does not mirror the same condition. A `TestField` or `Error` elsewhere in `OnValidate` or `OnModify` is not sufficient evidence: the field may be populated by code, non-editable, or required only for another path. Setting `ShowMandatory = false` on a field that is unconditionally required on the current path is the same defect stated explicitly, and per the documentation it also overrides any marking `NotBlank` would otherwise contribute. See sample: [`showmandatory-on-code-required-page-fields.bad.al`](showmandatory-on-code-required-page-fields.bad.al).
## See also
diff --git a/microsoft/knowledge/ui/standalone-content-in-layout-table.md b/microsoft/knowledge/ui/standalone-content-in-layout-table.md
index 74a69b2..63f8d58 100644
--- a/microsoft/knowledge/ui/standalone-content-in-layout-table.md
+++ b/microsoft/knowledge/ui/standalone-content-in-layout-table.md
@@ -19,4 +19,4 @@ Layout tables have no `` column headers, so a captionless field that is mean
Reserve `ShowCaption = false` in a layout-table grid for non-editable, free-standing content cells. If a field's role is to label or annotate another field in the same grid, restructure the grid to meet the data-table conditions (see `grid-data-table-heuristic.md`) instead of hiding the caption.
-See sample: `standalone-content-in-layout-table.good.al`.
+See sample: [`standalone-content-in-layout-table.good.al`](standalone-content-in-layout-table.good.al).
diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md
index 5040099..720423b 100644
--- a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md
+++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md
@@ -22,4 +22,4 @@ When `StyleExpr` is Text, you must trace the variable's assignments โ typicall
Inspect the declared type of the symbol referenced by `StyleExpr` before drawing conclusions. If it is Boolean, evaluate the `Style` property. If it is Text, follow every assignment to the variable and check the full set of possible style values against `cosmetic-styles-need-no-textual-context.md` and `semantic-styles-need-independent-textual-meaning.md`.
-See sample: `style-expr-text-vs-boolean.good.al`.
+See sample: [`style-expr-text-vs-boolean.good.al`](style-expr-text-vs-boolean.good.al).
diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md
index b56aadb..c02bbd9 100644
--- a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md
+++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md
@@ -24,4 +24,4 @@ Both manifestations have the same root cause: tabular semantics were intended bu
A single field that keeps its visible caption is enough to demote an entire would-be data-table grid into a layout table โ and silently strip the labels off its sibling captionless fields. Either restructure to meet all three conditions, or restore captions on every editable field.
-See sample: `tabular-intent-requires-data-table-conditions.bad.al`.
+See sample: [`tabular-intent-requires-data-table-conditions.bad.al`](tabular-intent-requires-data-table-conditions.bad.al).
diff --git a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md
index 75c8a09..d796827 100644
--- a/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md
+++ b/microsoft/knowledge/ui/validate-request-page-input-in-onqueryclosepage.md
@@ -17,13 +17,13 @@ application-area: [all]
## Best Practice
-Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action โ validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits โ see `showmandatory-on-code-required-page-fields.md`. See sample: `validate-request-page-input-in-onqueryclosepage.good.al`.
+Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action โ validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits โ see `showmandatory-on-code-required-page-fields.md`. See sample: [`validate-request-page-input-in-onqueryclosepage.good.al`](validate-request-page-input-in-onqueryclosepage.good.al).
## Anti Pattern
Validating mandatory request-page input only in `OnPreReport`. The check is correct and the report is never run with bad input, but every interactive mistake costs the user the whole request page: the error arrives after the page is gone, and filters, dates, and options all have to be entered again. Reviewer signal: a `TestField`, `Error`, or blank/zero-value check in `OnPreReport` or `OnPreDataItem` against a variable that is bound to a request-page control, in a report whose request page declares no `OnQueryClosePage`.
-The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant โ the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: `validate-request-page-input-in-onqueryclosepage.bad.al`.
+The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant โ the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: [`validate-request-page-input-in-onqueryclosepage.bad.al`](validate-request-page-input-in-onqueryclosepage.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md
index 9ba7e8a..5efc751 100644
--- a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md
+++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md
@@ -17,10 +17,10 @@ Primary-key changes and field-type changes (for example widening `Integer` to `B
Treat primary-key and field-type changes as restricted to tables introduced in the same change. For changes on tables with existing data, design and ship the corresponding upgrade procedure (typically backed by `DataTransfer` and an upgrade tag) that guarantees the new layout is achievable for every row, and verify with concrete evidence that the existing values fit the new constraint (no PK collisions, no value-range overflow).
-See sample: `breaking-changes-only-on-tables-without-data.good.al`.
+See sample: [`breaking-changes-only-on-tables-without-data.good.al`](breaking-changes-only-on-tables-without-data.good.al).
## Anti Pattern
Changing the primary key on a base-app table, or widening / narrowing a field type on a table that has been shipping for releases, with no accompanying upgrade plan. The change compiles cleanly and may even deploy on an empty-ish tenant, then fails on customers who actually have data.
-See sample: `breaking-changes-only-on-tables-without-data.bad.al`.
+See sample: [`breaking-changes-only-on-tables-without-data.bad.al`](breaking-changes-only-on-tables-without-data.bad.al).
diff --git a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
index 30f6ca7..8770f93 100644
--- a/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
+++ b/microsoft/knowledge/upgrade/check-only-triggers-do-not-migrate-data.md
@@ -17,10 +17,10 @@ application-area: [all]
Have check triggers call query-only helpers that raise an error when an invariant fails. Put every `Insert`, `Modify`, `Delete`, `Rename`, `DataTransfer`, and other migration write behind helpers called from the matching `OnUpgrade...` trigger.
-See sample: `check-only-triggers-do-not-migrate-data.good.al`.
+See sample: [`check-only-triggers-do-not-migrate-data.good.al`](check-only-triggers-do-not-migrate-data.good.al).
## Anti Pattern
Repairing data in `OnCheckPreconditions...` or finishing migration in `OnValidateUpgrade...`. Those writes blur the phase contract and make a check alter the state it is supposed to assess.
-See sample: `check-only-triggers-do-not-migrate-data.bad.al`.
+See sample: [`check-only-triggers-do-not-migrate-data.bad.al`](check-only-triggers-do-not-migrate-data.bad.al).
diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
index 988dfa4..830dbdb 100644
--- a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
+++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
@@ -17,13 +17,13 @@ Tables that can contain more than 300,000 records, and any newly added field on
For a bulk update use a `DataTransfer` variable: call `SetTables(Database::"...", Database::"...")` (source and destination may be the same table), add filters with `AddSourceFilter`, set the target value with `AddConstantValue` (or copy a source field with `AddFieldValue`), and execute with `CopyFields()`. To express multiple distinct updates against the same table, `Clear` the `DataTransfer` between executions and configure the next one.
-See sample: `datatransfer-for-bulk-init.good.al`.
+See sample: [`datatransfer-for-bulk-init.good.al`](datatransfer-for-bulk-init.good.al).
## Anti Pattern
Iterating with `FindSet(true) ... repeat ... Modify() ... until Next() = 0` to set a single field across an entire large table. On 300k+ rows this is the canonical slow-upgrade footgun.
-See sample: `datatransfer-for-bulk-init.bad.al`.
+See sample: [`datatransfer-for-bulk-init.bad.al`](datatransfer-for-bulk-init.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
index 34a406b..33173da 100644
--- a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
+++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md
@@ -19,10 +19,10 @@ For *new fields and tables added in the same change* this is fine: nothing yet d
Use `DataTransfer` when set-based transfer is safe and row-level business logic is intentionally unnecessary โ initial population of a new field is the canonical case. When an existing field's validation must run, loop through records and call `Validate(Field, Value)`; if the table's modify trigger must also run, follow with `Modify(true)`. If performance requires `DataTransfer`, document exactly which field-validation and row-modification triggers or subscribers are intentionally bypassed and verify that derived data remains correct.
-See sample: `datatransfer-skips-triggers-and-subscribers.good.al`.
+See sample: [`datatransfer-skips-triggers-and-subscribers.good.al`](datatransfer-skips-triggers-and-subscribers.good.al).
## Anti Pattern
Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` or `OnModify` logic, without confirming that both validation and row-modification subscribers can be skipped. Replacing it with only `Modify(true)` is also incomplete when field validation is required; call `Validate` for that field first.
-See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`.
+See sample: [`datatransfer-skips-triggers-and-subscribers.bad.al`](datatransfer-skips-triggers-and-subscribers.bad.al).
diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md
index cf585eb..868b61e 100644
--- a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md
+++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md
@@ -17,10 +17,10 @@ When upgrade code encounters unexpected data โ a record it expected to find, a
When an upgrade procedure detects something missing, call `Session.LogMessage` with a stable event ID, classify the message verbosity (typically `Warning`), and `exit` the procedure so the rest of the upgrade can proceed. The platform telemetry then surfaces the situation to the partner without breaking the customer.
-See sample: `do-not-block-upgrade-on-data-errors.good.al`.
+See sample: [`do-not-block-upgrade-on-data-errors.good.al`](do-not-block-upgrade-on-data-errors.good.al).
## Anti Pattern
Calling `Record.Get(Key)` (or any other erroring API) and letting the error propagate out of the upgrade trigger. The first tenant with imperfect data fails to upgrade, and the failure surfaces as a hard upgrade error rather than as a telemetry signal.
-See sample: `do-not-block-upgrade-on-data-errors.bad.al`.
+See sample: [`do-not-block-upgrade-on-data-errors.bad.al`](do-not-block-upgrade-on-data-errors.bad.al).
diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md
index 4de929b..332efa6 100644
--- a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md
+++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md
@@ -17,13 +17,13 @@ An AL `enum` is a fixed list of ordinal-named values. Persisted rows reference e
When adding an enum value, place it after the last existing `value(N; ...)` entry, with an ordinal strictly greater than every existing one. Never renumber existing entries. To retire a value, do not delete it: mark it `ObsoleteState = Pending` (and later `Removed`) with `ObsoleteReason` and `ObsoleteTag` so the ordinal remains taken.
-See sample: `enum-values-additive-at-end.good.al`.
+See sample: [`enum-values-additive-at-end.good.al`](enum-values-additive-at-end.good.al).
## Anti Pattern
Inserting a value between existing entries ("just put `NewMiddleValue` between `First` and `Second`"), or removing a value from the enum without first going through `ObsoleteState = Pending` โ `Removed`. Every row whose persisted ordinal matched the removed or shifted value now reads as a different member.
-See sample: `enum-values-additive-at-end.bad.al`.
+See sample: [`enum-values-additive-at-end.bad.al`](enum-values-additive-at-end.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
index 6324836..5cbdec8 100644
--- a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
+++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md
@@ -17,13 +17,13 @@ On the first install of an extension on a tenant the platform records a zero dat
In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run first-install seed logic only when they match. On a non-zero data version, follow the reinstall path or exit.
-See sample: `first-install-dataversion-zero-check.good.al`.
+See sample: [`first-install-dataversion-zero-check.good.al`](first-install-dataversion-zero-check.good.al).
## Anti Pattern
Treating `OnInstallAppPerCompany` as if it always implies "fresh tenant". The trigger also fires when reinstalling over an existing data set; without the `0.0.0.0` guard, first-install seed code can run again and duplicate rows.
-See sample: `first-install-dataversion-zero-check.bad.al`.
+See sample: [`first-install-dataversion-zero-check.bad.al`](first-install-dataversion-zero-check.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/guard-database-reads.md b/microsoft/knowledge/upgrade/guard-database-reads.md
index c5bc206..09a99e3 100644
--- a/microsoft/knowledge/upgrade/guard-database-reads.md
+++ b/microsoft/knowledge/upgrade/guard-database-reads.md
@@ -17,10 +17,10 @@ Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgrad
Wrap every read in an `if`. `if Item.Get(No) then ...`, `if Customer.FindSet() then;`, `if not Vendor.FindLast() then exit;`. The empty-then form `if Customer.FindSet() then;` is the idiomatic way to attempt a read whose only purpose is to position a record, while swallowing the "not found" case.
-See sample: `guard-database-reads.good.al`.
+See sample: [`guard-database-reads.good.al`](guard-database-reads.good.al).
## Anti Pattern
Calling `Item.Get()`, `Customer.FindSet()`, or `Vendor.FindLast()` bare in upgrade code. The first tenant whose data does not match the upgrade's assumptions will fail to upgrade.
-See sample: `guard-database-reads.bad.al`.
+See sample: [`guard-database-reads.bad.al`](guard-database-reads.bad.al).
diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md
index 4733ef2..bfa0f03 100644
--- a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md
+++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md
@@ -23,10 +23,10 @@ Several legitimate cases do NOT need upgrade code:
When a new field on an existing table has an `InitValue` that matters, ship an upgrade procedure that walks the existing rows and sets the field to the same value โ typically via `DataTransfer.AddConstantValue` for performance โ guarded by an upgrade tag.
-See sample: `initvalue-does-not-update-existing-rows.good.al`.
+See sample: [`initvalue-does-not-update-existing-rows.good.al`](initvalue-does-not-update-existing-rows.good.al).
## Anti Pattern
Adding a field with `InitValue = true;` (or any non-default `InitValue`) and shipping no upgrade code. Existing rows silently carry the datatype default, leaving the table in two states: rows created before the upgrade with the wrong value, and rows created after with the right one.
-See sample: `initvalue-does-not-update-existing-rows.bad.al`.
+See sample: [`initvalue-does-not-update-existing-rows.bad.al`](initvalue-does-not-update-existing-rows.bad.al).
diff --git a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
index 12f432f..52c3989 100644
--- a/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
+++ b/microsoft/knowledge/upgrade/install-code-does-not-run-on-version-upgrade.md
@@ -17,10 +17,10 @@ An install codeunit runs when an extension is installed for the first time or an
Use `Subtype = Install` for first-install and reinstall initialization. Put version migration in a separate `Subtype = Upgrade` codeunit and enter it from `OnUpgradePerCompany` or `OnUpgradePerDatabase`.
-See sample: `install-code-does-not-run-on-version-upgrade.good.al`.
+See sample: [`install-code-does-not-run-on-version-upgrade.good.al`](install-code-does-not-run-on-version-upgrade.good.al).
## Anti Pattern
Putting a schema or data migration only in an install trigger and expecting it to run when a higher app version is upgraded. The migration is never invoked on that path.
-See sample: `install-code-does-not-run-on-version-upgrade.bad.al`.
+See sample: [`install-code-does-not-run-on-version-upgrade.bad.al`](install-code-does-not-run-on-version-upgrade.bad.al).
diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
index b9e5e13..3095655 100644
--- a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
+++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md
@@ -17,10 +17,10 @@ Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. A full
Filter directly to invalid rows and use `IsEmpty` or another bounded existence check where possible. If a broad validation is unavoidable, document the invariant that requires it and keep all data changes in `OnUpgrade...`.
-See sample: `minimize-onvalidate-upgrade-triggers.good.al`.
+See sample: [`minimize-onvalidate-upgrade-triggers.good.al`](minimize-onvalidate-upgrade-triggers.good.al).
## Anti Pattern
Reading every record in `OnValidateUpgradePerCompany` when a filtered existence check can prove the same invariant. The scan repeats on every upgrade.
-See sample: `minimize-onvalidate-upgrade-triggers.bad.al`.
+See sample: [`minimize-onvalidate-upgrade-triggers.bad.al`](minimize-onvalidate-upgrade-triggers.bad.al).
diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
index eb644b6..f04fb40 100644
--- a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
+++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
@@ -19,10 +19,10 @@ The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedu
Defer external calls to runtime code. If a piece of upgrade work conceptually needs data from an external service, set a flag or write a queue row during upgrade and have the runtime code make the call later (for example on first user sign-in or via job queue), where retries and degraded modes are tractable.
-See sample: `no-external-calls-in-upgrade.good.al`.
+See sample: [`no-external-calls-in-upgrade.good.al`](no-external-calls-in-upgrade.good.al).
## Anti Pattern
Calling `HttpClient.Get`, `HttpClient.Post`, or DotNet interop methods from `OnUpgradePerCompany`, `OnUpgradePerDatabase`, or any procedure they invoke.
-See sample: `no-external-calls-in-upgrade.bad.al`.
+See sample: [`no-external-calls-in-upgrade.bad.al`](no-external-calls-in-upgrade.bad.al).
diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md
index cb008ac..64a54f2 100644
--- a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md
+++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md
@@ -17,10 +17,10 @@ application-area: [all]
Stage the deprecation across releases. Step 1: mark `Pending` with reason and tag; consumers are warned but data and code keep working. Step 2: in a later release, transition to `Removed` and (if persisted data references the element) ship an upgrade procedure that migrates that data โ gated by an upgrade tag. The standard mechanic for retiring the actual implementation body is to remove the `#if not CLEAN` block in the same release that flips the state to `Removed`.
-See sample: `obsolete-pending-to-removed-staging.good.al`.
+See sample: [`obsolete-pending-to-removed-staging.good.al`](obsolete-pending-to-removed-staging.good.al).
## Anti Pattern
Jumping straight to `ObsoleteState = Removed` without a prior `Pending` release. Consumers have no deprecation window to migrate and any data still referencing the element is stranded. Equally wrong: leaving an element `Pending` indefinitely and never staging its removal โ the deprecation never completes.
-See sample: `obsolete-pending-to-removed-staging.bad.al`.
+See sample: [`obsolete-pending-to-removed-staging.bad.al`](obsolete-pending-to-removed-staging.bad.al).
diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
index d6ec37a..b468437 100644
--- a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
+++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md
@@ -22,13 +22,13 @@ In both forms, the reason should name the replacement and the tag should identif
For an object or field, set all three properties together. For a method, variable, or event, provide both `[Obsolete]` arguments. Keep the original tag stable through the lifecycle rather than changing it to a planned removal version.
-See sample: `obsoletion-requires-reason-and-tag.good.al`.
+See sample: [`obsoletion-requires-reason-and-tag.good.al`](obsoletion-requires-reason-and-tag.good.al).
## Anti Pattern
Setting only `ObsoleteState = Pending`/`Removed` on an object or field, or using `[Obsolete('', '')]` on a method, variable, or event. Both forms produce deprecation metadata without useful replacement guidance or traceability.
-See sample: `obsoletion-requires-reason-and-tag.bad.al`.
+See sample: [`obsoletion-requires-reason-and-tag.bad.al`](obsoletion-requires-reason-and-tag.bad.al).
## See also
diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
index e5e1983..0bba7c2 100644
--- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
+++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md
@@ -19,10 +19,10 @@ Registration is not install-time seeding. When an extension is installed into an
In the upgrade codeunit, guard work with `HasUpgradeTag` and call `SetUpgradeTag` only after successful completion. Seed the same tag explicitly from `OnInstallAppPerCompany` when first-install logic should not run as a later upgrade. Also add historical per-company tags to `OnGetPerCompanyUpgradeTags` so `SetAllUpgradeTags` marks them complete for newly created companies. Keep the tag definition shared so all paths use the exact same value.
-See sample: `register-upgrade-tags-with-subscribers.good.al`.
+See sample: [`register-upgrade-tags-with-subscribers.good.al`](register-upgrade-tags-with-subscribers.good.al).
## Anti Pattern
Assuming an `OnGetPerCompanyUpgradeTags` subscriber sets tags during extension installation, or omitting the subscriber and allowing old upgrade steps to run when `SetAllUpgradeTags` initializes a new company. The subscriber supplies a list; only `SetAllUpgradeTags` or an explicit `SetUpgradeTag` call persists it.
-See sample: `register-upgrade-tags-with-subscribers.bad.al`.
+See sample: [`register-upgrade-tags-with-subscribers.bad.al`](register-upgrade-tags-with-subscribers.bad.al).
diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md
index 0b441e6..c4558b7 100644
--- a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md
+++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md
@@ -19,10 +19,10 @@ This is the opposite of a load-bearing concern: code that MUST run during the up
In a runtime procedure that performs non-essential side effects, guard the side-effect block with `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` and include a brief comment explaining what is being skipped and why.
-See sample: `skip-nonessential-work-via-execution-context.good.al`.
+See sample: [`skip-nonessential-work-via-execution-context.good.al`](skip-nonessential-work-via-execution-context.good.al).
## Anti Pattern
Using `GetExecutionContext()` to *enable* upgrade behaviour from outside an upgrade codeunit. Upgrade behaviour belongs in a codeunit with `Subtype = Upgrade`; runtime code should only use the check to *suppress* optional work.
-See sample: `skip-nonessential-work-via-execution-context.bad.al`.
+See sample: [`skip-nonessential-work-via-execution-context.bad.al`](skip-nonessential-work-via-execution-context.bad.al).
diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md
index dcc21e3..078e109 100644
--- a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md
+++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md
@@ -19,10 +19,10 @@ Empty `OnUpgradePerCompany` / `OnUpgradePerDatabase` triggers are acceptable โ
Each upgrade trigger contains an ordered list of procedure calls, one per feature: `UpgradeFeatureA();` `UpgradeFeatureB();`. Each procedure handles its own upgrade tag, its own data work, and can be added or removed independently.
-See sample: `triggers-call-helpers-not-implementations.good.al`.
+See sample: [`triggers-call-helpers-not-implementations.good.al`](triggers-call-helpers-not-implementations.good.al).
## Anti Pattern
Implementing record loops, `ModifyAll`, or other data work directly in the trigger body. The trigger then mixes orchestration with implementation, and adding a second feature requires editing the trigger rather than appending one line.
-See sample: `triggers-call-helpers-not-implementations.bad.al`.
+See sample: [`triggers-call-helpers-not-implementations.bad.al`](triggers-call-helpers-not-implementations.bad.al).
diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
index a9fee7c..8bf558d 100644
--- a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
+++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
@@ -17,10 +17,10 @@ A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upg
Place every piece of upgrade logic in a codeunit declared with `Subtype = Upgrade;` and expose entry points via the two triggers `OnUpgradePerCompany` and `OnUpgradePerDatabase`. Helper procedures may live in normal codeunits, but they inherit the upgrade-context rules (guarded reads, no external calls, upgrade tags, etc.) when called from an upgrade trigger.
-See sample: `upgrade-codeunit-subtype.good.al`.
+See sample: [`upgrade-codeunit-subtype.good.al`](upgrade-codeunit-subtype.good.al).
## Anti Pattern
Putting upgrade-style logic in a regular codeunit that the platform never invokes during upgrade โ for example a normal codeunit with a manually invented "RunUpgrade" procedure that nothing wires to the upgrade pipeline. The migration code will simply not run.
-See sample: `upgrade-codeunit-subtype.bad.al`.
+See sample: [`upgrade-codeunit-subtype.bad.al`](upgrade-codeunit-subtype.bad.al).
diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md
index 62347d1..bb7649f 100644
--- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md
+++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md
@@ -17,13 +17,13 @@ Each piece of upgrade logic must run exactly once per company (or database) acro
Every upgrade procedure starts with a `HasUpgradeTag` guard and ends with `SetUpgradeTag` once the work is committed. Each feature gets its own tag string so features can be re-run independently if needed.
-See sample: `use-upgrade-tags-not-version-checks.good.al`.
+See sample: [`use-upgrade-tags-not-version-checks.good.al`](use-upgrade-tags-not-version-checks.good.al).
## Anti Pattern
Branching on `MyApp.DataVersion().Major > N`, or chains of `< N` / `< M` to decide which upgrade step to run. Such code becomes unmaintainable after a few releases and silently does the wrong thing on tenants that skip versions.
-See sample: `use-upgrade-tags-not-version-checks.bad.al`.
+See sample: [`use-upgrade-tags-not-version-checks.bad.al`](use-upgrade-tags-not-version-checks.bad.al).
## See also
diff --git a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md
index 7988326..66fe36e 100644
--- a/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md
+++ b/microsoft/knowledge/web-services/api-enum-values-are-a-contract-by-name-not-ordinal.md
@@ -21,7 +21,7 @@ LLMs treat one carrier as universal. Some assume the caption is serialised and r
Establish which schema versions the field is served under before changing anything about its enum. Under schema 2.0 (Microsoft's API v2.0, an explicit `$schemaversion=2.0` in the consumer contract, or another reliable context signal) the member name is the contract: keep names stable, put wording changes in `Caption`, add a value by appending a new name with an ordinal above every existing one, and retire a value through `ObsoleteState` rather than by deleting it. For a custom API that clients may still call as schema 1.0, any install of BC 17 to 23 or a caller that pins 1.0, the caption is a contract as well: change neither name nor caption in place, or publish the change as a new `APIVersion` on a new page object. A rename is out in every case: AppSourceCop AS0082 rejects it against a baseline, and dependent extensions bind to the name.
-See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.good.al`.
+See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.good.al`](api-enum-values-are-a-contract-by-name-not-ordinal.good.al).
## Anti Pattern
@@ -31,7 +31,7 @@ Detection signal: a diff hunk that changes the name in a `value(...)` line while
The mirror image is a review defect: suppressing a caption-change finding because "the API serialises names". That holds only under schema 2.0. Do not flag a `Caption` change when the reviewer can establish schema 2.0 for every consumer; on a custom API where clients may select schema 1.0, report a caption change on an exposed value as a consumer-visible change and ask for versioning. A value appended at the end changes no contract under either schema and is never a finding.
-See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`.
+See sample: [`api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`](api-enum-values-are-a-contract-by-name-not-ordinal.bad.al).
## See also
diff --git a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md
index 2358a6b..8f6a73c 100644
--- a/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md
+++ b/microsoft/knowledge/web-services/disable-write-operations-on-read-only-api-pages.md
@@ -17,10 +17,10 @@ An API meant purely for reading โ a reporting or lookup endpoint โ is not re
For a read-only / reporting API page set all three CRUD guards off โ `InsertAllowed = false`, `ModifyAllowed = false`, `DeleteAllowed = false` โ and mark the page `Editable = false`. The endpoint then serves GET requests and rejects any insert, modify, or delete, matching the read-only contract regardless of the caller. Make the read-only stance explicit rather than depending on the writable default.
-See sample: `disable-write-operations-on-read-only-api-pages.good.al`.
+See sample: [`disable-write-operations-on-read-only-api-pages.good.al`](disable-write-operations-on-read-only-api-pages.good.al).
## Anti Pattern
An API intended for read-only consumption that omits the CRUD guards, leaving `InsertAllowed`, `ModifyAllowed`, and `DeleteAllowed` at their writable defaults. The endpoint silently accepts POST, PATCH, and DELETE, so a client can mutate or remove data the API was never meant to expose for writing. The detection signal: a read-only/reporting `PageType = API` page that does not set the three `*Allowed = false` properties.
-See sample: `disable-write-operations-on-read-only-api-pages.bad.al`.
+See sample: [`disable-write-operations-on-read-only-api-pages.bad.al`](disable-write-operations-on-read-only-api-pages.bad.al).
diff --git a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md
index 739b5aa..4337b41 100644
--- a/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md
+++ b/microsoft/knowledge/web-services/expose-only-committed-data-from-api-reads.md
@@ -17,10 +17,10 @@ This is about the data-consistency contract of an API endpoint: what a consumer
For an API page that must expose only committed data, set the endpoint's read isolation once as the page opens: in the `OnOpenPage` trigger write `Rec.ReadIsolation := IsolationLevel::ReadCommitted;`. Every read the endpoint then serves ignores uncommitted writes from concurrent transactions, so a consumer never receives a row that another transaction might still roll back.
-See sample: `expose-only-committed-data-from-api-reads.good.al`.
+See sample: [`expose-only-committed-data-from-api-reads.good.al`](expose-only-committed-data-from-api-reads.good.al).
## Anti Pattern
An API intended to return committed-only data that sets no isolation level, leaving reads at the default that can observe in-flight, uncommitted writes. A consumer can fetch a row created by a concurrent transaction that is later rolled back โ a dirty read that surfaces data which never durably existed. The detection signal: a committed-only read API with no `Rec.ReadIsolation := IsolationLevel::ReadCommitted` in `OnOpenPage`.
-See sample: `expose-only-committed-data-from-api-reads.bad.al`.
+See sample: [`expose-only-committed-data-from-api-reads.bad.al`](expose-only-committed-data-from-api-reads.bad.al).
diff --git a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md
index 7f0ed87..18908a7 100644
--- a/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md
+++ b/microsoft/knowledge/web-services/expose-operations-as-bound-actions.md
@@ -17,10 +17,10 @@ An API consumer that needs to *do* something to a record โ post it, ship it, r
Declare the operation as `[ServiceEnabled] procedure Post(var ActionContext: WebServiceActionContext)` on the API page. Inside, perform the operation against `Rec`, then call a `SetActionResponse` helper that writes the result โ the bound record and its id โ back into the `WebServiceActionContext` so the caller receives a well-formed response. The operation is now an explicit, named endpoint action separate from ordinary field writes.
-See sample: `expose-operations-as-bound-actions.good.al`.
+See sample: [`expose-operations-as-bound-actions.good.al`](expose-operations-as-bound-actions.good.al).
## Anti Pattern
Exposing a writable Boolean (for example `posted`) whose `OnValidate` performs the posting. A client that PATCHes the field to `true` โ an action indistinguishable from any other data edit โ silently triggers a side-effecting business operation. The detection signal: an API page field whose `OnValidate` posts, ships, or releases, instead of a `[ServiceEnabled]` bound action.
-See sample: `expose-operations-as-bound-actions.bad.al`.
+See sample: [`expose-operations-as-bound-actions.bad.al`](expose-operations-as-bound-actions.bad.al).
diff --git a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md
index 93d2dcd..f34e9a4 100644
--- a/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md
+++ b/microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md
@@ -17,10 +17,10 @@ Every BC table carries a `SystemId` โ an immutable GUID assigned at insert and
Set `ODataKeyFields = SystemId` so OData routes records by the stable GUID, and expose it as `field(id; Rec.SystemId)` marked `Editable = false`. Clients then address a record at `.../customers()`, an identity that survives any rename of the business key. Keep the business key (for example `No.`) as an ordinary exposed field, not as the OData key.
-See sample: `expose-systemid-as-the-api-key.good.al`.
+See sample: [`expose-systemid-as-the-api-key.good.al`](expose-systemid-as-the-api-key.good.al).
## Anti Pattern
Setting `ODataKeyFields = "No."` so the endpoint addresses records by a renamable business field. As soon as a user changes that `No.`, every external reference built on the old value points at nothing, silently breaking integrations. The detection signal: `ODataKeyFields` set to a business field rather than `SystemId`, or an API page that exposes no `id` field bound to `Rec.SystemId`.
-See sample: `expose-systemid-as-the-api-key.bad.al`.
+See sample: [`expose-systemid-as-the-api-key.bad.al`](expose-systemid-as-the-api-key.bad.al).
diff --git a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
index 4cf81d6..2f92c0c 100644
--- a/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
+++ b/microsoft/knowledge/web-services/link-api-parts-on-systemid-and-set-multiplicity.md
@@ -17,13 +17,13 @@ application-area: [all]
Define the child foreign key as `Guid` with a `TableRelation` to the parent table's `SystemId`, then use `SubPageLink = "" = Field(SystemId)` on the parent API page. A child collection may omit `Multiplicity` and rely on the default 1:N relationship, or declare `Multiplicity = Many` explicitly. Set `Multiplicity = ZeroOrOne` when the intended navigation metadata is a singleton.
-See sample: `link-api-parts-on-systemid-and-set-multiplicity.good.al`.
+See sample: [`link-api-parts-on-systemid-and-set-multiplicity.good.al`](link-api-parts-on-systemid-and-set-multiplicity.good.al).
## Anti Pattern
On a parent API with `ODataKeyFields = SystemId`, linking a child business field such as `"Order No."` to the parent's `"No."` creates a second identity scheme for navigation instead of using the contract's stable GUID. A separate defect is an explicit `Multiplicity` that conflicts with the intended shape, such as `ZeroOrOne` on an order-lines collection or `Many` on a singleton. Do not treat omission alone as a defect: it is valid for a collection because the default is 1:N, while an intended singleton must explicitly use `Multiplicity = ZeroOrOne`.
-See sample: `link-api-parts-on-systemid-and-set-multiplicity.bad.al`.
+See sample: [`link-api-parts-on-systemid-and-set-multiplicity.bad.al`](link-api-parts-on-systemid-and-set-multiplicity.bad.al).
## Source
diff --git a/microsoft/knowledge/web-services/set-required-api-page-properties.md b/microsoft/knowledge/web-services/set-required-api-page-properties.md
index 496db1a..24edc5d 100644
--- a/microsoft/knowledge/web-services/set-required-api-page-properties.md
+++ b/microsoft/knowledge/web-services/set-required-api-page-properties.md
@@ -17,10 +17,10 @@ An API page needs `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, and
Declare the five routing/entity properties required by the API page and set `APIVersion` explicitly for a stable published contract, for example `'v1.0'`. Expose the record's fields inside a repeater under `area(content)`. Review missing routing metadata as a malformed API definition, but review a missing `APIVersion` as unintended publication under `beta`, not as an unpublished endpoint.
-See sample: `set-required-api-page-properties.good.al`.
+See sample: [`set-required-api-page-properties.good.al`](set-required-api-page-properties.good.al).
## Anti Pattern
Leaving out `APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`, or `SourceTable` leaves the API definition incomplete. A subtler contract defect is declaring all of those but omitting `APIVersion`: the page is exposed as `beta`, which is valid runtime behavior but not the explicit stable route a production client expects.
-See sample: `set-required-api-page-properties.bad.al`.
+See sample: [`set-required-api-page-properties.bad.al`](set-required-api-page-properties.bad.al).
diff --git a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
index bfd2c9f..1e8417b 100644
--- a/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
+++ b/microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md
@@ -17,10 +17,10 @@ Once an API version is published, external clients depend on its exact shape โ
Keep the existing page object and its `APIVersion = 'v1.0'` contract unchanged. Copy the page to a new object ID, set that object's `APIVersion = 'v2.0'`, and make the v2-only shape changes there. A multi-value `APIVersion` list is appropriate only when the exact same page shape is supported under each listed version.
-See sample: `version-apis-by-adding-not-mutating-published-versions.good.al`.
+See sample: [`version-apis-by-adding-not-mutating-published-versions.good.al`](version-apis-by-adding-not-mutating-published-versions.good.al).
## Anti Pattern
Editing the published `v1.0` page in place breaks its clients. So does adding `v2.0` to that same page and assuming subsequent field changes apply only to v2: both routes use one object shape. The detection signal is a breaking shape change without a separate API page object retaining the old version.
-See sample: `version-apis-by-adding-not-mutating-published-versions.bad.al`.
+See sample: [`version-apis-by-adding-not-mutating-published-versions.bad.al`](version-apis-by-adding-not-mutating-published-versions.bad.al).
diff --git a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
index b35a05c..2aad620 100644
--- a/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
+++ b/microsoft/knowledge/web-services/webhook-eligibility-and-validationtoken-renewal.md
@@ -17,13 +17,13 @@ Business Central can subscribe only to eligible API pages, not every endpoint th
Before creating a subscription, confirm the resource appears in `webhookSupportedResources` and that a custom endpoint is an API page with a single stable key over an eligible persistent table. Use one validation path that echoes `validationToken` for both create (`POST`) and renew (`PATCH`) handshakes. Track `expirationDateTime` and renew before expiry: online subscriptions expire after three days, while on-premises lifetime defaults to three days and can be changed with `ApiSubscriptionExpiration`.
-See samples: `webhook-eligibility-and-validationtoken-renewal.good.al` and `webhook-eligibility-and-validationtoken-renewal.good.js`.
+See samples: [`webhook-eligibility-and-validationtoken-renewal.good.al`](webhook-eligibility-and-validationtoken-renewal.good.al) and [`webhook-eligibility-and-validationtoken-renewal.good.js`](webhook-eligibility-and-validationtoken-renewal.good.js).
## Anti Pattern
Attempting to subscribe to an API query, temporary/composite/system-table/Job Queue Entry API page, or assuming a successful create handshake makes renewal automatic. Composite includes an explicit multi-field `ODataKeyFields` and a missing `ODataKeyFields` when the source table's primary key has multiple fields. A renewal issues the same validation challenge; a notification handler that ignores the query-string token cannot create or renew the subscription.
-See samples: `webhook-eligibility-and-validationtoken-renewal.bad.al` and `webhook-eligibility-and-validationtoken-renewal.bad.js`.
+See samples: [`webhook-eligibility-and-validationtoken-renewal.bad.al`](webhook-eligibility-and-validationtoken-renewal.bad.al) and [`webhook-eligibility-and-validationtoken-renewal.bad.js`](webhook-eligibility-and-validationtoken-renewal.bad.js).
## Source
diff --git a/microsoft/skills/review/al-appsource-review.md b/microsoft/skills/review/al-appsource-review.md
index 01b1404..2769aa0 100644
--- a/microsoft/skills/review/al-appsource-review.md
+++ b/microsoft/skills/review/al-appsource-review.md
@@ -4,7 +4,7 @@ id: al-appsource-review
version: 1
title: AL AppSource review
description: Performs an AL AppSource review against source and app metadata guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source and app metadata changes against the `appsource` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). AppSource findings are narrow by design โ they apply when the diff touches AppSourceCop configuration, AL object or extension-member names, AppSource-facing `app.json` metadata, or AL constructs covered by an AppSource submission requirement. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff` (the standard PR-review entry point), `file-path` (single-file review), or `folder-path`. AppSource findings are narrow by design โ they apply when the diff touches AppSourceCop configuration, AL object or extension-member names, AppSource-facing `app.json` metadata, or AL constructs covered by an AppSource submission requirement. The skill returns `not-applicable` when none of those apply.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `appsource` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/appsource/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain appsource`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -45,8 +45,6 @@ A file enters the candidate worklist when its `keywords` intersect the extracted
The following targeted checks cover every current `appsource` article across the Microsoft and community layers. Treat each as a candidate-selection cue: when the signal appears in changed code, add the named article to the worklist and evaluate it in Action.
-- Select exactly one naming-collision owner. When no namespace declaration is present, a new/renamed object lacks the reserved prefix/suffix, or an extension object adds an unaffixed member to a base object โ `object-affixes-prevent-collisions`.
-- For BC23 or later, use `two-level-namespace-replaces-object-affix-not-extension-member-affix` instead when the changed source actually declares or changes a namespace and relies on it as the owned-object affix alternative, but has fewer than two levels or incorrectly applies that exception to members on another publisher's object. Never worklist this article for an unaffixed source file with no namespace declaration.
- The app has no assignable permission set covering its setup and usage paths, omits visible object/tabledata grants, or requires `SUPER` for normal operation โ `permission-sets-cover-setup-and-usage-without-super`. Require repository-level app context; one isolated permission-set object cannot prove complete coverage.
- An `EventSubscriber` attribute targets `OnBeforeCompanyOpen` or `OnAfterCompanyOpen` โ `do-not-subscribe-to-company-open-events`.
- Install, upgrade, or setup code provisions an app-owned profile through `Record Profile` and `Insert` instead of declaring a `profile` object โ `define-profiles-as-al-objects`.
@@ -73,13 +71,13 @@ For each worklist entry, evaluate the diff against the file's `## Best Practice`
Set `confidence` to:
-- `high` when the detection is based on an unambiguous pattern match (affix configuration/name or URL path depth).
+- `high` when the detection is based on an unambiguous pattern match such as URL path depth.
- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`.
- `low` when the finding is an advisory derived only from applicability.
After evaluating each worklist entry, also consider whether the diff exhibits an AppSource defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a concrete, material AppSource defect a knowledgeable BC reviewer would agree is wrong โ steelman it first and drop anything stylistic, speculative, dependent on code outside the diff, or merely a valid alternative; when in doubt, omit. The scope is strictly AppSource; defects outside this domain belong to other leaves and MUST NOT be emitted here. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add the configured affix to one object or extension member, or replace a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example, replacing a deep help URL with a known two-level canonical URL). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
@@ -87,7 +85,7 @@ Outcome selection:
- `completed` โ the skill evaluated every worklist item.
- `no-knowledge` โ no applicable AppSource knowledge survived filtering.
-- `not-applicable` โ the diff touches no AppSource source, analyzer configuration, or app-metadata surface.
+- `not-applicable` โ the diff touches no AppSource permission or app-metadata surface.
- `partial` โ a budget was hit before the worklist was exhausted.
- `failed` โ an unrecoverable error occurred.
@@ -105,19 +103,19 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
},
"findings": [
{
- "id": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md",
+ "id": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md",
"severity": "major",
- "message": "The tableextension adds an unaffixed Loyalty Points field to Customer, so it violates the configured AppSource affix and can collide with another extension.",
+ "message": "The app help URL is deeper than two path levels, so Copilot truncates it and may ground answers on unrelated sibling documentation.",
"location": {
- "file": "src/CustomerExt.TableExt.al",
- "line": 8
+ "file": "app.json",
+ "line": 12
},
"references": [
- { "path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" }
+ { "path": "microsoft/knowledge/appsource/keep-copilot-help-url-to-two-path-levels.md" }
],
"confidence": "high",
"domain": "AppSource",
- "suggested-code": "field(50100; \"Loyalty Points ABC\"; Integer)"
+ "suggested-code": "\"help\": \"https://contoso.com/docs/myapp\""
}
],
"suppressed": []
diff --git a/microsoft/skills/review/al-breaking-changes-review.md b/microsoft/skills/review/al-breaking-changes-review.md
index 82aeda0..7f30713 100644
--- a/microsoft/skills/review/al-breaking-changes-review.md
+++ b/microsoft/skills/review/al-breaking-changes-review.md
@@ -4,7 +4,7 @@ id: al-breaking-changes-review
version: 1
title: AL breaking changes review
description: Reviews AL source changes against breaking-changes guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `breaking-changes` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `breaking-changes` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/breaking-changes/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain breaking-changes`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types โ especially codeunits, tables, and table extensions that expose procedures, fields, or events to other apps, and any member whose access is being widened.
- The changed procedures, fields, and triggers, weighted toward non-`local` procedures, published table fields, event publishers, and any member whose signature, access modifier, or obsolete state is being altered.
-- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`, `namespace`, `using`, `AS0007`).
+- Tokens extracted from the diff that relate to API stability and deprecation (`signature`, `parameter`, `return`, `var`, `Obsolete`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `Pending`, `Removed`, `CLEAN`, `SecretText`, `token`, `internal`, `local`, `public`, `protected`, `Scope`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
@@ -50,8 +50,7 @@ The following targeted checks cover every current `breaking-changes` article:
- A published procedure changes parameter count/order/type/name, `var`, return type, or array shape instead of preserving the old signature and adding an overload โ `do-not-change-published-procedure-signatures`.
- A public procedure/event/interface exposes a credential or other sensitive value through `Text` or an externally callable contract โ `do-not-expose-sensitive-data-through-public-api`.
- Code already marked obsolete is expanded with new behavior instead of routing new callers to its replacement โ `do-not-modify-code-already-marked-obsolete`.
-- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data โ `obsolete-table-fields-instead-of-deleting-them`. This owns AS0005 field-name changes; do not substitute the namespace article.
-- A published object's namespace changes between the base and changed source while its identity otherwise remains โ `namespace-is-part-of-published-object-identity`. Do not apply it to a new, unshipped object or to an ordinary object-name change with no namespace change.
+- A shipped table field is deleted, renamed, renumbered, or replaced without retaining the original field as `ObsoleteState = Pending` and migrating its data โ `obsolete-table-fields-instead-of-deleting-them`.
For `obsolete-table-fields-instead-of-deleting-them`, compare the baseline ID and name before emitting. When the original field remains under the same ID and name with `ObsoleteState = Pending`, and the replacement uses a new ID, the change follows the rule and must not be flagged.
@@ -134,7 +133,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case โ BCQuality's state until breaking-changes knowledge files land โ produces:
+When no applicable breaking-changes knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-code-review.md b/microsoft/skills/review/al-code-review.md
index 9b3f934..9239220 100644
--- a/microsoft/skills/review/al-code-review.md
+++ b/microsoft/skills/review/al-code-review.md
@@ -4,7 +4,7 @@ id: al-code-review
version: 1
title: AL code review
description: Reviews AL source changes by composing the AL review leaf skills, one per knowledge domain.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -35,7 +35,7 @@ Reviews AL source changes by composing the leaf AL review skills. This is the ca
`al-code-review` does not evaluate knowledge files directly. It invokes each of its sub-skills against the same task input, collects their findings-reports, and then performs its own **self-review pass** over the diff using the agent's built-in BC and AL knowledge. BCQuality knowledge is an additive layer: anything the sub-skills found is cited from BCQuality, and anything the agent finds on its own is validated against BCQuality (cited if matched, suppressed if contradicted, surfaced as an **agent finding** otherwise). The result is a single rolled-up findings-report that mixes knowledge-backed and agent findings, each clearly tagged via `from-sub-skill`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract, extended with `sub-results` and โ when applicable โ `skipped-sub-skills`.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract, extended with `sub-results` and โ when applicable โ `skipped-sub-skills`.
## Source
@@ -63,22 +63,25 @@ The worklist is the list of sub-skills judged relevant by the previous step. Eve
### Execution discipline (mandatory)
-The Action step is a sequence of **discrete iterations**, not one combined generation. The contract requires the super-skill to invoke each sub-skill in turn and then perform a self-review pass. Concretely this means:
+The Action step consists of **discrete leaf invocations**, not one combined generation. Invocation scheduling belongs to the orchestrator: independent leaves may run serially or concurrently, but their evaluation contexts and findings-reports remain isolated. Concretely this means:
-- **Isolate leaf invocations when the host supports it.** For fast/small models, each sub-skill SHOULD run in a fresh model call or child context containing only the task input, READ/DO contracts, the leaf instructions, a domain-filtered slice of the current knowledge index, and articles that leaf worklists. Preserve each index row's exact `path`; the leaf must copy references from that slice. The coordinator then collects the resulting JSON. This is the preferred fast-model profile: it bounds context, prevents later leaves from being skipped as attention is exhausted, and removes any reason to synthesize article paths.
-- Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source โ Relevance โ Worklist โ Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report before moving on.
+- **Isolate leaf invocations when the host supports it.** Each sub-skill SHOULD run in a fresh model call or child context containing only its assigned source paths, READ/DO contracts, the leaf instructions, the complete bounded domain catalog per READ, and articles that leaf worklists. Preserve each catalog row's exact `path`; the leaf must copy references from that catalog.
+- **Keep run artifacts private.** Before dispatch, allocate a new GUID-named directory under the current session's artifact directory and a distinct scratch/report child directory for every leaf. Pass a leaf only its own assigned source paths and child directory, never the run root or sibling paths. A leaf MUST NOT discover, enumerate, read, modify, or delete sibling artifacts. Do not reuse a prior run directory, and do not clean up any run artifact until every leaf has finished and consolidation is complete.
+- **Keep raw Task transport distinct from the accepted copy.** Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in the leaf's private artifacts or host log. Then apply DO's bounded pre-gate range normalization, when eligible, and its full consumer acceptance gate. The report accepted for rollup is the exact return when no normalization occurred, or the normalized candidate copy when DO permits it; worker-side persistence of another report file is optional and redundant.
+- **Treat automatic output spills as host-owned.** If the host reports that a Task return was automatically spilled, the coordinator MAY read that file read-only only at the exact path returned by the tool. Never modify, delete, enumerate around, or reuse an automatic spill path. Never bypass a content-exclusion or access denial.
+- Treat each sub-skill in the worklist as its own pass: read the sub-skill's instructions, apply its Source โ Relevance โ Worklist โ Action steps to the orchestrator-supplied inputs, and produce that sub-skill's complete findings-report independently.
- Do not collapse multiple sub-skills into one shared reasoning step. Each sub-skill has a distinct knowledge subset and a distinct evaluation procedure; sharing one rolled-up scan dilutes per-skill attention and causes leaves to silently underreport (this has been observed in production: leaf skills returned empty `findings[]` while their standalone runs against the same diff produced multiple matches).
- The agent self-review pass is its own final iteration. Begin it only after every sub-skill in the worklist has completed and its sub-result is recorded.
-- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this โ `sub-results` carries one entry per sub-skill, each a complete findings-report.
+- Sub-skills are independent: re-walking the diff once per sub-skill is correct and expected. The output schema accommodates this โ `sub-results` carries one entry per sub-skill, each a complete findings-report, in the frontmatter `sub-skills` order regardless of completion order.
- When isolated calls are unavailable and the current model cannot finish every leaf within its budget, return `partial` with completed `sub-results` and name the first unevaluated sub-skill in `outcome-reason`. Never silently mark the remaining leaves clean.
### Roll up sub-skill findings
-For each sub-skill in the worklist, executed one at a time per the discipline above:
+For each sub-skill in the worklist:
1. Invoke the sub-skill with the orchestrator's inputs, passing only the subset each sub-skill declares in its `inputs`.
-2. Capture the sub-skill's complete findings-report verbatim and append it to `sub-results`.
-3. If the sub-skill's `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`.
+2. Capture the exact Task return as the immutable raw audit payload and primary transport. Preserve it unchanged in the leaf's private artifacts or host log before deriving a candidate. Apply only DO's bounded pre-gate normalization: when the complete raw report has no other defect, a finding has positive-integer `line`, `start-line`, and `end-line`, `start-line <= line <= end-line`, `start-line != line`, and no `suggested-code` field, copy the complete report and remove only that finding's optional `location.range`. Record the normalization separately in private run telemetry or artifacts, never in the findings-report. Validate the entire candidate through DO's existing strict acceptance gate. Accept the exact return when unchanged or the normalized candidate when it passes; otherwise record a separate failed validation result with no findings for rollup. Do not reconstruct JSON, infer fields, alter paths or references, clamp lines, normalize reversed or out-of-bounds ranges, remove a range associated with `suggested-code`, or salvage individual findings.
+3. Append the accepted findings-report, or the separate failed validation result, to `sub-results`. If its `outcome` is `failed`, stop here for this sub-skill: its findings are not reliable per the DO contract and MUST NOT be copied into the super-skill's top-level `findings[]` or counted in `summary.counts`.
4. Otherwise, compare each entry from the sub-skill's `findings[]` with findings already rolled up. Two findings are duplicates when they point to the same file and overlapping line/range and prescribe materially the same correction, even when their knowledge-file IDs differ. Merge duplicates instead of appending both: keep the more specific domain owner, preserve that finding's optional `domain` field verbatim (including its absence), use its reference as `references[0]` and therefore as `id`, append the other references as supporting references, keep the highest severity and confidence justified by either report, and preserve one self-contained message. Article and leaf ownership notes decide specificity; do not choose by execution order.
5. Append each non-duplicate finding, setting `from-sub-skill` to the sub-skill's `skill.id` and preserving its optional `domain` field verbatim, including its absence. For non-citation findings (those whose `id` is a skill-defined slug rather than a reference path), prefix `id` with `:` to prevent collisions across sub-skills. Other finding fields are preserved.
@@ -116,13 +119,19 @@ Sub-skills MAY also emit `suggested-code` when their knowledge file unambiguousl
### Summary and rollup
-Aggregate `summary.counts` and `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. Agent findings emitted by the super-skill itself contribute to `summary.counts` but not to `summary.coverage` (coverage is a sub-skill worklist metric and is undefined for self-review).
+Calculate `summary.counts` from the final top-level `findings[]`, after failed sub-results have been excluded and duplicates have been merged. Aggregate `summary.coverage` as the sums across invoked sub-skills whose `outcome` is not `failed`. Agent findings emitted by the super-skill itself contribute to `summary.counts` but not to `summary.coverage` (coverage is a sub-skill worklist metric and is undefined for self-review).
`suppressed[]` at the super-skill level remains empty. Knowledge-file-level suppression is reported by each sub-skill within its own entry in `sub-results`.
Derive `outcome` using the DO rollup rules. `outcome-reason` is populated for `partial` and `failed` and SHOULD summarize per-sub-skill state, for example: *"al-security-review failed (tool timeout); al-performance-review completed."*
-Before emitting the rollup, apply DO's reference-integrity gate to every nested and top-level finding. Every knowledge-backed ID/reference path must exist in the live checkout, must have been opened by the producing leaf, and must be copied verbatim rather than synthesized. Treat a sub-result containing an unverifiable citation as failed and exclude its findings from the top-level rollup.
+Before emitting the rollup, apply DO's consumer acceptance gate to every nested
+and top-level finding. A leaf's nested report is its accepted exact return or
+its accepted normalized candidate copy; its exact Task return remains the
+separate immutable raw audit payload. Treat an invalid sub-result as failed and
+exclude all of its findings from the top-level rollup. Never reconstruct it
+into a success-shaped report or perform normalization beyond DO's bounded
+exception.
## Output
@@ -300,7 +309,9 @@ Output conforms to the DO output contract, extended with `sub-results` and `skip
}
```
-The empty-corpus case โ BCQuality's state until knowledge files land โ rolls up to `no-knowledge`:
+When the selected leaves find no applicable knowledge, the result rolls up to
+`no-knowledge`. This example shows two leaf results; a full run includes every
+invoked leaf:
```json
{
diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md
index 2386613..05dcd0b 100644
--- a/microsoft/skills/review/al-data-modeling-review.md
+++ b/microsoft/skills/review/al-data-modeling-review.md
@@ -4,7 +4,7 @@ id: al-data-modeling-review
version: 1
title: AL data-modeling review
description: Performs an AL data-modeling review against guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Data-modeling findings are narrow by design โ they apply when the diff touches setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design โ they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `data-modeling` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/data-modeling/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain data-modeling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-error-handling-review.md b/microsoft/skills/review/al-error-handling-review.md
index 39851ac..56bc0fe 100644
--- a/microsoft/skills/review/al-error-handling-review.md
+++ b/microsoft/skills/review/al-error-handling-review.md
@@ -4,7 +4,7 @@ id: al-error-handling-review
version: 1
title: AL error handling review
description: Reviews AL source changes against error-handling guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `error-handling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `error-handling` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/error-handling/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain error-handling`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -132,7 +132,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case โ BCQuality's state until error-handling knowledge files land โ produces:
+When no applicable error-handling knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-events-review.md b/microsoft/skills/review/al-events-review.md
index c854f1c..68bcdb0 100644
--- a/microsoft/skills/review/al-events-review.md
+++ b/microsoft/skills/review/al-events-review.md
@@ -4,7 +4,7 @@ id: al-events-review
version: 1
title: AL events review
description: Reviews AL source changes against events-and-subscribers guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `events` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `events` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/events/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain events`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -141,7 +141,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case โ BCQuality's state until events knowledge files land โ produces:
+When no applicable events knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-interfaces-review.md b/microsoft/skills/review/al-interfaces-review.md
index 0031e8f..f5d65cd 100644
--- a/microsoft/skills/review/al-interfaces-review.md
+++ b/microsoft/skills/review/al-interfaces-review.md
@@ -4,7 +4,7 @@ id: al-interfaces-review
version: 1
title: AL interfaces review
description: Reviews AL source changes against interface and enum-with-implementation guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `interfaces` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `interfaces` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/interfaces/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain interfaces`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-performance-review.md b/microsoft/skills/review/al-performance-review.md
index bf2f4e8..664f20d 100644
--- a/microsoft/skills/review/al-performance-review.md
+++ b/microsoft/skills/review/al-performance-review.md
@@ -4,7 +4,7 @@ id: al-performance-review
version: 1
title: AL performance review
description: Reviews AL source changes against performance guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `performance` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `performance` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/performance/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain performance`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -134,7 +134,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case โ BCQuality's state until performance knowledge files land โ produces:
+When no applicable performance knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md
index 0bbd8ae..17b4e7b 100644
--- a/microsoft/skills/review/al-privacy-review.md
+++ b/microsoft/skills/review/al-privacy-review.md
@@ -4,7 +4,7 @@ id: al-privacy-review
version: 1
title: AL privacy review
description: Reviews AL source changes against privacy and data-classification guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `privacy` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `privacy` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/privacy/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain privacy`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-query-review.md b/microsoft/skills/review/al-query-review.md
index c4ea895..c52ddd8 100644
--- a/microsoft/skills/review/al-query-review.md
+++ b/microsoft/skills/review/al-query-review.md
@@ -4,7 +4,7 @@ id: al-query-review
version: 1
title: AL Query review
description: Reviews AL Query objects and Query instance usage against BCQuality guidance.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -18,7 +18,7 @@ Reviews AL source changes against the `query` knowledge domain in BCQuality. Thi
## Source
-Read `knowledge-index.json` once and take entries whose `domain` is `query` across enabled layers. Open an article body only after it enters the Worklist. If the index is unavailable, discover `*/knowledge/query/*.md` by path.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain query`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-security-review.md b/microsoft/skills/review/al-security-review.md
index 8472afe..00e8d10 100644
--- a/microsoft/skills/review/al-security-review.md
+++ b/microsoft/skills/review/al-security-review.md
@@ -4,7 +4,7 @@ id: al-security-review
version: 1
title: AL security review
description: Reviews AL source changes against security guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `security` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `security` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/security/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain security`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -129,7 +129,7 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
}
```
-The empty-corpus case โ BCQuality's state until security knowledge files land โ produces:
+When no applicable security knowledge is available, the report is:
```json
{
diff --git a/microsoft/skills/review/al-style-review.md b/microsoft/skills/review/al-style-review.md
index bffef4d..2703c87 100644
--- a/microsoft/skills/review/al-style-review.md
+++ b/microsoft/skills/review/al-style-review.md
@@ -4,7 +4,7 @@ id: al-style-review
version: 1
title: AL style review
description: Reviews AL source changes against naming, labelling, and code-convention guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,13 +16,13 @@ application-area: [all]
Reviews AL source changes against the `style` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-Style findings cover AL conventions that CodeCop and similar analyzers partially enforce โ label suffixes, API page naming, temporary-variable prefixes, label properties, named invocations, `FieldCaption`/`TableCaption` in user messages, `OptionCaption` pairing, Error-parameter passing, `this` keyword, required parentheses, file-naming. Use together with a formal analyzer; this skill adds BCQuality's remedial-knowledge explanations of why each rule exists.
+Style findings cover AL conventions that require contextual judgment โ API page naming, temporary-variable prefixes, label semantics, named invocations, `FieldCaption`/`TableCaption` in user messages, error-parameter handling, and file naming. Mechanical compiler and analyzer rules are intentionally outside this skill; run the consuming app's configured analyzers separately.
-An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `style` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/style/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain style`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
@@ -40,8 +40,8 @@ Discard files that are not applicable. Retain conditionally applicable files onl
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:
- Changed AL objects โ especially API pages (`PageType = API`), tables and pages declaring Labels/TextConsts, codeunits issuing `Error`/`Message`/`Confirm`, and any file whose name violates the `..al` convention.
-- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, option fields, error-handling call sites, and codeunit-internal method calls.
-- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `OptionMembers`, `OptionCaption`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`).
+- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, error-handling call sites, and API declarations.
+- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `StrSubstNo`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object or declaration. Read an article's full file โ its `## Best Practice` / `## Anti Pattern` bodies โ only after it makes the worklist; candidate selection uses the index alone.
@@ -50,8 +50,6 @@ Do not worklist `temporary-variable-temp-prefix.md` for an event publisher param
Apply these high-signal mappings before fuzzy topic ranking:
- A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder โ `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged.
-- `function-call-parentheses-required.md` applies only to a zero-argument invocation written without `()`. Never worklist it from an invocation that already has parentheses or supplies arguments, including `Error(Label, Arg1, Arg2)`.
-
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.
When the post-conflict worklist is empty because no applicable style knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable style knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array.
@@ -60,7 +58,7 @@ When the post-conflict worklist is empty because no applicable style knowledge e
For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Style findings rarely reach `blocker` โ reserve it for cases where the knowledge file documents a platform-level requirement (for example, API page property constraints the OData runtime rejects). Most style findings are `minor` or `info`; egregious misuse (`Error` with pre-built Text losing translation and telemetry classification) may reach `major`.
-Severity calibration โ a formal analyzer already flags the mechanical presence/naming conventions (the `this` keyword AA0248, approved label suffixes AA0074, variable-declaration order by type AA0021, a missing `ToolTip`, required parentheses). On those, BCQuality's value is the *explanation* of why the rule exists, not a second gate; emit them at `info` so a consumer that gates on severity does not re-flag what CodeCop/AppSourceCop already reports. Reserve `minor` for style issues with concrete downstream impact the analyzer does not catch โ lost translation or telemetry classification from a string-built `Error`, an `OptionCaption` that does not match its `OptionMembers`, or a misleading named invocation. A procedure-local `Label` is valid and is not a correctness or localization finding; an explicit repository preference for object scope is at most low-severity maintainability guidance. This keeps the domain's default output advisory and prevents analyzer-redundant noise from competing with substantive review.
+Severity calibration โ reserve `minor` for style issues with concrete downstream impact that deterministic tooling does not establish, such as lost translation or telemetry classification from a string-built `Error` or a misleading named invocation. A procedure-local `Label` is valid and is not a correctness or localization finding; an explicit repository preference for object scope is at most low-severity maintainability guidance. Do not rediscover or report mechanical compiler or analyzer diagnostics, even at `info`.
Set `confidence` to:
@@ -70,7 +68,7 @@ Set `confidence` to:
After evaluating each worklist entry, also consider whether the diff exhibits a style defect the agent recognises from its general AL knowledge that no knowledge file in the worklist covers. Such candidates are agent findings within this skill's domain โ emit them with `references: []`, an `id` slug prefixed with `agent:`, `confidence` capped at `medium`, `severity` capped at `minor` (agent findings are advisory and non-gating), and a `message` that is self-contained (describing both the issue and a concrete recommendation, since there is no knowledge-file footer for the consumer to fall back on). Hold every candidate to the precision bar in `skills/do.md` (*Agent findings*): emit only a clear, widely-accepted AL style violation with a concrete basis a knowledgeable BC reviewer would agree on โ steelman it first and drop personal preference, speculation, and any single defensible formatting choice among several; when in doubt, omit. The scope is strictly style โ naming, labelling, formatting, and analyzer-adjacent conventions. A correctness, logic, data-integrity, or contract defect is NOT a style finding even when it can be reworded as a convention: a method that mutates a shared `Record`'s filters, an unfiltered `DeleteAll`, a violated interface contract, or a wrong boolean guard are behavioural defects, not conventions โ do not emit them here under a style framing. If a specific domain leaf covers the concern (performance, security, error-handling, โฆ) it belongs there; if no knowledge file in any domain covers it, it belongs to the `al-code-review` super-skill's cross-cutting self-review agent channel (`from-sub-skill: "agent"`, `severity` capped at `minor`), not to this leaf. A reliable test: if you cannot cite a style `## Best Practice`/`## Anti Pattern` for the concern, it is very likely not a style finding. Before emitting, check the worklist for a knowledge file that matches the candidate โ if one exists, upgrade the candidate to a knowledge-backed finding instead. See `skills/do.md` for the full contract.
-For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; add a missing `ToolTip`, `OptionCaption`, or `DataClassification`; replace a string-concatenated `Error` with a Label-backed call; change an over-broad permission token; or add an obvious `else`/guard branch). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
+For every emitted finding, decide whether the fix is mechanical. A fix is mechanical when it is small, local, and unambiguous from the diff context (for example: add a missing contextual `ToolTip`, replace a string-concatenated `Error` with a Label-backed call, or correct an API naming property whose intended value is clear). For mechanical findings, emit `findings[].suggested-code` with the literal replacement for the source lines indicated by `location`. The payload must be a verbatim replacement โ no diff markers, no fences, no commentary โ that the consumer can render as a one-click suggestion. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, adapt the `.good.al` replacement into `suggested-code`.
Omit `suggested-code` only when the appropriate fix depends on context the skill cannot determine, when multiple defensible replacements exist, or when the fix spans non-contiguous code. If a finding is mechanical-looking but you omit `suggested-code`, set `findings[].suggested-code-omission-reason` to a short explanation. See `skills/do.md` for the full contract.
@@ -91,20 +89,20 @@ Output conforms to the DO output contract. Every finding this skill emits MUST s
"skill": { "id": "al-style-review", "version": 1 },
"outcome": "completed",
"summary": {
- "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 1 },
+ "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 },
"coverage": { "worklist-size": 1, "items-evaluated": 1 }
},
"findings": [
{
- "id": "microsoft/knowledge/style/label-suffix-approved-list.md",
- "severity": "info",
- "message": "A Label named Text000 has no approved suffix (Msg/Err/Qst/Tok/Lbl/Txt). Per the referenced CodeCop AA0074 guidance, every Label and TextConst carries a suffix indicating its consuming call.",
+ "id": "microsoft/knowledge/style/label-comment-explains-placeholders.md",
+ "severity": "minor",
+ "message": "The label has two ambiguous placeholders but no Comment explaining what each value represents to translators.",
"location": {
"file": "src/Sales/PostingRoutines.Codeunit.al",
"line": 42
},
"references": [
- { "path": "microsoft/knowledge/style/label-suffix-approved-list.md" }
+ { "path": "microsoft/knowledge/style/label-comment-explains-placeholders.md" }
],
"confidence": "high",
"domain": "Style"
diff --git a/microsoft/skills/review/al-telemetry-review.md b/microsoft/skills/review/al-telemetry-review.md
index 4a758f0..1c2046d 100644
--- a/microsoft/skills/review/al-telemetry-review.md
+++ b/microsoft/skills/review/al-telemetry-review.md
@@ -4,7 +4,7 @@ id: al-telemetry-review
version: 1
title: AL telemetry review
description: Performs an AL telemetry review against guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `telemetry` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Telemetry findings are narrow by design โ they apply when the diff emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Telemetry findings are narrow by design โ they apply when the review scope emits, wraps, or changes custom telemetry through `Session.LogMessage`, `Session.LogError`, `FeatureTelemetry`, or related telemetry helpers. The skill returns `not-applicable` when none of those apply.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `telemetry` as this skill's candidate set across every enabled Microsoft, community, and custom layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/telemetry/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain telemetry`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-testing-review.md b/microsoft/skills/review/al-testing-review.md
index 8bad734..c96ac83 100644
--- a/microsoft/skills/review/al-testing-review.md
+++ b/microsoft/skills/review/al-testing-review.md
@@ -4,7 +4,7 @@ id: al-testing-review
version: 1
title: AL testing review
description: Performs an AL testing review against guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `testing` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Testing findings are narrow by design โ they apply when the diff touches test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Testing findings are narrow by design โ they apply when the review scope contains test codeunits, test runners, test methods, handlers, assertions, or fixture construction. The skill returns `not-applicable` when none of those apply.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `testing` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/testing/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain testing`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-ui-review.md b/microsoft/skills/review/al-ui-review.md
index c0af5af..8ffd731 100644
--- a/microsoft/skills/review/al-ui-review.md
+++ b/microsoft/skills/review/al-ui-review.md
@@ -4,7 +4,7 @@ id: al-ui-review
version: 1
title: AL UI and accessibility review
description: Reviews AL page and control add-in UI files against UI text, caption, tooltip, and accessibility guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al, javascript]
@@ -18,11 +18,11 @@ Reviews AL page source and control add-in UI files against the `ui` knowledge do
UI findings apply to page files โ files that declare `PageType = ...`, including `*.Page.al` under the standard file-naming convention โ and to JavaScript/CSS/HTML files that implement Business Central control add-ins, including their client-service communication. The skill returns `not-applicable` when the diff contains no page or control add-in changes.
-An orchestrator invokes this skill with either a `pr-diff` or a `file-path`. The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `ui` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/ui/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain ui`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-upgrade-review.md b/microsoft/skills/review/al-upgrade-review.md
index 667ea32..94851a3 100644
--- a/microsoft/skills/review/al-upgrade-review.md
+++ b/microsoft/skills/review/al-upgrade-review.md
@@ -4,7 +4,7 @@ id: al-upgrade-review
version: 1
title: AL upgrade review
description: Reviews AL source changes against upgrade-code and migration guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `upgrade` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Upgrade findings are narrow by design โ they apply when the diff touches upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Upgrade findings are narrow by design โ they apply when the review scope contains upgrade codeunits, install codeunits, table schema, enums, or objects under migration namespaces. The skill returns `not-applicable` when none of those apply.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `upgrade` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/upgrade/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain upgrade`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/microsoft/skills/review/al-web-services-review.md b/microsoft/skills/review/al-web-services-review.md
index cfa6fdd..19d0735 100644
--- a/microsoft/skills/review/al-web-services-review.md
+++ b/microsoft/skills/review/al-web-services-review.md
@@ -4,7 +4,7 @@ id: al-web-services-review
version: 1
title: AL web services review
description: Reviews AL API surfaces and webhook integration handlers against web-services guidance from BCQuality.
-inputs: [pr-diff, file-path]
+inputs: [pr-diff, file-path, folder-path]
outputs: [findings-report]
bc-version: [all]
technologies: [al, javascript]
@@ -16,11 +16,11 @@ application-area: [all]
Reviews AL source changes against the `web-services` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`.
-An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). The skill produces a single JSON document conforming to the DO output contract.
+An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. The skill produces a single JSON document conforming to the DO output contract.
## Source
-Read the BCQuality knowledge index once โ the `knowledge-index.json` BCQuality builds at the root of the knowledge checkout (Entry's preparation step regenerates it over the live, already-filtered clone โ see `skills/entry.md`). It lists every article that survived layer and allow/deny filtering and carries, per article, its `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint โ exactly the fields Relevance and Worklist consume. Take the index entries whose `domain` is `web-services` as this skill's candidate set across every enabled layer; do not open the individual article files at this step. Open an article's full body only once it enters the Worklist below, so a review reads the index plus the handful of worklisted articles instead of every file under `*/knowledge/web-services/**`.
+Use READ's **Bounded retrieval for review skills** workflow with `-Domain web-services`. Consume every catalog page across enabled layers before applying this leaf's Relevance and Worklist; preserve each exact catalog path and open complete bodies only for exact paths selected by the Worklist. If the helper or prepared index is unavailable or invalid, use READ's explicit path-discovery and bounded native-read fallback.
## Relevance
diff --git a/skills/README.md b/skills/README.md
index 3d8fdfb..766131f 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -48,8 +48,6 @@ This gives the two skill formats distinct roles:
The host adapter and internal coordinator deliberately share the
`al-code-review` name because they represent the same user-facing operation in
their respective formats. Their locations distinguish their roles. The
-adapter remains distinct from BC-ALAgents' separately installed `al-review`
-skill, avoiding a collision in hosts that use one shared skill inventory. The
reference from the adapter to Entry, and from a dispatched super-skill to its
leaf skills, is intentional progressive disclosure. It avoids registering
every internal BCQuality protocol file as an ambient host skill while allowing
@@ -57,4 +55,4 @@ each review domain to run in an isolated context.
These contracts are stable. Changes require a PR approved by both maintainers.
-For the end-to-end flow โ from orchestrator trigger through to findings integration โ see [`../agent-consumption.md`](../agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md).
+For the end-to-end flow โ from orchestrator trigger through to findings integration โ see [How agents consume BCQuality](../docs/agent-consumption.md). For the high-level project framing, see [`../README.md`](../README.md).
diff --git a/skills/al-code-review/SKILL.md b/skills/al-code-review/SKILL.md
index 991a771..df54914 100644
--- a/skills/al-code-review/SKILL.md
+++ b/skills/al-code-review/SKILL.md
@@ -1,6 +1,6 @@
---
name: al-code-review
-description: Review Business Central AL code changes using BCQuality's curated rules. Use for an AL pull request, working-tree diff, branch, or individual AL file when BCQuality is installed as a standalone plugin.
+description: Review Business Central AL code using BCQuality's curated rules. Use for an AL app folder, pull request, working-tree diff, branch, or individual AL file when BCQuality is installed as a standalone plugin.
---
# AL code review
@@ -21,8 +21,11 @@ context and execute the resulting dispatch.
- Copy the caller's actual request verbatim into `goal`; do not replace a
focused request such as "review performance" with a generic full-review
goal.
- - Set `inputs-available` to the inputs actually available to the review,
- normally `pr-diff` for changes or `file-path` for one file.
+ - Set `inputs-available` to the inputs actually available to the review:
+ `folder-path` for an app or source folder, `pr-diff` for changes, or
+ `file-path` for one file. Pass the caller's actual path with the selected
+ input type; for a whole-app request in the current working directory, use
+ that directory as the `folder-path`.
- Set `technologies: [al]` when the input is known to be AL.
- Pass `bc-version`, `countries`, and `application-area` only when supplied
or reliably determined.
@@ -66,4 +69,3 @@ where a consumer prunes its checkout to policy before the agent runs and the
index is rebuilt over the pruned tree. Treat `BCQUALITY_ENABLED_LAYERS` as a
selection filter, never as a security boundary. A host that needs a genuine
deny mechanism must prune the installed tree itself.
-
diff --git a/skills/do.md b/skills/do.md
index a79c5fc..9abdf58 100644
--- a/skills/do.md
+++ b/skills/do.md
@@ -19,7 +19,12 @@ An action skill is a single markdown file with YAML frontmatter. It lives inside
- `/community/skills/` โ community-contributed action skills.
- `/custom/skills/` โ partner or customer action skills (typically in a consumer repo, not in BCQuality itself).
-Action skills do not live at the repo root. The files in `/skills/` โ the three meta-skill contracts (READ, DO, WRITE) and the entry-point skill (`entry.md`, `kind: entry-point`) โ are the only skills that sit outside a layer. The entry-point skill structurally follows this same four-step pattern but produces a dispatch record rather than a findings-report; see `skills/entry.md` for its contract.
+Action skills do not live at the repo root. Layer-independent files in
+`/skills/` contain the three meta-skill contracts (READ, DO, WRITE), the
+entry-point skill (`entry.md`, `kind: entry-point`), and host-format adapters.
+Adapters are not action skills. Entry structurally follows the same
+four-step pattern but produces a dispatch record rather than a findings-report;
+see [entry.md](entry.md) for its contract.
## Skills hold mechanics; knowledge files hold BC facts
@@ -56,7 +61,24 @@ application-area: [all]
`bc-version`, `technologies`, `countries`, `application-area` are optional filters that let an orchestrator pre-select applicable skills for a task. They follow the same semantics as in READ.
-`inputs` is a list of abstract input types the skill **accepts**. Standard values: `pr-diff`, `object-list`, `file-path`, `repository`, `telemetry-query`. Semantics are any-of: the orchestrator supplies whichever listed input types it has, and the skill is invoked with a non-empty subset of its declared `inputs`. A skill that cannot proceed with the supplied subset MUST return `outcome: "not-applicable"`. `outputs` is always a single-element list naming the output kind; today only `findings-report` is defined.
+`inputs` is a list of abstract input types the skill **accepts**. Standard values:
+`pr-diff`, `object-list`, `file-path`, `folder-path`, `repository`, and
+`telemetry-query`. Semantics are any-of: the orchestrator supplies whichever
+listed input types it has, and the skill is invoked with a non-empty subset of
+its declared `inputs`. A skill that cannot proceed with the supplied subset
+MUST return `outcome: "not-applicable"`. `outputs` is always a single-element
+list naming the output kind; today only `findings-report` is defined.
+
+`file-path` is one file. `folder-path` is a directory whose recursively
+contained files form the complete current-state input, such as a Business
+Central app folder containing `app.json` and AL source. The input value is the
+actual path, not merely the name of the input type. The agent MUST enumerate
+the folder rather than reducing it to one representative file.
+
+Review skills use terms such as "diff", "changed files", and "changed code" as
+shorthand for the supplied review scope. For `folder-path`, every relevant file
+under the folder is in scope. A folder supplies no historical baseline:
+comparison-only rules MUST NOT infer a prior state that was not provided.
`sub-skills` is an optional field. When present and non-empty, the skill is a **super-skill** that composes other action skills; see *Composition* below. Values are repo-relative paths to action-skill files.
@@ -137,6 +159,71 @@ The emitted document MUST be strict, valid JSON per [RFC 8259](https://www.rfc-e
AL source is the common failure case. Quoted identifiers (for example `Rec."No."`) and multi-line snippets routinely appear in `message`, `suggested-code`, and `suggested-code-omission-reason`, and each embedded quote or newline MUST be escaped when placed in a string value. A `suggested-code` payload that spans several lines is a single JSON string with `\n` separators, not a literal multi-line block. Emit the document as one JSON value with no trailing commentary, and do not rely on the consumer to repair unescaped output.
+### Consumer acceptance gate
+
+Capture the exact Task return as the immutable raw audit payload and primary
+transport. Preserve it unchanged in private run artifacts or host logs before
+creating any derived value. The accepted findings-report is either that exact
+return or the bounded normalized candidate described below; the raw audit
+payload never changes.
+
+Before the full acceptance gate, a coordinator MAY create a normalized
+candidate copy only through this deterministic procedure:
+
+1. Parse the exact return as strict JSON and provisionally check the complete
+ report without mutating it. Every acceptance rule below MUST already pass
+ except for one or more findings whose optional `location.range` has
+ `start-line != line`.
+2. Each such finding is eligible only when `location.line`,
+ `location.range.start-line`, and `location.range.end-line` are positive
+ integers, `start-line <= line <= end-line`, and the finding does not contain
+ the `suggested-code` field. Field presence disqualifies normalization even
+ if its value is empty because suggested code may be bound to the reported
+ range.
+3. Deep-copy the complete parsed report. In the candidate copy, remove only
+ `location.range` from every eligible finding. Retain `location.line` and
+ every other value unchanged. Do not add normalization metadata to the
+ findings-report.
+4. Record each removed range separately in private run telemetry or artifacts,
+ associated with the immutable raw audit payload. This record is
+ runner-owned and is not part of the declared report schema.
+5. Validate the entire normalized candidate with the existing full consumer
+ acceptance gate below. Only a candidate that passes every rule becomes the
+ accepted copy used for rollup. If any other validation defect exists, or
+ full validation fails, discard the candidate, preserve the raw payload, and
+ fail the complete leaf as before.
+
+This exception does not infer missing fields, alter references or paths, clamp
+line numbers, repair JSON, normalize a reversed or out-of-bounds range, remove
+a range from a finding containing `suggested-code`, or salvage arbitrary
+individual findings.
+
+Before accepting either the exact return or an eligible normalized candidate
+as a findings-report, a coordinator or host MUST validate it deterministically:
+
+1. Validate every required field, enum, type, conditional requirement, summary
+ count, coverage value, and leaf/super-skill constraint against this output
+ contract.
+2. For every knowledge-backed finding, verify each `references[].path` is an
+ exact repo-relative knowledge path that exists in the live BCQuality
+ snapshot, and verify `findings[].id` exactly equals
+ `references[0].path`. Verify each path is also present in the coordinator's
+ recorded set of complete article bodies retrieved for that leaf; catalog
+ membership alone is insufficient. Keep optional `references[].sha`
+ separate: it is commit provenance, not an article content hash.
+3. For every `location`, verify `file` is an exact source path in the supplied
+ review scope, the file exists in that source snapshot, and `line` and any
+ inclusive range identify existing lines with `start-line == line` and
+ `end-line >= start-line`.
+
+Validation failure invalidates the complete return; consumers MUST NOT salvage
+individual findings, infer missing fields, reconstruct JSON, clamp ranges,
+rewrite paths, or otherwise silently repair model output. Preserve the invalid
+raw payload unchanged. Record a separate failed validation result for that leaf
+with no findings, and derive the super-skill outcome as `partial` or `failed`
+using the normal rollup rules. Worker-side report-file persistence is optional
+and never replaces validation of the accepted exact or normalized copy.
+
### Field semantics
**`outcome`** (required) โ
@@ -231,7 +318,7 @@ Omit `suggested-code` only when the appropriate fix depends on context the skill
- `reference` โ the suppressed file (same object shape as `findings[].references`).
- `reason` โ `layer-precedence` when another layer won under READ's precedence rules; `configuration` when the consumer disabled the file's layer.
-**`sub-results`** โ super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Leaf skills MUST NOT emit `sub-results`.
+**`sub-results`** โ super-skills only. Array of complete findings-reports, one per sub-skill that was invoked (i.e., every sub-skill not listed in `skipped-sub-skills`). Each entry MUST itself conform to this output contract. Entries MUST appear in the worklist's declared order, regardless of invocation or completion order. Leaf skills MUST NOT emit `sub-results`.
**`skipped-sub-skills`** โ super-skills only. Array of sub-skills that were declared in frontmatter but not invoked. `reason` is `configuration` when the orchestrator disabled the sub-skill, or `not-applicable` when the super-skill's Relevance step ruled it out.
@@ -248,6 +335,20 @@ A **super-skill** is an action skill whose frontmatter declares a non-empty `sub
Composition is flat: a super-skill MAY list only leaf skills (skills without their own `sub-skills`). Nested super-skills are not permitted in v1.
+### Scheduling boundary
+
+The super-skill defines which leaves must run, the input and output contracts,
+and how their results are composed. It does not prescribe a model, concurrency
+limit, retry policy, or telemetry system. Those choices belong to the
+orchestrator.
+
+Each leaf invocation MUST remain a discrete evaluation with its own complete
+findings-report. An orchestrator MAY execute independent leaves serially or
+concurrently, but MUST invoke every worklisted leaf, preserve `sub-results` in
+the declared worklist order, and wait for every invocation to finish before
+performing any super-skill self-review or final rollup. Scheduling MUST NOT
+change relevance, coverage, failure, reference-integrity, or output semantics.
+
### Section interpretation for super-skills
The five required sections still apply. Their meaning shifts from knowledge files to sub-skills:
@@ -274,7 +375,13 @@ When the worklist is empty (every sub-skill was skipped), `outcome` is `not-appl
### Rolled-up summary
-`summary.counts` is the sum of sub-skill counts. `summary.coverage.worklist-size` and `items-evaluated` are the sums across invoked sub-skills.
+`summary.counts` counts the findings in the super-skill's final top-level
+`findings[]`, after failed sub-results have been excluded and duplicates have
+been merged. It MUST NOT be calculated by summing sub-skill counts, because the
+same concern may appear in more than one sub-result.
+
+`summary.coverage.worklist-size` and `items-evaluated` are the sums across
+invoked sub-skills whose outcomes are not `failed`.
### Suppression scope
@@ -282,15 +389,16 @@ A super-skill's top-level `suppressed[]` remains knowledge-file-only and is typi
## Worked example
-A minimal action skill that cites applicable guidance for a changed AL file, without generating findings of its own:
+A minimal action skill that reviews a changed AL file against applicable
+guidance. Relevance alone never produces a finding:
```yaml
---
kind: action-skill
-id: cite-applicable-guidance
+id: review-applicable-guidance
version: 1
-title: Cite applicable guidance
-description: Lists knowledge files relevant to a changed AL file.
+title: Review applicable guidance
+description: Reviews a changed AL file against applicable knowledge.
inputs: [file-path]
outputs: [findings-report]
technologies: [al]
@@ -308,7 +416,12 @@ Filter by `technologies: [al]` and `bc-version` matching the target environment.
Intersect `keywords` with tokens derived from the target file's object name and changed members.
## Action
-For each worklist entry, emit one finding with severity `info`, a message naming the concern, and a reference object pointing to the knowledge file.
+Read each worklisted article in full and compare its normative guidance to the
+input. Emit a finding only for a concrete violation or an observation the
+article explicitly defines, with justified severity, evidence, and a reference
+copied from the discovered article path. Do not report an article merely
+because it was relevant. If every item was evaluated and none warrants a
+finding, return `completed` with an empty `findings` array.
## Output
Conforms to the DO output contract.
diff --git a/skills/entry.md b/skills/entry.md
index 0196c35..efa84a1 100644
--- a/skills/entry.md
+++ b/skills/entry.md
@@ -23,6 +23,7 @@ task-context:
inputs-available: # values the orchestrator has ready to pass to a chosen skill
- pr-diff
- file-path
+ - folder-path
technologies: [al]
bc-version: 28
countries: [w1]
diff --git a/skills/read.md b/skills/read.md
index 6a2080d..dddd7db 100644
--- a/skills/read.md
+++ b/skills/read.md
@@ -7,7 +7,9 @@ title: Schema + Use โ how to read a knowledge file
# READ
-Every consumer of BCQuality โ an agent, an action skill, a human reviewer โ reads this file first. It defines what a knowledge file is, what fields it contains, what they mean, and how to reconcile multiple files.
+Read this contract before interpreting knowledge files. Task execution starts
+at [Entry](entry.md); READ is loaded on demand when a dispatched skill needs
+it. It defines knowledge fields, their meaning, and how to reconcile files.
This contract is stable. Changes require a PR approved by both maintainers.
@@ -131,7 +133,7 @@ Rules:
- A sample file is identified by the article's slug followed by a `..` suffix. The supported kinds are `good` and `bad`. Additional kinds MAY be introduced by a layer; consumers MUST ignore unknown kinds without failing.
- The extension matches the technology (`al`, `ps1`, `js`, `kql`, โฆ). A single article MAY carry samples in multiple technologies if the article's frontmatter `technologies` lists them.
-- Articles MAY have a `good` sample only, a `bad` sample only, both, or neither. The article text SHOULD reference each sample it ships, using a relative path like `` `.good.al` ``.
+- Articles MAY have a `good` sample only, a `bad` sample only, both, or neither. The article text SHOULD reference each sample it ships with a relative Markdown link whose label retains the backticked filename, like `` [`.good.al`](.good.al) ``.
- Samples are **demonstration-only**. They are not deployed, not compiled as part of a published app, and not derived from the Business Central base application source. Each sample is self-contained and exists purely to make the accompanying article concrete for humans and agents.
- Layer precedence applies to sample files the same way it applies to articles: a `/custom/knowledge//.good.al` overrides a `/microsoft/knowledge//.good.al` for the same article in the same layer hierarchy.
@@ -147,3 +149,58 @@ The standard workflow for finding applicable files:
4. Resolve conflicts via layer precedence.
Steps 1โ3 are deterministic; step 4 is applied only when conflicts are detected.
+
+### Bounded retrieval for review skills
+
+Resolve `$root` to the BCQuality root, not the reviewed source. Entry prepares
+the index once before dispatch; that prepared index is the catalog snapshot and
+leaves use it read-only. Catalog retrieval validates the complete index metadata
+and returned paths without reopening or rehashing article bodies. Post-Entry
+body changes therefore take effect only after Entry rebuilds the index; exact
+body retrieval rejects a selected article whose content hash differs from its
+prepared row. In one PowerShell tool session, invoke the helpers with `&` so
+array arguments remain arrays:
+
+```powershell
+& (Join-Path $root 'tools\Search-Knowledge.ps1') -Domain $domain -Technologies @('al')
+& (Join-Path $root 'tools\Get-KnowledgeArticles.ps1') -Paths @($exactPath)
+```
+
+Pass enabled layers and only task dimensions that are actually known. Catalog
+retrieval returns every domain and READ-applicable row: it does not rank,
+sample, apply top-k, deduplicate by basename, or omit rows based on query text.
+Consume every page by passing `continuation.offset` as `-Offset` and
+`continuation.snapshot` as `-Snapshot` with the unchanged request until
+`complete` is `true`. Each page repeats request context, defaults, and totals.
+An omitted applicability field on a row inherits that page's `defaults`; it
+does not mean unknown task context. Preserve every row's exact `path`, `layer`,
+complete `keywords`, `title`, one-line `description`, non-default applicability
+fields, explicit `applicability`, and `unknownDimensions`.
+
+Apply the leaf's existing Relevance and Worklist to the complete catalog union.
+Split the resulting exact paths into stable chunks of at most eight; never pass
+more paths than `-MaxArticles` (whose maximum is eight). Request article bodies
+only by one such chunk. Consume every
+returned `body`, then request `remainingPaths` with
+`continuation.snapshot` as `-Snapshot` until `complete` is `true`, preserving
+the other request settings. Continuation is confined to that chunk. Bodies are
+original strict UTF-8 text with source byte counts and SHA-256 content hashes;
+they are never summarized or truncated. Samples are not loaded unless
+requested explicitly with `-Samples` and exact sibling paths; their sibling
+article must match its prepared hash and contain the exact READ link.
+
+The default serialized response limit is 16,000 bytes including its output
+newline. Never combine pages or bodies into an unbounded prompt. A malformed or
+internally inconsistent prepared index, changed continuation snapshot, selected
+article hash mismatch, invalid continuation, unsafe or missing path, invalid
+UTF-8, broken sample link, oversized path chunk, or row/envelope that cannot fit
+fails explicitly. Entry is the only index preparation point: a leaf does not
+rebuild. If PowerShell, a helper, or a valid prepared index is unavailable,
+discover exact paths across the enabled domain folders and use native bounded
+reads through EOF, validating frontmatter per READ and never treating retrieval
+failure as an empty result.
+
+The helpers' `sha256` and `bytes` fields describe the retrieved file content.
+They are not citation provenance. Optional findings `references[].sha` is the
+BCQuality commit SHA the skill reviewed; omit it when that provenance is not
+available or would misrepresent uncommitted content.
diff --git a/skills/write.md b/skills/write.md
index 8096f1a..c2edab5 100644
--- a/skills/write.md
+++ b/skills/write.md
@@ -16,7 +16,7 @@ Before authoring anything, confirm a knowledge file is the right artifact. BCQua
- **Skills** (`*/skills/**`) hold only finder/applier mechanics โ how to discover, filter, worklist, and emit findings. See `skills/do.md`.
- **Knowledge files** (`*/knowledge/**`) hold every Business-Central-specific fact a skill acts on.
-A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file โ apply the admission test in the [README](../README.md#what-belongs-here): *would a capable LLM get this wrong without the file?* If you find yourself editing a skill to stop it flagging something, stop and write a knowledge file instead.
+A new BC fact is therefore a knowledge file, never a skill edit. In particular, if you arrived here because a review agent flagged something it should not have (a false positive) or missed something it should have caught, the remedy is a knowledge file โ apply the [admission test](../docs/contributing.md#what-belongs-here): *would a capable LLM get this wrong without the file?* If you find yourself editing a skill to stop it flagging something, stop and write a knowledge file instead.
### Negative knowledge is first-class
@@ -56,6 +56,13 @@ Target under 100 lines. Ideal under 50. Long files almost always mean two concer
Custom `##` sections are permitted when they serve the concern (for example, `## Applies to` for scope caveats or `## See also` for related files). Consumers are not required to understand them, so do not put load-bearing content there.
+When adding or changing a platform claim, cite an authoritative public source
+where available. A short `## References` section can link the relevant API,
+property documentation, or public source definition. If no such source is
+available, identify the evidence or policy basis explicitly; do not imply an
+official guarantee. Keep the actual rule and its exceptions in normative
+sections, not only in references. See [sources and examples](../docs/contributing.md#sources-and-examples).
+
## No fenced code blocks
Knowledge files do not contain code. Samples live as **sibling files** next to the article โ `.good.al`, `.bad.al`, etc. โ in the same knowledge-layer folder. See `skills/read.md` for the full convention. This keeps knowledge files retrieval-friendly and prevents code from drifting out of sync with BC platform changes buried inside prose.
@@ -93,7 +100,7 @@ The `/custom/` layer is **empty by default** in the upstream `microsoft/BCQualit
Before authoring or scaffolding any file under `/custom/knowledge/` or `/custom/skills/`, an author โ human or agent โ MUST confirm the working repository is **not** `microsoft/BCQuality`:
- Check the `origin` remote: `git remote get-url origin`. If it points at `github.com/microsoft/BCQuality`, stop โ you are in the upstream repo, not a fork.
-- If you are in the upstream repo, do not write the file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or โ if the guidance is genuinely shareable โ author it in `/community/knowledge/` instead.
+- If you are in the upstream repo, do not write the custom file. Either fork the repository (or clone it into your organization's own repo) and add the custom content there, or โ if the guidance is genuinely shareable โ use the shared layer that owns the domain, following *Choosing a layer* above. Community is not a staging area for Microsoft-owned domains.
A pull request that adds `/custom/` content to `microsoft/BCQuality` will be **automatically closed** by the `Guard custom layer` workflow. Validate the fork precondition first so authoring effort is not wasted on a PR that cannot be merged.
@@ -109,7 +116,8 @@ Before opening a pull request:
- Frontmatter `domain` exactly matches the containing domain folder.
- File is in the correct layer and domain folder.
- Name is kebab-case and descriptive.
-- Every companion sample is referenced by filename from the article, and every referenced sample exists.
+- Every companion sample has a clickable relative link retaining its backticked filename, and every referenced sample exists.
+- Platform claims link supporting sources where available; policy or empirical guidance is identified as such.
- Every review-leaf domain has at least one article with both `.good.al` and `.bad.al` companions; the evaluation harness derives positive and clean controls from that convention automatically.
Agents scaffolding new files SHOULD run this checklist programmatically before emitting the file.
diff --git a/tools/Bounded-Results.ps1 b/tools/Bounded-Results.ps1
new file mode 100644
index 0000000..6def944
--- /dev/null
+++ b/tools/Bounded-Results.ps1
@@ -0,0 +1,117 @@
+# Shared deterministic paging. Callers build the complete immutable result first.
+#requires -Version 7.2
+Set-StrictMode -Version Latest
+
+function Get-ResultSnapshot {
+ param([Parameter(Mandatory)] $Value)
+
+ $json = ConvertTo-Json -InputObject $Value -Depth 30 -Compress
+ return [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($json))
+ ).ToLowerInvariant()
+}
+
+function Get-SerializedByteCount {
+ param([Parameter(Mandatory)] [string] $Json)
+
+ # PowerShell writes one platform newline after the returned JSON string.
+ return [Text.Encoding]::UTF8.GetByteCount($Json) +
+ [Text.Encoding]::UTF8.GetByteCount([Environment]::NewLine)
+}
+
+function ConvertTo-BoundedPage {
+ param(
+ [Parameter(Mandatory)] [Collections.IDictionary] $Header,
+ [Parameter(Mandatory)] [Collections.IDictionary] $Groups,
+ [ValidateRange(0, 2147483647)] [int] $Offset = 0,
+ [string] $Snapshot,
+ [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000
+ )
+
+ $total = 0
+ foreach ($name in $Groups.Keys) {
+ $total += $Groups[$name].Count
+ }
+ if (($total -eq 0 -and $Offset -ne 0) -or ($total -gt 0 -and $Offset -ge $total)) {
+ throw "Invalid Offset=$Offset for totalCount=$total; no rows were returned."
+ }
+ if ($Offset -gt 0 -and -not $Snapshot) {
+ throw 'Continuation requires Snapshot from the preceding page.'
+ }
+ if ($Snapshot -and $Snapshot -cne $Header.snapshot) {
+ throw 'Snapshot changed or continuation belongs to another request. Discard partial results and restart at Offset=0.'
+ }
+
+ $page = [ordered]@{}
+ foreach ($key in $Header.Keys) {
+ $page[$key] = $Header[$key]
+ }
+ $page.offset = $Offset
+ $page.returnedCount = 0
+ $page.totalCount = $total
+ $page.remainingCount = $total - $Offset
+ $page.complete = ($total -eq 0)
+ $page.continuation = if ($total) {
+ [ordered]@{ offset = $Offset; snapshot = $Header.snapshot }
+ }
+ else {
+ $null
+ }
+ foreach ($name in $Groups.Keys) {
+ $page[$name] = [Collections.Generic.List[object]]::new()
+ }
+
+ $json = ConvertTo-Json -InputObject $page -Depth 30 -Compress
+ if ((Get-SerializedByteCount -Json $json) -gt $MaxBytes) {
+ throw "Page envelope exceeds MaxBytes=$MaxBytes. Use READ's path-discovery fallback; never truncate."
+ }
+
+ $position = 0
+ foreach ($name in $Groups.Keys) {
+ foreach ($row in $Groups[$name]) {
+ if ($position++ -lt $Offset) {
+ continue
+ }
+
+ $page[$name].Add($row)
+ $page.returnedCount++
+ $page.remainingCount--
+ $page.complete = ($page.remainingCount -eq 0)
+ $page.continuation = if ($page.complete) {
+ $null
+ }
+ else {
+ [ordered]@{
+ offset = $Offset + $page.returnedCount
+ snapshot = $Header.snapshot
+ }
+ }
+
+ $next = ConvertTo-Json -InputObject $page -Depth 30 -Compress
+ if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) {
+ $page[$name].RemoveAt($page[$name].Count - 1)
+ $page.returnedCount--
+ $page.remainingCount++
+ $page.complete = $false
+ $page.continuation = [ordered]@{
+ offset = $Offset + $page.returnedCount
+ snapshot = $Header.snapshot
+ }
+ if ($page.returnedCount -eq 0) {
+ $rowPath = $null
+ if ($row -is [Collections.IDictionary]) {
+ if ($row.Contains('path')) { $rowPath = $row['path'] }
+ }
+ elseif ($null -ne $row -and $row.PSObject.Properties['path']) {
+ $rowPath = $row.PSObject.Properties['path'].Value
+ }
+ $identity = if ($rowPath) { " at $rowPath" } else { " at Offset=$Offset" }
+ throw "One complete $name row plus envelope exceeds MaxBytes=$MaxBytes$identity. No row was clipped."
+ }
+ return $json
+ }
+ $json = $next
+ }
+ }
+ return $json
+}
diff --git a/tools/Build-KnowledgeIndex.ps1 b/tools/Build-KnowledgeIndex.ps1
index 5835e5d..d246ff3 100644
--- a/tools/Build-KnowledgeIndex.ps1
+++ b/tools/Build-KnowledgeIndex.ps1
@@ -30,6 +30,8 @@
expected to prune its clone to policy first). For provenance and to
reproduce a consumer's exact view, pass -EnabledLayers to restrict the walk
to those layers and to record the policy in the index header.
+ Invalid articles are omitted with a path-specific warning so one bad
+ optional layer article cannot block valid siblings.
.PARAMETER BCQualityRoot
Path to the BCQuality content root to index (typically a filtered clone).
@@ -69,6 +71,17 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1')
+
+if ($PSBoundParameters.ContainsKey('EnabledLayers')) {
+ if ($null -eq $EnabledLayers) {
+ throw 'EnabledLayers must be an array; omit it to index all layers.'
+ }
+ if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) {
+ throw 'EnabledLayers must contain unique canonical lowercase layer names.'
+ }
+}
# Default to the clone root (parent of this script's tools/ folder) so the
# agent's Entry preparation step can invoke this with no arguments from the
@@ -93,6 +106,45 @@ function Get-RelativePath {
return ($rel -replace '\\', '/')
}
+function Get-BytesSha256 {
+ param([byte[]] $Bytes)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant()
+ }
+ finally {
+ $sha.Dispose()
+ }
+}
+
+function Read-ArticleSource {
+ param([string] $Path)
+ $bytes = [IO.File]::ReadAllBytes($Path)
+ try {
+ $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes)
+ }
+ catch [Text.DecoderFallbackException] {
+ throw [IO.InvalidDataException]::new('invalid UTF-8', $_.Exception)
+ }
+ return [pscustomobject]@{
+ bytes = $bytes
+ text = $text
+ sha256 = Get-BytesSha256 -Bytes $bytes
+ }
+}
+
+function Get-ValueSha256 {
+ param([Parameter(Mandatory)] $Value)
+ $bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $Value -Depth 8 -Compress))
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ return ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant()
+ }
+ finally {
+ $sha.Dispose()
+ }
+}
+
# Trims a Description to a single short line (<= $Max chars) for the lean
# index. Takes the first sentence; truncates on a word boundary if still long.
function Get-LeanDescription {
@@ -116,9 +168,12 @@ function ConvertFrom-ArticleFrontmatter {
# Pattern) is included; the index is a lossless substitute for the
# frontmatter + Description the worklist predicate reads, not a
# substitute for the article's normative guidance.
- param([string] $Path)
+ param(
+ [string] $Path,
+ [string] $Text
+ )
- $lines = Get-Content -LiteralPath $Path -ErrorAction Stop
+ $lines = [regex]::Split($Text.TrimStart([char]0xfeff), '\r\n|\n|\r')
# Frontmatter is the first '---'-delimited block.
if ($lines.Count -lt 1 -or $lines[0].Trim() -ne '---') { return $null }
@@ -129,19 +184,42 @@ function ConvertFrom-ArticleFrontmatter {
if ($fmEnd -lt 0) { return $null }
$fm = @{}
+ $arrayFields = @('bc-version', 'keywords', 'technologies', 'countries', 'application-area')
for ($i = 1; $i -lt $fmEnd; $i++) {
$line = $lines[$i]
if ($line -match '^\s*([a-zA-Z][\w-]*)\s*:\s*(.*)$') {
$key = $Matches[1]
$val = $Matches[2].Trim()
- if ($val -match '^\[(.*)\]$') {
+ if ($key -in $arrayFields) {
+ if ($val -notmatch '^\[(.*)\]$') {
+ throw [IO.InvalidDataException]::new(
+ "frontmatter field '$key' must use non-empty bracket-array syntax"
+ )
+ }
$inner = $Matches[1].Trim()
- if ($inner -eq '') { $fm[$key] = @() }
- else { $fm[$key] = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() }) }
+ if ($inner -eq '') {
+ throw [IO.InvalidDataException]::new(
+ "frontmatter field '$key' must use non-empty bracket-array syntax"
+ )
+ }
+ $values = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() })
+ if (@($values | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) {
+ throw [IO.InvalidDataException]::new(
+ "frontmatter field '$key' must use non-empty bracket-array syntax"
+ )
+ }
+ $fm[$key] = $values
}
elseif ($val -ne '') { $fm[$key] = $val }
}
}
+ foreach ($field in $arrayFields) {
+ if (-not $fm.ContainsKey($field) -or $fm[$field] -isnot [array] -or -not $fm[$field].Count) {
+ throw [IO.InvalidDataException]::new(
+ "frontmatter field '$field' must use non-empty bracket-array syntax"
+ )
+ }
+ }
# Body parsing: H1 title and the full Description section. The Description
# is the article's primary retrieval target per READ and is captured
@@ -185,42 +263,71 @@ $indexArticles = [System.Collections.Generic.List[object]]::new()
foreach ($layerDir in @('microsoft', 'community', 'custom')) {
$kbRoot = Join-Path $BCQualityRoot (Join-Path $layerDir 'knowledge')
if (-not (Test-Path $kbRoot)) { continue }
- if ($EnabledLayers -and ($EnabledLayers -notcontains $layerDir)) { continue }
+ if ($EnabledLayers -and ($EnabledLayers -cnotcontains $layerDir)) { continue }
- Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue |
- Sort-Object FullName |
- ForEach-Object {
- $rel = Get-RelativePath -Root $BCQualityRoot -Full $_.FullName
- $parsed = $null
- try { $parsed = ConvertFrom-ArticleFrontmatter -Path $_.FullName } catch { $parsed = $null }
+ $files = @(
+ Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue |
+ Sort-Object FullName
+ )
+ foreach ($file in $files) {
+ $rel = Get-RelativePath -Root $BCQualityRoot -Full $file.FullName
+ try {
+ $source = Read-ArticleSource -Path $file.FullName
+ $parsed = ConvertFrom-ArticleFrontmatter -Path $file.FullName -Text $source.text
if (-not $parsed) {
- # Invalid/unparseable file: list path + domain-from-path so it
- # is never silently dropped from discovery. Consumers fall back
- # to reading it in full.
- $domainFromPath = if ($rel -match '/knowledge/([^/]+)/') { $Matches[1] } else { '' }
- $indexArticles.Add([pscustomobject]@{
- path = $rel; layer = $layerDir; domain = $domainFromPath
- 'bc-version' = @(); technologies = @(); countries = @(); 'application-area' = @()
- keywords = @(); title = ''; description = ''; parsed = $false
- }) | Out-Null
- return
+ throw [IO.InvalidDataException]::new('missing or unterminated frontmatter')
+ }
+ foreach ($required in @(
+ @('domain', $parsed.domain),
+ @('H1 title', $parsed.title),
+ @('Description', $parsed.description)
+ )) {
+ if ([string]::IsNullOrWhiteSpace([string]$required[1])) {
+ throw [IO.InvalidDataException]::new("missing $($required[0])")
+ }
}
- $indexArticles.Add([pscustomobject]@{
- path = $rel
- layer = $layerDir
- domain = $parsed.domain
- 'bc-version' = @($parsed.'bc-version')
- technologies = @($parsed.technologies)
- countries = @($parsed.countries)
- 'application-area' = @($parsed.'application-area')
- keywords = @($parsed.keywords)
- title = $parsed.title
- description = if ($FullIndex) { $parsed.description } else { Get-LeanDescription -Text $parsed.description }
- parsed = $true
- }) | Out-Null
}
+ catch [IO.InvalidDataException] {
+ Write-Warning "Skipping invalid knowledge article '$rel': $($_.Exception.Message)."
+ continue
+ }
+
+ $article = [ordered]@{
+ path = $rel
+ layer = $layerDir
+ domain = $parsed.domain
+ 'bc-version' = @($parsed.'bc-version')
+ technologies = @($parsed.technologies)
+ countries = @($parsed.countries)
+ 'application-area' = @($parsed.'application-area')
+ keywords = @($parsed.keywords)
+ title = $parsed.title
+ description = if ($FullIndex) { $parsed.description } else { Get-LeanDescription -Text $parsed.description }
+ parsed = $true
+ sourceSha256 = $source.sha256
+ }
+ $problem = Get-KnowledgeMetadataProblem -Row $article
+ if ($problem) {
+ Write-Warning "Skipping invalid knowledge article '$rel': $problem."
+ continue
+ }
+ $indexArticles.Add($article) | Out-Null
+ }
}
+$articlesByPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+foreach ($article in $indexArticles) {
+ if (-not $articlesByPath.TryAdd($article.path, $article)) {
+ throw "Duplicate knowledge path while building source snapshot: $($article.path)"
+ }
+}
+$sourcePaths = [string[]]@($articlesByPath.Keys)
+[Array]::Sort($sourcePaths, [StringComparer]::Ordinal)
+$sourceManifest = @(
+ foreach ($path in $sourcePaths) {
+ [ordered]@{ path = $path; sha256 = $articlesByPath[$path].sourceSha256 }
+ }
+)
$index = [pscustomobject]@{
version = 1
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
@@ -228,6 +335,7 @@ $index = [pscustomobject]@{
knowledgeAllow= @($KnowledgeAllow)
knowledgeDeny = @($KnowledgeDeny)
articleCount = $indexArticles.Count
+ sourceSnapshot= Get-ValueSha256 -Value $sourceManifest
articles = @($indexArticles)
}
diff --git a/tools/Get-KnowledgeArticles.ps1 b/tools/Get-KnowledgeArticles.ps1
new file mode 100644
index 0000000..cdd112a
--- /dev/null
+++ b/tools/Get-KnowledgeArticles.ps1
@@ -0,0 +1,187 @@
+<#
+.SYNOPSIS
+ Reads a bounded prefix of exact article or sample paths without altering bodies.
+.DESCRIPTION
+ The UTF-8 byte size bound covers the complete serialized JSON plus its output
+ newline. A body that cannot fit fails explicitly; it is never summarized or
+ truncated. Samples are loaded only with -Samples and must be linked by their
+ sibling article using READ's exact link convention.
+#>
+#requires -Version 7.2
+[CmdletBinding()]
+param(
+ [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent),
+ [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $Paths,
+ [ValidateRange(1, 8)] [int] $MaxArticles = 8,
+ [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000,
+ [ValidateSet('microsoft', 'community', 'custom')]
+ [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'),
+ [string] $IndexPath,
+ [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot,
+ [switch] $Samples
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1')
+. (Join-Path $PSScriptRoot 'Bounded-Results.ps1')
+
+$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot
+if (-not $IndexPath) {
+ $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json'
+}
+if ($Paths.Count -gt $MaxArticles) {
+ throw "Paths count $($Paths.Count) exceeds MaxArticles=$MaxArticles. Split the worklist into stable chunks of at most $MaxArticles exact paths."
+}
+if ($null -eq $EnabledLayers) {
+ throw 'EnabledLayers must be an array.'
+}
+if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) {
+ throw 'EnabledLayers must contain unique canonical lowercase layer names.'
+}
+
+$recovery = "Run Entry preparation once before dispatch, or use READ's bounded native-file fallback. Do not rebuild in a leaf."
+$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery
+$index = $preparedIndex.index
+$byPath = $preparedIndex.byPath
+$unrestricted = $index.enabledLayers.Count -eq 0 -or
+ ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0])
+$indexedLayers = @(
+ if ($unrestricted) { 'microsoft', 'community', 'custom' } else { $index.enabledLayers }
+)
+if (@($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) {
+ throw "Index layer coverage does not cover EnabledLayers. $recovery"
+}
+
+$resolved = [Collections.Generic.List[string]]::new()
+$records = [Collections.Generic.List[object]]::new()
+$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+$articleTexts = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal)
+$sampleContents = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+foreach ($path in $Paths) {
+ $kind = if ($Samples) { 'sample' } else { 'article' }
+ $fullPath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path -Kind $kind
+ if ($path.Split('/')[0] -cnotin $EnabledLayers) {
+ throw "Layer disabled for path: $path"
+ }
+ if (-not $seen.Add($path)) {
+ throw "Duplicate requested path: $path"
+ }
+ if ($Samples) {
+ $articlePath = $path -replace '\.(good|bad)\.[a-z0-9]+$', '.md'
+ if (-not $byPath.ContainsKey($articlePath)) {
+ throw "Sample article is absent from the prepared index: $articlePath"
+ }
+ $fullArticlePath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $articlePath
+ if (-not $articleTexts.ContainsKey($articlePath)) {
+ $articleContent = Read-KnowledgeText -Path $fullArticlePath
+ if ($articleContent.sha256 -cne $byPath[$articlePath].sourceSha256) {
+ throw "Selected article hash does not match the prepared index: $articlePath"
+ }
+ $articleTexts.Add($articlePath, $articleContent.text)
+ }
+ Assert-SampleLink -ArticleText $articleTexts[$articlePath] -SamplePath $fullPath
+ $sampleContent = Read-KnowledgeText -Path $fullPath
+ $sampleContents.Add($path, $sampleContent)
+ $records.Add([ordered]@{
+ path = $path
+ articlePath = $articlePath
+ articleSha256 = $byPath[$articlePath].sourceSha256
+ sampleSha256 = $sampleContent.sha256
+ sampleBytes = $sampleContent.bytes
+ })
+ }
+ else {
+ if (-not $byPath.ContainsKey($path)) {
+ throw "Selected article is absent from the prepared index: $path"
+ }
+ $records.Add([ordered]@{
+ path = $path
+ expectedSha256 = $byPath[$path].sourceSha256
+ })
+ }
+ $resolved.Add($fullPath)
+}
+
+$requestSnapshot = Get-ResultSnapshot -Value ([ordered]@{
+ root = $BCQualityRoot
+ preparedIndexSha256 = $preparedIndex.content.sha256
+ kind = if ($Samples) { 'samples' } else { 'articles' }
+ enabledLayers = @($EnabledLayers)
+ files = @($records)
+})
+if ($Snapshot -and $Snapshot -cne $requestSnapshot) {
+ throw 'Article snapshot changed or continuation belongs to another exact path batch. Discard partial results and restart.'
+}
+
+$articles = [Collections.Generic.List[object]]::new()
+function ConvertTo-BatchJson {
+ param([int] $ReadCount)
+
+ $remaining = @(
+ if ($ReadCount -lt $Paths.Count) {
+ $Paths[$ReadCount..($Paths.Count - 1)]
+ }
+ )
+ $remainingRecords = @(
+ if ($ReadCount -lt $records.Count) {
+ $records[$ReadCount..($records.Count - 1)]
+ }
+ )
+ $continuation = if ($remaining.Count) {
+ [ordered]@{
+ snapshot = Get-ResultSnapshot -Value ([ordered]@{
+ root = $BCQualityRoot
+ preparedIndexSha256 = $preparedIndex.content.sha256
+ kind = if ($Samples) { 'samples' } else { 'articles' }
+ enabledLayers = @($EnabledLayers)
+ files = $remainingRecords
+ })
+ }
+ }
+ else {
+ $null
+ }
+ return [ordered]@{
+ version = 1
+ kind = if ($Samples) { 'samples' } else { 'articles' }
+ snapshot = $requestSnapshot
+ requestedCount = $Paths.Count
+ returnedCount = $ReadCount
+ complete = ($ReadCount -eq $Paths.Count)
+ articles = @($articles)
+ remainingPaths = $remaining
+ continuation = $continuation
+ } | ConvertTo-Json -Depth 8 -Compress
+}
+
+$json = ''
+for ($i = 0; $i -lt [Math]::Min($MaxArticles, $Paths.Count); $i++) {
+ $content = if ($Samples) {
+ $sampleContents[$Paths[$i]]
+ }
+ else {
+ Read-KnowledgeText -Path $resolved[$i]
+ }
+ if (-not $Samples -and $content.sha256 -cne $records[$i].expectedSha256) {
+ throw "Selected article hash does not match the prepared index: $($Paths[$i])"
+ }
+ $articles.Add([ordered]@{
+ path = $Paths[$i]
+ bytes = $content.bytes
+ sha256 = $content.sha256
+ body = $content.text
+ })
+ $next = ConvertTo-BatchJson -ReadCount ($i + 1)
+ if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) {
+ $articles.RemoveAt($articles.Count - 1)
+ if ($i -eq 0) {
+ throw "No complete body plus continuation fits MaxBytes=$MaxBytes at $($Paths[$i]). Use a smaller exact path batch or READ's bounded native-file fallback; never truncate."
+ }
+ break
+ }
+ $json = $next
+}
+
+$json
diff --git a/tools/Knowledge-Retrieval.ps1 b/tools/Knowledge-Retrieval.ps1
new file mode 100644
index 0000000..7007868
--- /dev/null
+++ b/tools/Knowledge-Retrieval.ps1
@@ -0,0 +1,298 @@
+# Shared filesystem guards for catalog and exact article retrieval.
+Set-StrictMode -Version Latest
+
+function Resolve-KnowledgeRoot {
+ param([string] $Root)
+
+ $item = Get-Item -LiteralPath $Root -Force -ErrorAction Stop
+ if ($item.PSProvider.Name -ne 'FileSystem' -or -not $item.PSIsContainer) {
+ throw "BCQuality root must be a filesystem directory: $Root"
+ }
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw "Linked BCQuality roots are not supported: $Root"
+ }
+ return $item.FullName
+}
+
+function Assert-KnowledgePath {
+ param(
+ [string] $Path,
+ [ValidateSet('article', 'sample')] [string] $Kind = 'article'
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Path) -or
+ $Path -cnotmatch '^(microsoft|community|custom)/knowledge/[^/]+/.+' -or
+ $Path -match '[\\:*?"<>|\x00-\x1f]' -or
+ @($Path.Split('/') | Where-Object { $_ -in '', '.', '..' -or $_ -match '[. ]$' }).Count) {
+ throw "Invalid knowledge path: $Path"
+ }
+ if (($Kind -eq 'article' -and -not $Path.EndsWith('.md', [StringComparison]::Ordinal)) -or
+ ($Kind -eq 'sample' -and $Path -cnotmatch '\.(good|bad)\.[a-z0-9]+$')) {
+ throw "Expected an exact $Kind path: $Path"
+ }
+}
+
+function Resolve-KnowledgePath {
+ param(
+ [string] $Root,
+ [string] $Path,
+ [ValidateSet('article', 'sample')] [string] $Kind = 'article'
+ )
+
+ Assert-KnowledgePath -Path $Path -Kind $Kind
+ $current = $Root
+ foreach ($part in $Path.Split('/')) {
+ $items = @(
+ Get-ChildItem -LiteralPath $current -Filter $part -Force -ErrorAction Stop |
+ Where-Object Name -CEQ $part
+ )
+ if ($items.Count -ne 1) {
+ throw "Knowledge path does not exist with exact casing: $Path"
+ }
+ $item = $items[0]
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw "Linked knowledge paths are not supported: $Path"
+ }
+ $current = $item.FullName
+ }
+ if ($item.PSIsContainer) {
+ throw "Knowledge path is not a file: $Path"
+ }
+ return $item.FullName
+}
+
+function Read-KnowledgeText {
+ param([string] $Path)
+
+ $bytes = [IO.File]::ReadAllBytes($Path)
+ try {
+ $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes)
+ }
+ catch {
+ throw "Knowledge file is not valid strict UTF-8: $Path"
+ }
+ return [pscustomobject]@{
+ text = $text
+ bytes = $bytes.Length
+ sha256 = [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($bytes)
+ ).ToLowerInvariant()
+ }
+}
+
+function Get-NormalizedKnowledgeVersions {
+ param([string[]] $Values)
+
+ foreach ($value in $Values) {
+ if ($value -match '^"([^"]*)"$' -or $value -match "^'([^']*)'$") {
+ $Matches[1]
+ }
+ else {
+ $value
+ }
+ }
+}
+
+function Get-KnowledgeMetadataProblem {
+ param([Collections.IDictionary] $Row)
+
+ if ($Row['parsed'] -isnot [bool] -or -not $Row['parsed']) {
+ return 'unparsed frontmatter'
+ }
+ if ($Row['domain'] -isnot [string] -or
+ $Row['domain'] -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$') {
+ return 'missing/invalid domain'
+ }
+ foreach ($field in @('bc-version', 'technologies', 'countries', 'application-area', 'keywords')) {
+ if ($Row[$field] -isnot [array] -or -not $Row[$field].Count) {
+ return "missing/invalid $field"
+ }
+ foreach ($value in $Row[$field]) {
+ if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace($value)) {
+ return "invalid $field value"
+ }
+ }
+ }
+ foreach ($field in @('title', 'description')) {
+ if ($Row[$field] -isnot [string] -or
+ [string]::IsNullOrWhiteSpace($Row[$field]) -or
+ $Row[$field] -match '[\r\n]') {
+ return "missing/invalid $field"
+ }
+ }
+
+ $versions = @(Get-NormalizedKnowledgeVersions -Values $Row['bc-version'])
+ if ($versions -ccontains 'all') {
+ if ($versions.Count -ne 1) {
+ return 'mixed bc-version sentinel'
+ }
+ }
+ elseif ($versions.Count -eq 1 -and $versions[0] -match '^(\d+)\.\.(\d+)?$') {
+ $start = [bigint]::Parse($Matches[1])
+ if ($start -le 0 -or ($Matches[2] -and [bigint]::Parse($Matches[2]) -le 0)) {
+ return 'invalid bc-version range bound'
+ }
+ if ($Matches[2] -and $start -gt [bigint]::Parse($Matches[2])) {
+ return 'descending bc-version range'
+ }
+ }
+ else {
+ foreach ($version in $versions) {
+ if ($version -notmatch '^\d+$' -or [bigint]::Parse($version) -le 0) {
+ return 'invalid bc-version'
+ }
+ }
+ }
+ if (@($Row.technologies | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) {
+ return 'invalid technologies'
+ }
+ if ($Row.technologies -ccontains 'all') {
+ return 'invalid technologies sentinel'
+ }
+ if ($Row.countries -ccontains 'w1') {
+ if ($Row.countries.Count -ne 1) {
+ return 'mixed countries sentinel'
+ }
+ }
+ elseif (@($Row.countries | Where-Object { $_ -cnotmatch '^[a-z]{2}$' }).Count) {
+ return 'invalid countries'
+ }
+ if (@($Row['application-area'] | Where-Object { $_ -cnotmatch '^(all|[a-z0-9]+(-[a-z0-9]+)*)$' }).Count) {
+ return 'invalid application-area'
+ }
+ if ($Row['application-area'] -ccontains 'all' -and $Row['application-area'].Count -ne 1) {
+ return 'mixed application-area sentinel'
+ }
+ if (@($Row.keywords | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) {
+ return 'invalid keywords'
+ }
+ return ''
+}
+
+function Get-PreparedManifestSha256 {
+ param(
+ [string[]] $Paths,
+ [Collections.Generic.Dictionary[string, object]] $ByPath
+ )
+
+ $hash = [Security.Cryptography.IncrementalHash]::CreateHash(
+ [Security.Cryptography.HashAlgorithmName]::SHA256
+ )
+ try {
+ $hash.AppendData([byte[]][char]'[')
+ for ($i = 0; $i -lt $Paths.Count; $i++) {
+ if ($i) {
+ $hash.AppendData([byte[]][char]',')
+ }
+ $row = [ordered]@{
+ path = $Paths[$i]
+ sha256 = $ByPath[$Paths[$i]].sourceSha256
+ }
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $row -Depth 8 -Compress)
+ ))
+ }
+ $hash.AppendData([byte[]][char]']')
+ return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant()
+ }
+ finally {
+ $hash.Dispose()
+ }
+}
+
+function Read-PreparedKnowledgeIndex {
+ param(
+ [string] $IndexPath,
+ [string] $Recovery
+ )
+
+ if (-not (Test-Path -LiteralPath $IndexPath -PathType Leaf)) {
+ throw "Knowledge index missing: $IndexPath. $Recovery"
+ }
+ $indexItem = Get-Item -LiteralPath $IndexPath -Force -ErrorAction Stop
+ if ($indexItem.PSProvider.Name -ne 'FileSystem' -or
+ ($indexItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
+ throw "Knowledge index must be an unlinked filesystem file: $IndexPath. $Recovery"
+ }
+
+ $content = Read-KnowledgeText -Path $indexItem.FullName
+ try {
+ $index = $content.text.TrimStart([char]0xfeff) |
+ ConvertFrom-Json -AsHashtable -ErrorAction Stop
+ }
+ catch {
+ throw "Malformed knowledge index JSON: $($_.Exception.Message). $Recovery"
+ }
+ if ($index -isnot [Collections.IDictionary] -or
+ $index.version -ne 1 -or
+ $index.articles -isnot [array] -or
+ $index.articleCount -ne $index.articles.Count -or
+ $index.enabledLayers -isnot [array] -or
+ $index.knowledgeAllow -isnot [array] -or
+ $index.knowledgeDeny -isnot [array] -or
+ $index.sourceSnapshot -isnot [string] -or
+ $index.sourceSnapshot -cnotmatch '^[a-f0-9]{64}$') {
+ throw "Invalid knowledge index envelope. $Recovery"
+ }
+
+ $generatedAt = [DateTimeOffset]::MinValue
+ if ($index.generatedAt -is [DateTime]) {
+ $generatedAt = [DateTimeOffset]$index.generatedAt
+ }
+ elseif (-not [DateTimeOffset]::TryParse(
+ [string]$index.generatedAt,
+ [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::RoundtripKind,
+ [ref]$generatedAt
+ )) {
+ throw "Invalid knowledge index generatedAt. $Recovery"
+ }
+ if ($generatedAt -gt [DateTimeOffset]::UtcNow.AddMinutes(1)) {
+ throw "Invalid knowledge index generatedAt. $Recovery"
+ }
+
+ $byPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
+ foreach ($row in $index.articles) {
+ if ($row -isnot [Collections.IDictionary] -or $row.path -isnot [string]) {
+ throw "Index row has no exact path. $Recovery"
+ }
+ Assert-KnowledgePath -Path $row.path
+ if ($row.layer -cne $row.path.Split('/')[0] -or
+ $row.layer -cnotin @('microsoft', 'community', 'custom')) {
+ throw "Invalid index layer: $($row.path). $Recovery"
+ }
+ if ($row.sourceSha256 -isnot [string] -or
+ $row.sourceSha256 -cnotmatch '^[a-f0-9]{64}$') {
+ throw "Invalid source hash in knowledge index: $($row.path). $Recovery"
+ }
+ if (-not $byPath.TryAdd($row.path, $row)) {
+ throw "Duplicate index path: $($row.path). $Recovery"
+ }
+ }
+
+ $paths = [string[]]@($byPath.Keys)
+ [Array]::Sort($paths, [StringComparer]::Ordinal)
+ if ((Get-PreparedManifestSha256 -Paths $paths -ByPath $byPath) -cne $index.sourceSnapshot) {
+ throw "Stale or internally inconsistent prepared index snapshot. $Recovery"
+ }
+
+ return [pscustomobject]@{
+ index = $index
+ content = $content
+ byPath = $byPath
+ paths = $paths
+ }
+}
+
+function Assert-SampleLink {
+ param(
+ [string] $ArticleText,
+ [string] $SamplePath
+ )
+
+ $sampleName = [IO.Path]::GetFileName($SamplePath)
+ $expected = '[`' + $sampleName + '`](' + $sampleName + ')'
+ if (-not $ArticleText.Contains($expected, [StringComparison]::Ordinal)) {
+ throw "Sample is not linked by its article using the READ convention: $sampleName"
+ }
+}
diff --git a/tools/Search-Knowledge.ps1 b/tools/Search-Knowledge.ps1
new file mode 100644
index 0000000..ade280e
--- /dev/null
+++ b/tools/Search-Knowledge.ps1
@@ -0,0 +1,244 @@
+<#
+.SYNOPSIS
+ Returns bounded pages of every domain/layer/READ-applicable catalog row.
+.DESCRIPTION
+ Consumes Entry's prepared index read-only. Results are never ranked, sampled,
+ top-k limited, deduplicated by basename, or narrowed by query text. Omit an
+ unknown task dimension; an explicit empty array is a known empty set.
+#>
+#requires -Version 7.2
+[CmdletBinding()]
+param(
+ [ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent),
+ [Parameter(Mandatory)] [ValidateNotNullOrEmpty()]
+ [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] [string] $Domain,
+ [ValidateSet('microsoft', 'community', 'custom')]
+ [AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'),
+ [ValidateRange(1, 2147483647)] [int] $BCVersion,
+ [AllowEmptyCollection()] [string[]] $Technologies,
+ [AllowEmptyCollection()] [string[]] $Countries,
+ [AllowEmptyCollection()] [string[]] $ApplicationAreas,
+ [switch] $ExcludeConditional,
+ [string] $IndexPath,
+ [ValidateRange(1024, 16000)] [int] $MaxBytes = 16000,
+ [ValidateRange(0, 2147483647)] [int] $Offset = 0,
+ [ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1')
+. (Join-Path $PSScriptRoot 'Bounded-Results.ps1')
+
+$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot
+if (-not $IndexPath) {
+ $IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json'
+}
+if ($null -eq $EnabledLayers) {
+ throw 'EnabledLayers must be an array; use an empty array to disable all layers.'
+}
+if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) {
+ throw 'EnabledLayers must contain unique canonical lowercase layer names.'
+}
+
+$context = [ordered]@{}
+foreach ($pair in @(
+ @('BCVersion', 'bc-version'),
+ @('Technologies', 'technologies'),
+ @('Countries', 'countries'),
+ @('ApplicationAreas', 'application-area')
+)) {
+ if (-not $PSBoundParameters.ContainsKey($pair[0])) {
+ continue
+ }
+ $value = $PSBoundParameters[$pair[0]]
+ if ($null -eq $value) {
+ throw "Omit unknown context; do not pass null for $($pair[0])."
+ }
+ if ($pair[0] -ne 'BCVersion') {
+ foreach ($entry in $value) {
+ if ([string]::IsNullOrWhiteSpace($entry) -or $entry -cne $entry.Trim()) {
+ throw "Invalid context value for $($pair[0]): '$entry'"
+ }
+ }
+ }
+ $context[$pair[1]] = $value
+}
+if ($context.Contains('technologies') -and $context['technologies'] -ccontains 'all') {
+ throw "Technologies has no 'all' sentinel. Omit unknown context."
+}
+
+$recovery = "Run Entry preparation once before dispatch, or use READ's path-discovery fallback. Do not rebuild in a leaf."
+$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery
+$index = $preparedIndex.index
+$indexContent = $preparedIndex.content
+$byPath = $preparedIndex.byPath
+$paths = $preparedIndex.paths
+
+# The v1 generator historically serialized an omitted EnabledLayers parameter as [null].
+$unrestricted = $index.enabledLayers.Count -eq 0 -or
+ ($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0])
+$indexedLayers = @(
+ if ($unrestricted) {
+ 'microsoft', 'community', 'custom'
+ }
+ else {
+ $index.enabledLayers
+ }
+)
+if (@($indexedLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
+ @($indexedLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count -or
+ @($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) {
+ throw "Index layer coverage does not cover EnabledLayers. $recovery"
+}
+
+foreach ($path in $paths) {
+ $row = $byPath[$path]
+ if ($row.layer -cnotin $indexedLayers) {
+ throw "Index row layer is outside index coverage: $path. $recovery"
+ }
+ $problem = Get-KnowledgeMetadataProblem -Row $row
+ if ($problem) {
+ throw "Malformed knowledge index row at ${path}: $problem. $recovery"
+ }
+}
+
+$defaults = [ordered]@{
+ 'bc-version' = @('all')
+ technologies = @('al')
+ countries = @('w1')
+ 'application-area' = @('all')
+}
+$candidates = [Collections.Generic.List[object]]::new()
+$excluded = [Collections.Generic.List[object]]::new()
+foreach ($path in $paths) {
+ $row = $byPath[$path]
+ if ($row.domain -cne $Domain) {
+ continue
+ }
+
+ $unknown = [Collections.Generic.List[string]]::new()
+ $matchesContext = $true
+ foreach ($field in $defaults.Keys) {
+ $values = $row[$field]
+ if ($field -eq 'bc-version') {
+ $values = @(Get-NormalizedKnowledgeVersions -Values $values)
+ }
+ $sentinel = switch ($field) {
+ 'bc-version' { 'all' }
+ 'countries' { 'w1' }
+ 'application-area' { 'all' }
+ default { '' }
+ }
+ if ($sentinel -and $values -ccontains $sentinel) {
+ continue
+ }
+ if (-not $context.Contains($field)) {
+ $unknown.Add($field)
+ continue
+ }
+
+ $target = $context[$field]
+ $matched = $false
+ if ($field -eq 'bc-version') {
+ # Compare as bigint on both sides: metadata validation accepts bounds
+ # wider than Int32, and an int left operand would coerce them down.
+ $targetVersion = [bigint]$target
+ if ($values.Count -eq 1 -and $values[0] -match '^(\d+)\.\.(\d+)?$') {
+ $matched = $targetVersion -ge [bigint]::Parse($Matches[1]) -and
+ (-not $Matches[2] -or $targetVersion -le [bigint]::Parse($Matches[2]))
+ }
+ else {
+ $matched = @($values | Where-Object { [bigint]::Parse($_) -eq $targetVersion }).Count -gt 0
+ }
+ }
+ else {
+ $matched = @($values | Where-Object { $target -ccontains $_ }).Count -gt 0
+ }
+ if (-not $matched) {
+ $matchesContext = $false
+ break
+ }
+ }
+ if (-not $matchesContext -or ($ExcludeConditional -and $unknown.Count)) {
+ continue
+ }
+ $null = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path
+
+ $candidate = [ordered]@{
+ path = $path
+ layer = $row.layer
+ keywords = $row.keywords
+ title = $row.title
+ description = $row.description
+ }
+ foreach ($field in $defaults.Keys) {
+ if (($row[$field] -join "`0") -cne ($defaults[$field] -join "`0")) {
+ $candidate[$field] = $row[$field]
+ }
+ }
+ $candidate.applicability = if ($unknown.Count) { 'conditional' } else { 'applicable' }
+ $candidate.unknownDimensions = @($unknown)
+ if ($row.layer -cin $EnabledLayers) {
+ $candidates.Add($candidate)
+ }
+ else {
+ $excluded.Add($candidate)
+ }
+}
+
+$header = [ordered]@{
+ version = 2
+ domain = $Domain
+ context = $context
+ enabledLayers = @($EnabledLayers)
+ indexedLayers = @($indexedLayers)
+ excludeConditional = [bool]$ExcludeConditional
+ defaults = $defaults
+ candidateCount = $candidates.Count
+ excludedByConfigurationCount = $excluded.Count
+}
+
+function Get-CatalogSnapshot {
+ param(
+ [string] $PreparedIndexSha256,
+ [Collections.IDictionary] $Request,
+ [Collections.IDictionary] $Groups
+ )
+
+ $hash = [Security.Cryptography.IncrementalHash]::CreateHash(
+ [Security.Cryptography.HashAlgorithmName]::SHA256
+ )
+ try {
+ foreach ($value in @(
+ $PreparedIndexSha256,
+ (ConvertTo-Json -InputObject $Request -Depth 8 -Compress)
+ )) {
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes($value))
+ $hash.AppendData([byte[]](10))
+ }
+ foreach ($groupName in $Groups.Keys) {
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes("[$groupName]"))
+ $hash.AppendData([byte[]](10))
+ foreach ($row in $Groups[$groupName]) {
+ $hash.AppendData([Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $row -Depth 8 -Compress)
+ ))
+ $hash.AppendData([byte[]](10))
+ }
+ }
+ return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant()
+ }
+ finally {
+ $hash.Dispose()
+ }
+}
+
+$groups = [ordered]@{
+ candidates = $candidates
+ excludedByConfiguration = $excluded
+}
+$header.snapshot = Get-CatalogSnapshot -PreparedIndexSha256 $indexContent.sha256 -Request $header -Groups $groups
+
+ConvertTo-BoundedPage -Header $header -Groups $groups -Offset $Offset -Snapshot $Snapshot -MaxBytes $MaxBytes
diff --git a/tools/Test-KnowledgeRetrieval.ps1 b/tools/Test-KnowledgeRetrieval.ps1
new file mode 100644
index 0000000..ef65812
--- /dev/null
+++ b/tools/Test-KnowledgeRetrieval.ps1
@@ -0,0 +1,788 @@
+<#
+.SYNOPSIS
+ Validates lossless bounded catalog and exact-body retrieval.
+#>
+#requires -Version 7.2
+[CmdletBinding()]
+param(
+ [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..'))
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$Root = (Resolve-Path -LiteralPath $Root).Path
+
+$generator = Join-Path $Root 'tools/Build-KnowledgeIndex.ps1'
+$search = Join-Path $Root 'tools/Search-Knowledge.ps1'
+$getArticles = Join-Path $Root 'tools/Get-KnowledgeArticles.ps1'
+$utf8 = [Text.UTF8Encoding]::new($false, $true)
+
+function Assert-True {
+ param([bool] $Condition, [string] $Message)
+ if (-not $Condition) {
+ throw "Assertion failed: $Message"
+ }
+}
+
+function Assert-Equal {
+ param($Actual, $Expected, [string] $Message)
+ if ($Actual -cne $Expected) {
+ throw "Assertion failed: $Message. Expected '$Expected', got '$Actual'."
+ }
+}
+
+function Assert-Sequence {
+ param($Actual, $Expected, [string] $Message)
+ $actualJson = ConvertTo-Json -InputObject @($Actual) -Compress
+ $expectedJson = ConvertTo-Json -InputObject @($Expected) -Compress
+ if ($actualJson -cne $expectedJson) {
+ throw "Assertion failed: $Message. Expected $expectedJson, got $actualJson."
+ }
+}
+
+function Assert-Throws {
+ param([scriptblock] $Action, [string] $Pattern, [string] $Message)
+ try {
+ & $Action
+ }
+ catch {
+ if ($_.Exception.Message -notmatch $Pattern) {
+ throw "Assertion failed: $Message. Wrong error: $($_.Exception.Message)"
+ }
+ return
+ }
+ throw "Assertion failed: $Message. No error was thrown."
+}
+
+function Get-OutputByteCount {
+ param([string] $Text)
+ return [Text.Encoding]::UTF8.GetByteCount($Text) +
+ [Text.Encoding]::UTF8.GetByteCount([Environment]::NewLine)
+}
+
+function Invoke-CatalogPages {
+ param(
+ [hashtable] $Arguments,
+ [int] $MaxBytes = 4096
+ )
+
+ $allCandidates = [Collections.Generic.List[object]]::new()
+ $allExcluded = [Collections.Generic.List[object]]::new()
+ $offset = 0
+ $snapshot = ''
+ $shared = ''
+ $pageCount = 0
+ $lastPage = $null
+ do {
+ $pageArguments = @{} + $Arguments
+ $pageArguments.MaxBytes = $MaxBytes
+ $pageArguments.Offset = $offset
+ if ($snapshot) {
+ $pageArguments.Snapshot = $snapshot
+ }
+ $raw = & $search @pageArguments
+ Assert-True ($raw -is [string]) 'catalog helper emitted exactly one JSON string'
+ Assert-True ((Get-OutputByteCount -Text $raw) -le $MaxBytes) 'catalog page includes its newline in MaxBytes'
+ $page = $raw | ConvertFrom-Json
+ $pageCount++
+ Assert-True ($pageCount -le 1000) 'catalog continuation terminates'
+ Assert-Equal $page.offset $offset 'catalog offset is exact'
+ Assert-Equal $page.returnedCount (@($page.candidates).Count + @($page.excludedByConfiguration).Count) 'page returnedCount matches rows'
+ Assert-Equal $page.remainingCount ($page.totalCount - $offset - $page.returnedCount) 'page remainingCount is exact'
+
+ $currentShared = [ordered]@{
+ version = $page.version
+ domain = $page.domain
+ context = $page.context
+ enabledLayers = $page.enabledLayers
+ indexedLayers = $page.indexedLayers
+ excludeConditional = $page.excludeConditional
+ defaults = $page.defaults
+ candidateCount = $page.candidateCount
+ excludedByConfigurationCount = $page.excludedByConfigurationCount
+ snapshot = $page.snapshot
+ totalCount = $page.totalCount
+ } | ConvertTo-Json -Depth 8 -Compress
+ if (-not $shared) {
+ $shared = $currentShared
+ $snapshot = $page.snapshot
+ }
+ else {
+ Assert-Equal $currentShared $shared 'catalog pages repeat shared context, defaults, totals, and snapshot'
+ }
+
+ foreach ($row in @($page.candidates)) {
+ $allCandidates.Add($row)
+ }
+ foreach ($row in @($page.excludedByConfiguration)) {
+ $allExcluded.Add($row)
+ }
+ if (-not $page.complete) {
+ Assert-True ($null -ne $page.continuation) 'incomplete page has continuation'
+ Assert-Equal $page.continuation.snapshot $snapshot 'continuation is snapshot-bound'
+ Assert-True ($page.continuation.offset -gt $offset) 'continuation makes progress'
+ $offset = $page.continuation.offset
+ }
+ $lastPage = $page
+ } while (-not $page.complete)
+
+ Assert-True ($null -eq $lastPage.continuation) 'final page has no continuation'
+ Assert-Equal $allCandidates.Count $lastPage.candidateCount 'candidate total survives paging'
+ Assert-Equal $allExcluded.Count $lastPage.excludedByConfigurationCount 'excluded total survives paging'
+ return [pscustomobject]@{
+ candidates = @($allCandidates)
+ excluded = @($allExcluded)
+ pages = $pageCount
+ snapshot = $snapshot
+ lastPage = $lastPage
+ }
+}
+
+function Test-BodyRoundTrip {
+ param(
+ [string[]] $Paths,
+ [string] $IndexPath,
+ [switch] $Samples
+ )
+
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ for ($start = 0; $start -lt $Paths.Count; $start += 8) {
+ $end = [Math]::Min($start + 7, $Paths.Count - 1)
+ $remaining = @($Paths[$start..$end])
+ $snapshot = ''
+ do {
+ $arguments = @{
+ BCQualityRoot = $Root
+ IndexPath = $IndexPath
+ Paths = $remaining
+ MaxArticles = 8
+ MaxBytes = 16000
+ }
+ if ($Samples) {
+ $arguments.Samples = $true
+ }
+ if ($snapshot) {
+ $arguments.Snapshot = $snapshot
+ }
+ $raw = & $getArticles @arguments
+ Assert-True ($raw -is [string]) 'article helper emitted exactly one JSON string'
+ Assert-True ((Get-OutputByteCount -Text $raw) -le 16000) 'article batch includes its newline in MaxBytes'
+ $batch = $raw | ConvertFrom-Json
+ Assert-True ($batch.returnedCount -gt 0) 'article batching makes progress'
+ Assert-Equal $batch.returnedCount @($batch.articles).Count 'article returnedCount matches rows'
+ Assert-Equal $batch.complete (@($batch.remainingPaths).Count -eq 0) 'article completion matches remaining paths'
+ if ($batch.complete) {
+ Assert-True ($null -eq $batch.continuation) 'complete article batch has no continuation'
+ }
+ else {
+ Assert-True ($batch.continuation.snapshot -match '^[a-f0-9]{64}$') 'article continuation is snapshot-bound'
+ }
+
+ foreach ($article in @($batch.articles)) {
+ Assert-True ($seen.Add($article.path)) "body returned once: $($article.path)"
+ $fullPath = Join-Path $Root ($article.path.Replace('/', [IO.Path]::DirectorySeparatorChar))
+ $bytes = [IO.File]::ReadAllBytes($fullPath)
+ $text = $utf8.GetString($bytes)
+ $hash = [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($bytes)
+ ).ToLowerInvariant()
+ Assert-Equal $article.bytes $bytes.Length "byte count round-trips: $($article.path)"
+ Assert-Equal $article.sha256 $hash "SHA-256 round-trips: $($article.path)"
+ Assert-Equal $article.body $text "body round-trips: $($article.path)"
+ }
+ $remaining = @($batch.remainingPaths)
+ $snapshot = if ($batch.complete) { '' } else { $batch.continuation.snapshot }
+ } while ($remaining.Count)
+ }
+ Assert-Equal $seen.Count $Paths.Count 'every requested body round-trips without loss'
+}
+
+function New-NeutralArticle {
+ param(
+ [string] $FixtureRoot,
+ [string] $Layer,
+ [string] $Slug,
+ [string] $Version = 'all',
+ [string] $Technology = 'al',
+ [string] $Country = 'w1',
+ [string] $Area = 'all',
+ [string] $Title = 'Neutral retrieval example',
+ [string] $Description = 'Neutral retrieval metadata for deterministic tests.'
+ )
+
+ $directory = Join-Path $FixtureRoot "$Layer\knowledge\neutral"
+ New-Item -ItemType Directory -Force -Path $directory | Out-Null
+ $content = @"
+---
+bc-version: [$Version]
+domain: neutral
+keywords: [neutral, retrieval, deterministic]
+technologies: [$Technology]
+countries: [$Country]
+application-area: [$Area]
+---
+
+# $Title
+
+## Description
+
+$Description
+"@
+ Set-Content -LiteralPath (Join-Path $directory "$Slug.md") -Value $content -Encoding utf8NoBOM
+}
+
+function Test-InvalidSourceIndexing {
+ param(
+ [string] $FixtureRoot,
+ [string] $Field,
+ [string] $ValidValue,
+ [string] $InvalidValue
+ )
+
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source
+ $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md'
+ $text = [IO.File]::ReadAllText($articlePath, $utf8)
+ $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue")
+ [IO.File]::WriteAllText($articlePath, $text, $utf8)
+
+ $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$Field-index.json")
+ $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1)
+ $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] })
+ Assert-Equal $warnings.Count 1 "scalar $Field source emits one omission warning"
+ Assert-True (
+ $warnings[0].Message -match
+ "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source\.md': frontmatter field '$([regex]::Escape($Field))' must use non-empty bracket-array syntax\."
+ ) "scalar $Field warning identifies the exact path and reason"
+ $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ Assert-Equal $prepared.articleCount 1 "scalar $Field source is omitted while its valid sibling is indexed"
+ Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field index contains only the valid sibling"
+ $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral |
+ ConvertFrom-Json
+ Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "scalar $Field catalog retrieves the valid sibling"
+ $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'microsoft/knowledge/neutral/valid-source.md' |
+ ConvertFrom-Json
+ Assert-True $valid.complete "scalar $Field valid sibling body retrieves completely"
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'community/knowledge/neutral/invalid-source.md'
+ } 'Selected article is absent from the prepared index' "scalar $Field omitted source cannot be retrieved"
+}
+
+function Test-InvalidSemanticIndexing {
+ param(
+ [string] $FixtureRoot,
+ [string] $CaseName,
+ [string] $Field,
+ [string] $ValidValue,
+ [string] $InvalidValue,
+ [string] $ExpectedReason
+ )
+
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer microsoft -Slug valid-source
+ New-NeutralArticle -FixtureRoot $FixtureRoot -Layer community -Slug invalid-source
+ $articlePath = Join-Path $FixtureRoot 'community\knowledge\neutral\invalid-source.md'
+ $text = [IO.File]::ReadAllText($articlePath, $utf8)
+ $text = $text.Replace("$Field`: $ValidValue", "$Field`: $InvalidValue")
+ [IO.File]::WriteAllText($articlePath, $text, $utf8)
+
+ $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("$CaseName-index.json")
+ $generation = @(& $generator -BCQualityRoot $FixtureRoot -IndexPath $indexPath 3>&1)
+ $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] })
+ Assert-Equal $warnings.Count 1 "$CaseName emits one omission warning"
+ Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-source.md': $ExpectedReason." "$CaseName warning identifies exact path and reason"
+
+ $prepared = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ Assert-Equal $prepared.articleCount 1 "$CaseName omits invalid source and retains valid sibling"
+ Assert-Sequence $prepared.articles.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName index contains only valid sibling"
+ Assert-True ($prepared.sourceSnapshot -match '^[a-f0-9]{64}$') "$CaseName source snapshot remains valid"
+ $validRow = $prepared.articles[0]
+ $manifest = @(
+ [ordered]@{ path = $validRow.path; sha256 = $validRow.sourceSha256 }
+ )
+ $manifestBytes = [Text.Encoding]::UTF8.GetBytes(
+ (ConvertTo-Json -InputObject $manifest -Depth 8 -Compress)
+ )
+ $expectedSnapshot = [Convert]::ToHexString(
+ [Security.Cryptography.SHA256]::HashData($manifestBytes)
+ ).ToLowerInvariant()
+ Assert-Equal $prepared.sourceSnapshot $expectedSnapshot "$CaseName source snapshot covers only retained rows"
+
+ $catalog = & $search -BCQualityRoot $FixtureRoot -IndexPath $indexPath -Domain neutral |
+ ConvertFrom-Json
+ Assert-Sequence $catalog.candidates.path @('microsoft/knowledge/neutral/valid-source.md') "$CaseName catalog retains valid sibling"
+ $valid = & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'microsoft/knowledge/neutral/valid-source.md' |
+ ConvertFrom-Json
+ Assert-True $valid.complete "$CaseName valid sibling body retrieves"
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $FixtureRoot -IndexPath $indexPath `
+ -Paths 'community/knowledge/neutral/invalid-source.md'
+ } 'Selected article is absent from the prepared index' "$CaseName invalid source cannot be retrieved"
+}
+
+function Test-InvalidEnabledLayers {
+ param(
+ [string] $FixtureRoot,
+ [string] $CaseName,
+ $Layers,
+ [string] $ExpectedPattern
+ )
+
+ $indexPath = Join-Path (Split-Path $FixtureRoot -Parent) ("layers-$CaseName.json")
+ $arguments = @{
+ BCQualityRoot = $FixtureRoot
+ IndexPath = $indexPath
+ EnabledLayers = $Layers
+ }
+ Assert-Throws {
+ & $generator @arguments
+ } $ExpectedPattern "$CaseName EnabledLayers fails"
+ Assert-True (-not (Test-Path -LiteralPath $indexPath)) "$CaseName fails before index creation"
+}
+
+$tmp = Join-Path ([IO.Path]::GetTempPath()) ("bcquality_retrieval_" + [guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Force -Path $tmp | Out-Null
+try {
+ $indexPath = Join-Path $tmp 'knowledge-index.json'
+ & $generator -BCQualityRoot $Root -IndexPath $indexPath | Out-Null
+ $index = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $diskArticlePaths = @(
+ foreach ($layer in 'microsoft', 'community', 'custom') {
+ $knowledge = Join-Path $Root "$layer\knowledge"
+ if (Test-Path -LiteralPath $knowledge) {
+ Get-ChildItem -LiteralPath $knowledge -Recurse -File -Filter '*.md' |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/')
+ }
+ }
+ }
+ ) | Sort-Object
+ Assert-Equal $index.articleCount $diskArticlePaths.Count 'index covers every current article'
+ Assert-True ($index.sourceSnapshot -match '^[a-f0-9]{64}$') 'index carries an exact source snapshot'
+
+ $allCatalogRows = [Collections.Generic.List[object]]::new()
+ $domains = @($index.articles.domain | Sort-Object -Unique)
+ foreach ($domain in $domains) {
+ $catalog = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $Root
+ IndexPath = $indexPath
+ Domain = $domain
+ }
+ Assert-Equal $catalog.excluded.Count 0 "all layers enabled for $domain"
+ foreach ($row in $catalog.candidates) {
+ $allCatalogRows.Add($row)
+ }
+ }
+
+ $expectedRows = @($index.articles | Sort-Object path)
+ $actualRows = @($allCatalogRows | Sort-Object path)
+ Assert-Equal $actualRows.Count $expectedRows.Count 'paged union has no top-k or query-based loss'
+ Assert-Sequence ($actualRows.path) ($expectedRows.path) 'paged union equals all READ-filtered candidates'
+ Assert-Equal @($actualRows.path | Sort-Object -Unique).Count $actualRows.Count 'catalog does not deduplicate distinct paths'
+
+ $defaults = [ordered]@{
+ 'bc-version' = @('all')
+ technologies = @('al')
+ countries = @('w1')
+ 'application-area' = @('all')
+ }
+ for ($i = 0; $i -lt $actualRows.Count; $i++) {
+ $actual = $actualRows[$i]
+ $expected = $expectedRows[$i]
+ Assert-Equal $actual.path $expected.path 'catalog preserves exact path'
+ Assert-Equal $actual.layer $expected.layer 'catalog preserves layer'
+ Assert-Sequence $actual.keywords $expected.keywords 'catalog preserves full keywords'
+ Assert-Equal $actual.title $expected.title 'catalog preserves title'
+ Assert-Equal $actual.description $expected.description 'catalog preserves one-line description'
+
+ $unknown = [Collections.Generic.List[string]]::new()
+ foreach ($field in $defaults.Keys) {
+ $expectedValues = @($expected.$field)
+ $sentinel = switch ($field) {
+ 'bc-version' { 'all' }
+ 'countries' { 'w1' }
+ 'application-area' { 'all' }
+ default { '' }
+ }
+ if (-not $sentinel -or $expectedValues -notcontains $sentinel) {
+ $unknown.Add($field)
+ }
+ $hasField = $actual.PSObject.Properties.Name -ccontains $field
+ if (($expectedValues -join "`0") -ceq (@($defaults[$field]) -join "`0")) {
+ Assert-True (-not $hasField) "default field is inherited from page: $field"
+ }
+ else {
+ Assert-True $hasField "non-default field survives paging: $field"
+ Assert-Sequence $actual.$field $expectedValues "non-default field is exact: $field"
+ }
+ }
+ Assert-Equal $actual.applicability ($(if ($unknown.Count) { 'conditional' } else { 'applicable' })) 'applicability verdict is explicit'
+ Assert-Sequence $actual.unknownDimensions @($unknown) 'unknown dimensions are explicit'
+ }
+
+ $performanceFirst = & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 |
+ ConvertFrom-Json
+ Assert-True (-not $performanceFirst.complete) 'large domain produces deterministic continuation'
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -MaxBytes 4096 -Offset $performanceFirst.continuation.offset
+ } 'Continuation requires Snapshot' 'continuation without snapshot fails'
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset $performanceFirst.totalCount
+ } 'Invalid Offset' 'offset at total fails'
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain performance -Offset 1 -Snapshot ('0' * 64)
+ } 'Snapshot changed' 'wrong snapshot fails'
+
+ $changedRawIndex = Join-Path $tmp 'changed-raw-index.json'
+ $changedRaw = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $changedRaw.generatedAt = [DateTimeOffset]::UtcNow.ToString('O')
+ $changedRaw | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $changedRawIndex -Encoding utf8NoBOM -NoNewline
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $changedRawIndex -Domain performance `
+ -MaxBytes 4096 -Offset $performanceFirst.continuation.offset `
+ -Snapshot $performanceFirst.continuation.snapshot
+ } 'Snapshot changed' 'continuation is bound to the exact prepared index bytes'
+
+ $malformedIndex = Join-Path $tmp 'malformed.json'
+ Set-Content -LiteralPath $malformedIndex -Value '{not-json' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $malformedIndex -Domain performance
+ } 'Malformed knowledge index JSON' 'malformed JSON fails'
+
+ $unsafeIndex = Join-Path $tmp 'unsafe.json'
+ $unsafe = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $unsafe.articles[0].path = '../outside.md'
+ $unsafe | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $unsafeIndex -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $unsafeIndex -Domain performance
+ } 'Invalid knowledge path' 'unsafe indexed path fails'
+
+ $invalidRowIndex = Join-Path $tmp 'invalid-row.json'
+ $invalidRow = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $invalidRow.articles[0].keywords = @()
+ $invalidRow | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $invalidRowIndex -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $invalidRowIndex -Domain performance
+ } 'Malformed knowledge index row' 'malformed index row fails'
+
+ $semanticCorruptIndex = Join-Path $tmp 'semantic-corrupt-row.json'
+ $semanticCorrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $semanticCorrupt.articles[0].countries = @('usa')
+ $semanticCorrupt | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $semanticCorruptIndex -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $semanticCorruptIndex -Domain performance
+ } 'Malformed knowledge index row.*invalid countries' 'search rejects semantically invalid external index rows'
+
+ $corruptSemanticCases = @(
+ @{ name = 'uppercase-all'; field = 'bc-version'; value = @('ALL'); reason = 'invalid bc-version' },
+ @{ name = 'uppercase-w1'; field = 'countries'; value = @('W1'); reason = 'invalid countries' },
+ @{ name = 'zero-open-range'; field = 'bc-version'; value = @('"0.."'); reason = 'invalid bc-version range bound' },
+ @{ name = 'zero-closed-range'; field = 'bc-version'; value = @('"0..0"'); reason = 'invalid bc-version range bound' }
+ )
+ foreach ($case in $corruptSemanticCases) {
+ $corruptPath = Join-Path $tmp ("corrupt-$($case.name).json")
+ $corrupt = Get-Content -LiteralPath $indexPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $corrupt.articles[0].PSObject.Properties[$case.field].Value = $case.value
+ $corrupt | ConvertTo-Json -Depth 8 -Compress |
+ Set-Content -LiteralPath $corruptPath -Encoding utf8NoBOM
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $corruptPath -Domain performance
+ } "Malformed knowledge index row.*$([regex]::Escape($case.reason))" "search rejects $($case.name) in an external index"
+ }
+
+ $invalidUtf8Root = Join-Path $tmp 'invalid-utf8-source'
+ New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer microsoft -Slug valid-catalog
+ New-NeutralArticle -FixtureRoot $invalidUtf8Root -Layer community -Slug invalid-utf8-source
+ $invalidUtf8Article = Join-Path $invalidUtf8Root 'community\knowledge\neutral\invalid-utf8-source.md'
+ $validBytes = [IO.File]::ReadAllBytes($invalidUtf8Article)
+ [IO.File]::WriteAllBytes($invalidUtf8Article, [byte[]]@($validBytes + @(0xc3, 0x28)))
+ $invalidUtf8Index = Join-Path $tmp 'invalid-utf8-index.json'
+ $generation = @(& $generator -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index 3>&1)
+ $warnings = @($generation | Where-Object { $_ -is [Management.Automation.WarningRecord] })
+ Assert-Equal $warnings.Count 1 'malformed UTF-8 source emits one omission warning'
+ Assert-Equal $warnings[0].Message "Skipping invalid knowledge article 'community/knowledge/neutral/invalid-utf8-source.md': invalid UTF-8." 'malformed UTF-8 warning identifies the exact path and reason'
+ $invalidUtf8Prepared = Get-Content -LiteralPath $invalidUtf8Index -Raw -Encoding utf8 |
+ ConvertFrom-Json
+ Assert-Equal $invalidUtf8Prepared.articleCount 1 'malformed UTF-8 source is omitted while its valid sibling is indexed'
+ Assert-Sequence $invalidUtf8Prepared.articles.path @('microsoft/knowledge/neutral/valid-catalog.md') 'malformed UTF-8 index contains only the valid sibling'
+ $validCatalog = & $search -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index -Domain neutral |
+ ConvertFrom-Json
+ Assert-Sequence $validCatalog.candidates.path @('microsoft/knowledge/neutral/valid-catalog.md') 'catalog retrieves the valid sibling after malformed UTF-8 omission'
+ $validBody = & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index `
+ -Paths 'microsoft/knowledge/neutral/valid-catalog.md' |
+ ConvertFrom-Json
+ Assert-True $validBody.complete 'valid sibling body retrieves after malformed UTF-8 omission'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $invalidUtf8Root -IndexPath $invalidUtf8Index `
+ -Paths 'community/knowledge/neutral/invalid-utf8-source.md'
+ } 'Selected article is absent from the prepared index' 'omitted malformed UTF-8 source cannot be retrieved'
+
+ $scalarCases = @(
+ @{ field = 'bc-version'; valid = '[all]'; invalid = 'all' },
+ @{ field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = 'neutral' },
+ @{ field = 'technologies'; valid = '[al]'; invalid = 'al' },
+ @{ field = 'countries'; valid = '[w1]'; invalid = 'w1' },
+ @{ field = 'application-area'; valid = '[all]'; invalid = 'all' }
+ )
+ foreach ($case in $scalarCases) {
+ Test-InvalidSourceIndexing -FixtureRoot (Join-Path $tmp "scalar-$($case.field)") `
+ -Field $case.field -ValidValue $case.valid -InvalidValue $case.invalid
+ }
+
+ $semanticCases = @(
+ @{ name = 'mixed-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[all, 27]'; reason = 'mixed bc-version sentinel' },
+ @{ name = 'invalid-country'; field = 'countries'; valid = '[w1]'; invalid = '[usa]'; reason = 'invalid countries' },
+ @{ name = 'descending-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["28..27"]'; reason = 'descending bc-version range' },
+ @{ name = 'malformed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '[twenty-seven]'; reason = 'invalid bc-version' },
+ @{ name = 'malformed-keyword'; field = 'keywords'; valid = '[neutral, retrieval, deterministic]'; invalid = '[neutral, Bad_Token, deterministic]'; reason = 'invalid keywords' },
+ @{ name = 'malformed-technology'; field = 'technologies'; valid = '[al]'; invalid = '[AL]'; reason = 'invalid technologies' },
+ @{ name = 'malformed-application-area'; field = 'application-area'; valid = '[all]'; invalid = '[finance_]'; reason = 'invalid application-area' },
+ @{ name = 'uppercase-version-sentinel'; field = 'bc-version'; valid = '[all]'; invalid = '[ALL]'; reason = 'invalid bc-version' },
+ @{ name = 'uppercase-country-sentinel'; field = 'countries'; valid = '[w1]'; invalid = '[W1]'; reason = 'invalid countries' },
+ @{ name = 'zero-open-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0.."]'; reason = 'invalid bc-version range bound' },
+ @{ name = 'zero-closed-version-range'; field = 'bc-version'; valid = '[all]'; invalid = '["0..0"]'; reason = 'invalid bc-version range bound' }
+ )
+ foreach ($case in $semanticCases) {
+ Test-InvalidSemanticIndexing -FixtureRoot (Join-Path $tmp "semantic-$($case.name)") `
+ -CaseName $case.name -Field $case.field -ValidValue $case.valid `
+ -InvalidValue $case.invalid -ExpectedReason $case.reason
+ }
+
+ Assert-Throws {
+ & $search -BCQualityRoot $Root -IndexPath $indexPath -Domain ('x' * 2000) -MaxBytes 1024
+ } 'Page envelope exceeds' 'oversized page envelope fails'
+
+ $articlePaths = @($index.articles.path | Sort-Object)
+ Assert-Sequence $articlePaths $diskArticlePaths 'exact article path union matches disk'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $Root -IndexPath $indexPath -Paths @($articlePaths[0..8])
+ } 'exceeds MaxArticles=8' 'exact retrieval rejects path batches larger than eight'
+ $samplePaths = @(
+ foreach ($layer in 'microsoft', 'community', 'custom') {
+ $knowledge = Join-Path $Root "$layer\knowledge"
+ if (Test-Path -LiteralPath $knowledge) {
+ Get-ChildItem -LiteralPath $knowledge -Recurse -File |
+ Where-Object Name -Match '\.(good|bad)\.[a-z0-9]+$' |
+ ForEach-Object {
+ [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/')
+ }
+ }
+ }
+ ) | Sort-Object
+ Test-BodyRoundTrip -Paths $articlePaths -IndexPath $indexPath
+ Test-BodyRoundTrip -Paths $samplePaths -IndexPath $indexPath -Samples
+
+ $fixtureRoot = Join-Path $tmp 'neutral'
+ New-NeutralArticle -FixtureRoot $fixtureRoot -Layer microsoft -Slug default
+ New-NeutralArticle -FixtureRoot $fixtureRoot -Layer community -Slug versioned -Version '"27.."' -Technology javascript -Country dk -Area finance -Title 'Versioned neutral example'
+ New-NeutralArticle -FixtureRoot $fixtureRoot -Layer custom -Slug localized -Version 28 -Technology al -Country de -Area service -Title 'Localized neutral example'
+ $fixtureIndex = Join-Path $tmp 'neutral-index.json'
+ & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null
+
+ foreach ($case in @(
+ @{ name = 'uppercase'; layers = @('Microsoft'); pattern = 'unique canonical lowercase layer names' },
+ @{ name = 'duplicate'; layers = @('microsoft', 'microsoft'); pattern = 'unique canonical lowercase layer names' },
+ @{ name = 'unknown'; layers = @('partner'); pattern = 'unique canonical lowercase layer names' },
+ @{ name = 'null'; layers = $null; pattern = 'must be an array' }
+ )) {
+ Test-InvalidEnabledLayers -FixtureRoot $fixtureRoot -CaseName $case.name `
+ -Layers $case.layers -ExpectedPattern $case.pattern
+ }
+ $subsetIndex = Join-Path $tmp 'community-only-index.json'
+ & $generator -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex `
+ -EnabledLayers @('community') | Out-Null
+ $subset = & $search -BCQualityRoot $fixtureRoot -IndexPath $subsetIndex `
+ -Domain neutral -EnabledLayers @('community') |
+ ConvertFrom-Json
+ Assert-Equal $subset.candidateCount 1 'valid EnabledLayers subset builds and is consumable'
+ Assert-Sequence $subset.candidates.path @('community/knowledge/neutral/versioned.md') 'valid subset contains only its exact layer'
+
+ $applicable = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $fixtureRoot
+ IndexPath = $fixtureIndex
+ Domain = 'neutral'
+ BCVersion = 28
+ Technologies = @('al', 'javascript')
+ Countries = @('dk', 'de')
+ ApplicationAreas = @('finance', 'service')
+ } -MaxBytes 16000
+ Assert-Equal $applicable.candidates.Count 3 'neutral layer/version rows all survive matching context'
+ Assert-True (@($applicable.candidates | Where-Object applicability -CEQ applicable).Count -eq 3) 'matching rows are applicable'
+ $versioned = $applicable.candidates | Where-Object path -CEQ 'community/knowledge/neutral/versioned.md'
+ Assert-Equal $versioned.layer community 'non-default layer survives'
+ Assert-Sequence $versioned.'bc-version' @('"27.."') 'original version metadata survives'
+ Assert-Equal $versioned.applicability applicable 'lowercase sentinels and positive open range remain applicable'
+ Assert-Sequence $versioned.technologies @('javascript') 'non-default technology survives'
+ Assert-Sequence $versioned.countries @('dk') 'non-default country survives'
+ Assert-Sequence $versioned.'application-area' @('finance') 'non-default application area survives'
+
+ # Metadata validation accepts range bounds wider than Int32, so version
+ # matching must compare as bigint rather than coercing the bound down.
+ $wideRoot = Join-Path $tmp 'wide-version'
+ New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-closed -Version '"1..99999999999"'
+ New-NeutralArticle -FixtureRoot $wideRoot -Layer microsoft -Slug wide-open -Version '"99999999999.."'
+ $wideIndex = Join-Path $tmp 'wide-version-index.json'
+ & $generator -BCQualityRoot $wideRoot -IndexPath $wideIndex | Out-Null
+ $wide = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $wideRoot
+ IndexPath = $wideIndex
+ Domain = 'neutral'
+ BCVersion = 28
+ } -MaxBytes 16000
+ Assert-Sequence $wide.candidates.path @('microsoft/knowledge/neutral/wide-closed.md') 'bc-version bounds beyond Int32 compare without overflow'
+
+ $conditional = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $fixtureRoot
+ IndexPath = $fixtureIndex
+ Domain = 'neutral'
+ BCVersion = 28
+ Technologies = @('al', 'javascript')
+ } -MaxBytes 16000
+ $conditionalVersioned = $conditional.candidates |
+ Where-Object path -CEQ 'community/knowledge/neutral/versioned.md'
+ Assert-Equal $conditionalVersioned.applicability conditional 'unknown context produces conditional verdict'
+ Assert-Sequence $conditionalVersioned.unknownDimensions @('countries', 'application-area') 'unknown dimensions survive'
+
+ $layerFiltered = Invoke-CatalogPages -Arguments @{
+ BCQualityRoot = $fixtureRoot
+ IndexPath = $fixtureIndex
+ Domain = 'neutral'
+ EnabledLayers = @('microsoft')
+ } -MaxBytes 16000
+ Assert-Equal $layerFiltered.candidates.Count 1 'enabled layer remains a candidate'
+ Assert-Equal $layerFiltered.excluded.Count 2 'disabled layers remain explicit'
+ Assert-Sequence ($layerFiltered.excluded.layer | Sort-Object) @('community', 'custom') 'excluded rows preserve layer'
+
+ $oldSnapshot = $conditional.snapshot
+ Add-Content -LiteralPath (Join-Path $fixtureRoot 'community\knowledge\neutral\versioned.md') -Value ' ' -Encoding utf8NoBOM
+ $preparedCatalog = & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral |
+ ConvertFrom-Json
+ Assert-Equal $preparedCatalog.candidateCount 3 'catalog uses the prepared index without rehashing article bodies'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex `
+ -Paths 'community/knowledge/neutral/versioned.md'
+ } 'Selected article hash does not match the prepared index' 'exact retrieval detects selected article changes'
+ & $generator -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex | Out-Null
+ Assert-Throws {
+ & $search -BCQualityRoot $fixtureRoot -IndexPath $fixtureIndex -Domain neutral -Offset 1 -Snapshot $oldSnapshot
+ } 'Snapshot changed' 'continuation cannot cross rebuilt snapshots'
+
+ $largeRoot = Join-Path $tmp 'large-catalog'
+ New-NeutralArticle -FixtureRoot $largeRoot -Layer microsoft -Slug huge-title -Title ('T' * 3000)
+ $largeIndex = Join-Path $tmp 'large-index.json'
+ & $generator -BCQualityRoot $largeRoot -IndexPath $largeIndex | Out-Null
+ Assert-Throws {
+ & $search -BCQualityRoot $largeRoot -IndexPath $largeIndex -Domain neutral -MaxBytes 1024
+ } 'One complete candidates row|Page envelope exceeds' 'oversized catalog row fails without clipping'
+
+ # The shared pager reports the oversized row's identity for any row shape;
+ # a row without a path must still reach its explicit offset-based failure.
+ . (Join-Path $Root 'tools/Bounded-Results.ps1')
+ $pagerHeader = [ordered]@{ version = 2; snapshot = ('0' * 64) }
+ foreach ($shape in @(
+ @{ name = 'dictionary'; row = [ordered]@{ blob = ('x' * 3000) } },
+ @{ name = 'object'; row = [pscustomobject]@{ blob = ('x' * 3000) } }
+ )) {
+ Assert-Throws {
+ ConvertTo-BoundedPage -Header $pagerHeader `
+ -Groups ([ordered]@{ rows = @($shape.row) }) -MaxBytes 1024
+ } 'One complete rows row plus envelope exceeds MaxBytes=1024 at Offset=0' "oversized pathless $($shape.name) row fails with its offset identity"
+ }
+
+ $bodyRoot = Join-Path $tmp 'body-failures'
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug huge-body -Description ('x' * 3000)
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug broken-link
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-one -Description ('a' * 300)
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug continuation-two -Description ('b' * 300)
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug invalid-utf8
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-one
+ New-NeutralArticle -FixtureRoot $bodyRoot -Layer microsoft -Slug sample-two
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.md') `
+ -Value '[`sample-one.good.al`](sample-one.good.al)' -Encoding utf8NoBOM
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.md') `
+ -Value '[`sample-two.good.al`](sample-two.good.al)' -Encoding utf8NoBOM
+ Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-one.good.al') `
+ -Value ('a' * 900) -Encoding utf8NoBOM
+ Set-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') `
+ -Value ('b' * 900) -Encoding utf8NoBOM
+ $bodyIndex = Join-Path $tmp 'body-index.json'
+ & $generator -BCQualityRoot $bodyRoot -IndexPath $bodyIndex | Out-Null
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/huge-body.md' -MaxBytes 1024
+ } 'No complete body plus continuation fits' 'oversized body fails without truncation'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths '../outside.md'
+ } 'Invalid knowledge path' 'unsafe requested path fails'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/huge-body.md' -EnabledLayers community
+ } 'Layer disabled' 'disabled article layer fails'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex -Paths @(
+ 'microsoft/knowledge/neutral/huge-body.md',
+ 'microsoft/knowledge/neutral/huge-body.md'
+ )
+ } 'Duplicate requested path' 'duplicate exact paths fail'
+
+ $brokenSample = Join-Path $bodyRoot 'microsoft\knowledge\neutral\broken-link.good.al'
+ Set-Content -LiteralPath $brokenSample -Value 'codeunit 1 Neutral { }' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/broken-link.good.al' -Samples
+ } 'Sample is not linked' 'unlinked sample fails'
+
+ $sampleContinuationPaths = @(
+ 'microsoft/knowledge/neutral/sample-one.good.al',
+ 'microsoft/knowledge/neutral/sample-two.good.al'
+ )
+ $firstSamplePage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths $sampleContinuationPaths -Samples -MaxBytes 1600 |
+ ConvertFrom-Json
+ Assert-True (-not $firstSamplePage.complete) 'bounded sample batch produces continuation'
+ Assert-Sequence $firstSamplePage.remainingPaths @('microsoft/knowledge/neutral/sample-two.good.al') 'sample continuation preserves pending path'
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\sample-two.good.al') `
+ -Value 'changed' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths @($firstSamplePage.remainingPaths) -Samples `
+ -Snapshot $firstSamplePage.continuation.snapshot
+ } 'Article snapshot changed' 'sample continuation rejects a changed pending sample'
+
+ $continuationPaths = @(
+ 'microsoft/knowledge/neutral/continuation-one.md',
+ 'microsoft/knowledge/neutral/continuation-two.md'
+ )
+ $firstBodyPage = & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths $continuationPaths -MaxBytes 1300 |
+ ConvertFrom-Json
+ Assert-True (-not $firstBodyPage.complete) 'bounded article batch produces continuation'
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths @($firstBodyPage.remainingPaths) -Snapshot ('0' * 64)
+ } 'Article snapshot changed' 'wrong article continuation snapshot fails'
+ Add-Content -LiteralPath (Join-Path $bodyRoot 'microsoft\knowledge\neutral\continuation-two.md') -Value 'changed' -Encoding utf8NoBOM
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths @($firstBodyPage.remainingPaths) -Snapshot $firstBodyPage.continuation.snapshot
+ } 'Selected article hash does not match the prepared index' 'article continuation rejects a changed remaining body'
+
+ $invalidUtf8 = Join-Path $bodyRoot 'microsoft\knowledge\neutral\invalid-utf8.md'
+ $indexedBytes = [IO.File]::ReadAllBytes($invalidUtf8)
+ [IO.File]::WriteAllBytes($invalidUtf8, [byte[]]@($indexedBytes + @(0xc3, 0x28)))
+ Assert-Throws {
+ & $getArticles -BCQualityRoot $bodyRoot -IndexPath $bodyIndex `
+ -Paths 'microsoft/knowledge/neutral/invalid-utf8.md'
+ } 'Knowledge file is not valid strict UTF-8' 'invalid UTF-8 fails'
+
+ Write-Host "Knowledge retrieval check PASSED: $($articlePaths.Count) articles and $($samplePaths.Count) samples round-tripped; catalog union was lossless and bounded." -ForegroundColor Green
+}
+finally {
+ Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
+}
diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1
new file mode 100644
index 0000000..1d40058
--- /dev/null
+++ b/tools/Test-ReviewContract.ps1
@@ -0,0 +1,171 @@
+<#
+.SYNOPSIS
+ Validates the bounded leaf-range normalization contract.
+
+.DESCRIPTION
+ BCQuality has no executable findings-report consumer. These assertions keep
+ the normative DO contract, AL coordinator, and standalone runner aligned
+ while exercising the exact normalization predicate against representative
+ safe and ambiguous inputs.
+#>
+[CmdletBinding()]
+param(
+ [string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..'))
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$Root = (Resolve-Path -LiteralPath $Root).Path
+
+function Assert-True {
+ param(
+ [bool] $Condition,
+ [string] $Message
+ )
+
+ if (-not $Condition) {
+ throw "Assertion failed: $Message"
+ }
+}
+
+function Assert-Contains {
+ param(
+ [string] $Text,
+ [string] $Expected,
+ [string] $Message
+ )
+
+ Assert-True $Text.Contains($Expected) $Message
+}
+
+function Test-PositiveInteger {
+ param([object] $Value)
+
+ if (($null -eq $Value) -or ($Value -is [bool]) -or ($Value -isnot [ValueType])) {
+ return $false
+ }
+
+ $number = [double]$Value
+ return [double]::IsFinite($number) -and ($number -gt 0) -and ([math]::Truncate($number) -eq $number)
+}
+
+function Test-RangeNormalizationEligibility {
+ param([pscustomobject] $Finding)
+
+ if ($Finding.PSObject.Properties.Name -contains 'suggested-code') {
+ return $false
+ }
+ if (-not ($Finding.PSObject.Properties.Name -contains 'location')) {
+ return $false
+ }
+ if (-not ($Finding.location.PSObject.Properties.Name -contains 'line')) {
+ return $false
+ }
+ if (-not ($Finding.location.PSObject.Properties.Name -contains 'range')) {
+ return $false
+ }
+
+ $range = $Finding.location.range
+ if (-not ($range.PSObject.Properties.Name -contains 'start-line') -or
+ -not ($range.PSObject.Properties.Name -contains 'end-line')) {
+ return $false
+ }
+
+ $line = $Finding.location.line
+ $startLine = $range.'start-line'
+ $endLine = $range.'end-line'
+ if (-not (Test-PositiveInteger $line) -or
+ -not (Test-PositiveInteger $startLine) -or
+ -not (Test-PositiveInteger $endLine)) {
+ return $false
+ }
+
+ return ($startLine -le $line) -and ($line -le $endLine) -and ($startLine -ne $line)
+}
+
+$transportSentence = 'Capture the exact Task return as the immutable raw audit payload and primary transport.'
+$doContract = Get-Content -LiteralPath (Join-Path $Root 'skills/do.md') -Raw
+$coordinatorContract = Get-Content -LiteralPath (Join-Path $Root 'microsoft/skills/review/al-code-review.md') -Raw
+$runnerContract = Get-Content -LiteralPath (Join-Path $Root 'docs/standalone-runner.md') -Raw
+
+foreach ($surface in @(
+ [pscustomobject]@{ Name = 'DO'; Text = ($doContract -replace '\s+', ' ') }
+ [pscustomobject]@{ Name = 'AL coordinator'; Text = ($coordinatorContract -replace '\s+', ' ') }
+ [pscustomobject]@{ Name = 'standalone runner'; Text = ($runnerContract -replace '\s+', ' ') }
+)) {
+ Assert-Contains $surface.Text $transportSentence "$($surface.Name) preserves exact Task transport wording"
+}
+
+$normalizedDoContract = $doContract -replace '\s+', ' '
+foreach ($expected in @(
+ 'positive integers',
+ 'start-line <= line <= end-line',
+ 'does not contain the `suggested-code` field',
+ 'remove only',
+ 'private run telemetry or artifacts',
+ 'Validate the entire normalized candidate',
+ 'If any other validation defect exists',
+ 'salvage arbitrary individual findings'
+)) {
+ Assert-Contains $normalizedDoContract $expected "DO documents '$expected'"
+}
+
+$cases = @(
+ [pscustomobject]@{
+ Name = 'contained mismatched range without suggested code'
+ Expected = $true
+ Finding = '{"message":"keep me","location":{"file":"src/codeunit.al","line":37,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'aligned range'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":37,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'suggested code present'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":36,"end-line":38}},"suggested-code":""}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'line outside range'
+ Expected = $false
+ Finding = '{"location":{"line":39,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'reversed range'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":38,"end-line":36}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'zero bound'
+ Expected = $false
+ Finding = '{"location":{"line":1,"range":{"start-line":0,"end-line":2}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'fractional primary line'
+ Expected = $false
+ Finding = '{"location":{"line":37.5,"range":{"start-line":36,"end-line":38}}}' | ConvertFrom-Json
+ }
+ [pscustomobject]@{
+ Name = 'missing end line'
+ Expected = $false
+ Finding = '{"location":{"line":37,"range":{"start-line":36}}}' | ConvertFrom-Json
+ }
+)
+
+foreach ($case in $cases) {
+ $actual = Test-RangeNormalizationEligibility $case.Finding
+ Assert-True ($actual -eq $case.Expected) "$($case.Name) eligibility is $($case.Expected)"
+}
+
+$rawFinding = $cases[0].Finding
+$candidateFinding = $rawFinding | ConvertTo-Json -Depth 10 | ConvertFrom-Json
+$candidateFinding.location.PSObject.Properties.Remove('range')
+
+Assert-True ($rawFinding.location.PSObject.Properties.Name -contains 'range') 'raw finding remains unchanged'
+Assert-True (-not ($candidateFinding.location.PSObject.Properties.Name -contains 'range')) 'candidate removes only the optional range'
+Assert-True ($candidateFinding.location.line -eq $rawFinding.location.line) 'candidate preserves the primary line'
+Assert-True ($candidateFinding.message -ceq $rawFinding.message) 'candidate preserves all other finding content'
+
+Write-Output "Review contract validation passed ($($cases.Count) normalization cases)."
diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1
index a9912b7..c4b0bf1 100644
--- a/tools/Test-ReviewFixtures.ps1
+++ b/tools/Test-ReviewFixtures.ps1
@@ -434,6 +434,7 @@ if ($PrepareDirectory) {
}
if (-not $ResultsPath -and -not $ResultsDirectory) {
+ & (Join-Path $PSScriptRoot 'Test-ReviewContract.ps1') -Root $Root
Write-Host "Review fixture validation PASSED: $($cases.Count) cases cover $($leafDomains.Count) leaf domains." -ForegroundColor Green
exit 0
}
|