diff --git a/.github/workflows/claude-evaluation.yml b/.github/workflows/claude-evaluation.yml index 0aad55b2c..d91282bf2 100644 --- a/.github/workflows/claude-evaluation.yml +++ b/.github/workflows/claude-evaluation.yml @@ -23,6 +23,7 @@ on: options: - "bug-fix" - "test-generation" + - "code-review" test-run: description: "Indicate this is a test run (with few entries)" required: false diff --git a/.github/workflows/copilot-evaluation.yml b/.github/workflows/copilot-evaluation.yml index 338e7f5ef..adcdcfbbf 100644 --- a/.github/workflows/copilot-evaluation.yml +++ b/.github/workflows/copilot-evaluation.yml @@ -30,6 +30,7 @@ on: options: - "bug-fix" - "test-generation" + - "code-review" test-run: description: "Indicate this is a test run (with few entries)" required: false @@ -153,7 +154,9 @@ jobs: if: always() with: name: evaluation-results-${{ github.run_id }}-${{ matrix.entry }} - path: ${{ env.EVALUATION_RESULTS_DIR }}/**/*.jsonl + path: | + ${{ env.EVALUATION_RESULTS_DIR }}/**/*.jsonl + ${{ env.EVALUATION_RESULTS_DIR }}/**/*.log retention-days: ${{ inputs.test-run && 1 || 30 }} summarize-results: diff --git a/.github/workflows/summarize-results.yml b/.github/workflows/summarize-results.yml index 2f5b252fe..785262354 100644 --- a/.github/workflows/summarize-results.yml +++ b/.github/workflows/summarize-results.yml @@ -108,7 +108,8 @@ jobs: --use-capi ${{ !inputs.mock && '--storage braintrust --storage kusto' || '' }} - name: Update leaderboard in a new branch - if: ${{ !inputs.mock && !inputs.skip-leaderboard }} + # WIP for code-review category + if: ${{ !inputs.mock && !inputs.skip-leaderboard && inputs.category != 'code-review' }} run: | git fetch origin main diff --git a/dataset/codereview.jsonl b/dataset/codereview.jsonl new file mode 100644 index 000000000..a769bb9a5 --- /dev/null +++ b/dataset/codereview.jsonl @@ -0,0 +1,81 @@ +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/SecureAPIManager.Codeunit.al b/src/SecureAPIManager.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SecureAPIManager.Codeunit.al\n@@ -0,0 +1,46 @@\n+codeunit 50100 \"Secure API Manager\"\n+{\n+ Access = Internal;\n+\n+ var\n+ KeyNotConfiguredErr: Label 'API key is not configured for %1.', Comment = '%1 = configuration code';\n+ RequestFailedErr: Label 'The API request failed. Check the configuration.', Comment = 'Shown when an outbound API call fails.';\n+ EndpointTok: Label 'https://api.businesscentral.dynamics.com/v2.0/data', Locked = true;\n+ BearerTok: Label 'Bearer %1', Locked = true;\n+ StorageKeyTok: Label 'ApiKey_%1', Locked = true;\n+\n+ [NonDebuggable]\n+ procedure StoreKey(ConfigCode: Code[20]; KeyValue: SecretText)\n+ begin\n+ IsolatedStorage.SetEncrypted(GetStorageKey(ConfigCode), KeyValue, DataScope::Module);\n+ end;\n+\n+ [NonDebuggable]\n+ procedure GetKey(ConfigCode: Code[20]) Result: SecretText\n+ begin\n+ if not IsolatedStorage.Contains(GetStorageKey(ConfigCode), DataScope::Module) then\n+ Error(KeyNotConfiguredErr, ConfigCode);\n+ IsolatedStorage.Get(GetStorageKey(ConfigCode), DataScope::Module, Result);\n+ end;\n+\n+ procedure CallEndpoint(ConfigCode: Code[20])\n+ var\n+ Client: HttpClient;\n+ Headers: HttpHeaders;\n+ Response: HttpResponseMessage;\n+ AuthHeader: SecretText;\n+ begin\n+ AuthHeader := SecretStrSubstNo(BearerTok, GetKey(ConfigCode));\n+ Headers := Client.DefaultRequestHeaders();\n+ Headers.Add('Authorization', AuthHeader);\n+ if not Client.Get(EndpointTok, Response) then\n+ Error(RequestFailedErr);\n+ if not Response.IsSuccessStatusCode() then\n+ Error(RequestFailedErr);\n+ end;\n+\n+ local procedure GetStorageKey(ConfigCode: Code[20]): Text[50]\n+ begin\n+ exit(CopyStr(StrSubstNo(StorageKeyTok, ConfigCode), 1, 50));\n+ end;\n+}\ndiff --git a/src/HardcodedSecretClient.Codeunit.al b/src/HardcodedSecretClient.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/HardcodedSecretClient.Codeunit.al\n@@ -0,0 +1,18 @@\n+codeunit 50301 \"Hardcoded Secret Client\"\n+{\n+ procedure CallApi()\n+ var\n+ HttpClient: HttpClient;\n+ HttpHeaders: HttpHeaders;\n+ HttpResponseMessage: HttpResponseMessage;\n+ begin\n+ HttpHeaders := HttpClient.DefaultRequestHeaders();\n+ HttpHeaders.Add('X-Api-Key', this.GetApiKey());\n+ HttpClient.Get('https://api.contoso.com/data', HttpResponseMessage);\n+ end;\n+\n+ local procedure GetApiKey(): Text\n+ begin\n+ exit('sk-1234567890abcdef');\n+ end;\n+}\n", "expected_comments": [{"file": "src/HardcodedSecretClient.Codeunit.al", "line_start": 16, "line_end": 16, "severity": "critical", "domain": "security", "body": "Hardcoded API key in source code. Retrieve secrets from encrypted isolated storage or another secure store instead."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "Clean codeunit using SecretText, NonDebuggable, IsolatedStorage.SetEncrypted, and HTTPS enforcement with no security issues", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-002", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/SecureKeyManager.Codeunit.al b/src/SecureKeyManager.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SecureKeyManager.Codeunit.al\n@@ -0,0 +1,26 @@\n+codeunit 50101 \"Secure Key Manager\"\n+{\n+ Access = Internal;\n+\n+ var\n+ KeyNotFoundErr: Label 'The requested key was not found. Configure it before use.';\n+\n+ [NonDebuggable]\n+ procedure StoreEncryptedKey(KeyName: Code[20]; KeyValue: SecretText)\n+ begin\n+ IsolatedStorage.SetEncrypted(KeyName, KeyValue, DataScope::Module);\n+ end;\n+\n+ [NonDebuggable]\n+ procedure RetrieveKey(KeyName: Code[20]) Result: SecretText\n+ begin\n+ if not IsolatedStorage.Contains(KeyName, DataScope::Module) then\n+ Error(KeyNotFoundErr);\n+ IsolatedStorage.Get(KeyName, DataScope::Module, Result);\n+ end;\n+\n+ procedure HasKey(KeyName: Code[20]): Boolean\n+ begin\n+ exit(IsolatedStorage.Contains(KeyName, DataScope::Module));\n+ end;\n+}\ndiff --git a/src/PlainTokenHeaderClient.Codeunit.al b/src/PlainTokenHeaderClient.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PlainTokenHeaderClient.Codeunit.al\n@@ -0,0 +1,18 @@\n+codeunit 50302 \"Plain Token Header Client\"\n+{\n+ procedure SendRequest(AccessToken: Text)\n+ var\n+ HttpClient: HttpClient;\n+ HttpHeaders: HttpHeaders;\n+ HttpResponseMessage: HttpResponseMessage;\n+ begin\n+ HttpHeaders := HttpClient.DefaultRequestHeaders();\n+ HttpHeaders.Add('Authorization', 'Bearer ' + AccessToken);\n+ HttpClient.Get(this.GetOrdersEndpoint(), HttpResponseMessage);\n+ end;\n+\n+ local procedure GetOrdersEndpoint(): Text\n+ begin\n+ exit('https://api.contoso.com/orders');\n+ end;\n+}\n", "expected_comments": [{"file": "src/PlainTokenHeaderClient.Codeunit.al", "line_start": 10, "line_end": 10, "severity": "high", "domain": "security", "body": "Bearer token is concatenated into a plain Text authorization header. Build the header with SecretStrSubstNo() and add it as SecretText."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "Clean codeunit correctly storing and retrieving API keys using IsolatedStorage.SetEncrypted, SecretText, and NonDebuggable", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/SafeErrorHandler.Codeunit.al b/src/SafeErrorHandler.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SafeErrorHandler.Codeunit.al\n@@ -0,0 +1,41 @@\n+codeunit 50102 \"Safe Error Handler\"\n+{\n+ Access = Internal;\n+\n+ var\n+ InvalidRequestTxt: Label 'Invalid request. Please check your input.';\n+ AuthFailedTxt: Label 'Authentication failed. Please verify your credentials.';\n+ ForbiddenTxt: Label 'You do not have permission for this operation.';\n+ NotFoundTxt: Label 'The requested resource was not found.';\n+ UnexpectedTxt: Label 'An unexpected error occurred. Contact your administrator.';\n+ PostFailedErr: Label 'Could not post document %1.', Comment = '%1 = document number';\n+\n+ procedure GetApiResponseMessage(StatusCode: Integer): Text\n+ begin\n+ case StatusCode of\n+ 200, 201:\n+ exit('');\n+ 400:\n+ exit(InvalidRequestTxt);\n+ 401:\n+ exit(AuthFailedTxt);\n+ 403:\n+ exit(ForbiddenTxt);\n+ 404:\n+ exit(NotFoundTxt);\n+ else\n+ exit(UnexpectedTxt);\n+ end;\n+ end;\n+\n+ procedure PostDocument(DocNo: Code[20])\n+ begin\n+ if not TryPost(DocNo) then\n+ Error(PostFailedErr, DocNo);\n+ end;\n+\n+ [TryFunction]\n+ local procedure TryPost(DocNo: Code[20])\n+ begin\n+ end;\n+}\ndiff --git a/src/UnwrappedSecretClient.Codeunit.al b/src/UnwrappedSecretClient.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/UnwrappedSecretClient.Codeunit.al\n@@ -0,0 +1,18 @@\n+codeunit 50303 \"Unwrapped Secret Client\"\n+{\n+ procedure SendRequest(SessionToken: SecretText)\n+ var\n+ HttpClient: HttpClient;\n+ HttpHeaders: HttpHeaders;\n+ HttpResponseMessage: HttpResponseMessage;\n+ begin\n+ HttpHeaders := HttpClient.DefaultRequestHeaders();\n+ HttpHeaders.Add('Authorization', this.BuildHeader(SessionToken));\n+ HttpClient.Get('https://api.contoso.com/data', HttpResponseMessage);\n+ end;\n+\n+ local procedure BuildHeader(SessionToken: SecretText): Text\n+ begin\n+ exit('Bearer ' + SessionToken.Unwrap());\n+ end;\n+}\n", "expected_comments": [{"file": "src/UnwrappedSecretClient.Codeunit.al", "line_start": 16, "line_end": 16, "severity": "high", "domain": "security", "body": "SecretText.Unwrap() exposes the secret as plain Text without a [NonDebuggable] procedure. Add [NonDebuggable] or avoid unwrapping by using SecretStrSubstNo()."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "Clean codeunit with proper error handling: generic user-facing messages, no system details exposed, no GetLastErrorText shown to user", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-004", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/AppConstants.Codeunit.al b/src/AppConstants.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AppConstants.Codeunit.al\n@@ -0,0 +1,30 @@\n+codeunit 50103 \"App Constants\"\n+{\n+ Access = Internal;\n+\n+ var\n+ ApiVersionTok: Label 'v2.0', Locked = true;\n+ DefaultCurrencyTok: Label 'USD', Locked = true;\n+ DateFormatTok: Label 'yyyy-MM-dd', Locked = true;\n+ AppIdTok: Label 'BC-INVENTORY-APP', Locked = true;\n+\n+ procedure GetApiVersion(): Text\n+ begin\n+ exit(ApiVersionTok);\n+ end;\n+\n+ procedure GetDefaultCurrency(): Code[10]\n+ begin\n+ exit(DefaultCurrencyTok);\n+ end;\n+\n+ procedure GetDateFormat(): Text\n+ begin\n+ exit(DateFormatTok);\n+ end;\n+\n+ procedure GetAppId(): Text\n+ begin\n+ exit(AppIdTok);\n+ end;\n+}\ndiff --git a/src/QueryStringSecretClient.Codeunit.al b/src/QueryStringSecretClient.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/QueryStringSecretClient.Codeunit.al\n@@ -0,0 +1,15 @@\n+codeunit 50304 \"Query String Secret Client\"\n+{\n+ procedure FetchAccount(ApiKey: Text)\n+ var\n+ HttpClient: HttpClient;\n+ HttpResponseMessage: HttpResponseMessage;\n+ begin\n+ HttpClient.Get(this.BuildAccountUrl(ApiKey), HttpResponseMessage);\n+ end;\n+\n+ local procedure BuildAccountUrl(ApiKey: Text): Text\n+ begin\n+ exit('https://api.contoso.com/accounts?api_key=' + ApiKey);\n+ end;\n+}\n", "expected_comments": [{"file": "src/QueryStringSecretClient.Codeunit.al", "line_start": 13, "line_end": 13, "severity": "high", "domain": "security", "body": "API key is placed in the URL query string. Use an Authorization header, or SetSecretRequestUri() if a secret URI is unavoidable."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "Clean codeunit with configuration constants that are not secrets: API version, currency codes, labels, and format strings", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/ValidatedImportConfig.Table.al b/src/ValidatedImportConfig.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ValidatedImportConfig.Table.al\n@@ -0,0 +1,34 @@\n+table 50104 \"Validated Import Config\"\n+{\n+ Caption = 'Validated Import Configuration';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Code\"; Code[20])\n+ {\n+ Caption = 'Code';\n+ NotBlank = true;\n+ }\n+ field(2; \"Source Table ID\"; Integer)\n+ {\n+ Caption = 'Source Table';\n+ TableRelation = AllObjWithCaption.\"Object ID\" where(\"Object Type\" = const(Table));\n+ ValidateTableRelation = true;\n+ }\n+ field(3; \"Max Records\"; Integer)\n+ {\n+ Caption = 'Maximum Records';\n+ MinValue = 1;\n+ MaxValue = 10000;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Code\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+}\ndiff --git a/src/BroadFinanceAccess.PermissionSet.al b/src/BroadFinanceAccess.PermissionSet.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/BroadFinanceAccess.PermissionSet.al\n@@ -0,0 +1,15 @@\n+permissionset 50305 \"Broad Finance Access\"\n+{\n+ Assignable = true;\n+ Caption = 'Broad Finance Access', Locked = true;\n+ Permissions =\n+ tabledata * = RIMD,\n+ table * = X,\n+ tabledata Customer = R,\n+ tabledata Vendor = R,\n+ tabledata Item = R,\n+ tabledata \"Sales Header\" = R,\n+ tabledata \"Sales Line\" = R,\n+ codeunit \"Release Sales Document\" = X,\n+ codeunit \"Sales-Post\" = X;\n+}\n", "expected_comments": [{"file": "src/BroadFinanceAccess.PermissionSet.al", "line_start": 6, "line_end": 6, "severity": "critical", "domain": "security", "body": "Permission set grants RIMD on all table data. Replace the wildcard with the minimum specific tabledata permissions required."}, {"file": "src/BroadFinanceAccess.PermissionSet.al", "line_start": 7, "line_end": 7, "severity": "high", "domain": "security", "body": "Permission set grants execute permission on all tables. Grant execute only on the specific objects this role requires."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "Clean table with proper input validation: ValidateTableRelation, OnValidate triggers, MinValue/MaxValue, Editable=false on system fields", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-006", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/InventoryReader.PermissionSet.al b/src/InventoryReader.PermissionSet.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/InventoryReader.PermissionSet.al\n@@ -0,0 +1,11 @@\n+permissionset 50106 \"Inventory Reader\"\n+{\n+ Caption = 'Inventory Reader';\n+ Assignable = true;\n+\n+ Permissions =\n+ tabledata Item = r,\n+ tabledata \"Item Ledger Entry\" = r,\n+ tabledata \"Item Category\" = r,\n+ codeunit \"Inventory Lookup\" = X;\n+}\ndiff --git a/src/InventoryLookup.Codeunit.al b/src/InventoryLookup.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/InventoryLookup.Codeunit.al\n@@ -0,0 +1,15 @@\n+codeunit 50108 \"Inventory Lookup\"\n+{\n+ Access = Internal;\n+ Permissions = tabledata Item = r;\n+\n+ procedure GetItemDescription(ItemNo: Code[20]): Text[100]\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.SetLoadFields(Description);\n+ if Item.Get(ItemNo) then\n+ exit(Item.Description);\n+ exit('');\n+ end;\n+}\ndiff --git a/src/ExcessiveInherentAccess.Codeunit.al b/src/ExcessiveInherentAccess.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExcessiveInherentAccess.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50306 \"Excessive Inherent Access\"\n+{\n+ procedure LookupCustomerName(CustomerNo: Code[20]): Text\n+ begin\n+ exit(this.GetCustomerName(CustomerNo));\n+ end;\n+\n+ [InherentPermissions(PermissionObjectType::TableData, Database::Customer, 'RIMD')]\n+ local procedure GetCustomerName(CustomerNo: Code[20]): Text\n+ var\n+ Customer: Record Customer;\n+ begin\n+ if Customer.Get(CustomerNo) then\n+ exit(Customer.Name);\n+ exit('');\n+ end;\n+}\n", "expected_comments": [{"file": "src/ExcessiveInherentAccess.Codeunit.al", "line_start": 8, "line_end": 8, "severity": "high", "domain": "security", "body": "InherentPermissions grants RIMD tabledata access even though this procedure only reads Customer. Reduce the permission to the minimal read access required."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "Clean permission sets with least-privilege access: read-only for readers, read-insert for editors, no RIMD grants", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/SafeRecordQuery.Codeunit.al b/src/SafeRecordQuery.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SafeRecordQuery.Codeunit.al\n@@ -0,0 +1,31 @@\n+codeunit 50109 \"Safe Record Query\"\n+{\n+ Access = Internal;\n+\n+ procedure CustomerExists(CustomerNo: Code[20]): Boolean\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.SetLoadFields(\"No.\");\n+ exit(Customer.Get(CustomerNo));\n+ end;\n+\n+ procedure HasOpenSalesOrders(CustomerNo: Code[20]): Boolean\n+ var\n+ SalesHeader: Record \"Sales Header\";\n+ begin\n+ SalesHeader.SetRange(\"Document Type\", SalesHeader.\"Document Type\"::Order);\n+ SalesHeader.SetRange(\"Sell-to Customer No.\", CustomerNo);\n+ exit(not SalesHeader.IsEmpty());\n+ end;\n+\n+ procedure GetItemDescription(ItemNo: Code[20]): Text[100]\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.SetLoadFields(Description);\n+ if Item.Get(ItemNo) then\n+ exit(Item.Description);\n+ exit('');\n+ end;\n+}\ndiff --git a/src/InsecureEndpointClient.Codeunit.al b/src/InsecureEndpointClient.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/InsecureEndpointClient.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50307 \"Insecure Endpoint Client\"\n+{\n+ procedure SendSession(SessionToken: SecretText)\n+ var\n+ HttpClient: HttpClient;\n+ HttpContent: HttpContent;\n+ HttpResponseMessage: HttpResponseMessage;\n+ begin\n+ HttpContent.WriteFrom(SessionToken);\n+ HttpClient.Post(this.GetEndpoint(), HttpContent, HttpResponseMessage);\n+ end;\n+\n+ local procedure GetEndpoint(): Text\n+ begin\n+ exit('http://api.contoso.com/session');\n+ end;\n+}\n", "expected_comments": [{"file": "src/InsecureEndpointClient.Codeunit.al", "line_start": 15, "line_end": 15, "severity": "high", "domain": "security", "body": "External service endpoint uses HTTP instead of HTTPS. Use HTTPS for all external HTTP calls, especially when sending session tokens."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "Clean codeunit using proper BC record operations: SetRange, SetFilter, FindSet, Count — no string concatenation or dynamic SQL", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-008", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/PartnerConfigKeyManager.Codeunit.al b/src/PartnerConfigKeyManager.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PartnerConfigKeyManager.Codeunit.al\n@@ -0,0 +1,9 @@\n+codeunit 50100 \"Partner Config Key Manager\"\n+{\n+ Access = Internal;\n+\n+ internal procedure StoreApiKey(KeyName: Text; ApiKey: Text)\n+ begin\n+ IsolatedStorage.SetEncrypted(KeyName, ApiKey, DataScope::Module);\n+ end;\n+}\n", "expected_comments": [{"file": "src/PartnerConfigKeyManager.Codeunit.al", "line_start": 5, "line_end": 5, "domain": "security", "severity": "high", "body": "The API key is accepted as a plain Text parameter instead of SecretText, so the secret is exposed in memory and to anyone inspecting the call stack or debugger."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive security findings: encryption (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/OutlookAddinDeployer.Codeunit.al b/src/OutlookAddinDeployer.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/OutlookAddinDeployer.Codeunit.al\n@@ -0,0 +1,37 @@\n+namespace Microsoft.Integration.Outlook;\n+\n+codeunit 50104 \"Outlook Addin Deployer\"\n+{\n+ Access = Internal;\n+\n+ var\n+ EndpointTok: Label 'https://outlook.office365.com/api/v2.0/addins/deploy', Locked = true;\n+ StatusErr: Label 'Deployment failed (HTTP %1): %2', Comment = '%1 is the HTTP status code, %2 is the raw response body.';\n+ ConnectErr: Label 'Failed to connect to the deployment service: %1', Comment = '%1 is the underlying error text.';\n+\n+ procedure DeployAddin(ManifestPath: Text)\n+ var\n+ Client: HttpClient;\n+ Content: HttpContent;\n+ Response: HttpResponseMessage;\n+ Headers: HttpHeaders;\n+ Payload: Text;\n+ ResponseText: Text;\n+ begin\n+ Payload := '{\"manifest\":\"' + ManifestPath + '\"}';\n+ Content.WriteFrom(Payload);\n+ Content.GetHeaders(Headers);\n+ Headers.Add('Authorization', 'Bearer ' + GetAccessToken());\n+ if Client.Post(EndpointTok, Content, Response) then begin\n+ Response.Content.ReadAs(ResponseText);\n+ if not Response.IsSuccessStatusCode() then\n+ Error(StatusErr, Response.HttpStatusCode(), ResponseText);\n+ end else\n+ Error(ConnectErr, GetLastErrorText());\n+ end;\n+\n+ local procedure GetAccessToken(): Text\n+ begin\n+ exit('dummy_access_token_for_testing');\n+ end;\n+}\n", "expected_comments": [{"file": "src/OutlookAddinDeployer.Codeunit.al", "line_start": 21, "line_end": 21, "domain": "security", "severity": "medium", "body": "The manifest path is concatenated directly into a JSON payload, allowing JSON injection. Build the payload with a JsonObject so values are escaped."}, {"file": "src/OutlookAddinDeployer.Codeunit.al", "line_start": 28, "line_end": 28, "domain": "security", "severity": "medium", "body": "The error surfaces the raw HTTP status code and full response body to the user, leaking internal service details. Log the details and show a generic message."}, {"file": "src/OutlookAddinDeployer.Codeunit.al", "line_start": 30, "line_end": 30, "domain": "security", "severity": "medium", "body": "GetLastErrorText() is shown to the user, exposing internal system details. Log the raw error and present a sanitized message."}, {"file": "src/OutlookAddinDeployer.Codeunit.al", "line_start": 33, "line_end": 33, "domain": "security", "severity": "high", "body": "The access token is returned as plain Text instead of SecretText, exposing it in memory and to the debugger. Return and handle it as SecretText."}, {"file": "src/OutlookAddinDeployer.Codeunit.al", "line_start": 35, "line_end": 35, "domain": "security", "severity": "high", "body": "A hardcoded access token is embedded in source code. Retrieve the token from a secure store or OAuth flow instead of hardcoding it."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive security findings: error_exposure (verified line numbers)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-010", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/PaymentGatewayConnector.Codeunit.al b/src/PaymentGatewayConnector.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PaymentGatewayConnector.Codeunit.al\n@@ -0,0 +1,44 @@\n+codeunit 50300 \"Payment Gateway Connector\"\n+{\n+ Access = Internal;\n+\n+ var\n+ ConnectionFailedErr: Label 'Payment gateway connection failed.', Comment = 'Shown when the payment gateway cannot be reached.';\n+ GatewaySecretLbl: Label 'my-secret-api-key-do-not-commit-this', Comment = 'Fallback gateway credential used when no stored secret is configured.';\n+\n+ procedure InitializeGateway()\n+ var\n+ ApiKey: Text;\n+ begin\n+ ApiKey := 'hardcoded-api-key-value-12345';\n+ SetupGatewayConnection(ApiKey);\n+ end;\n+\n+ procedure GetStoredCredential(KeyName: Text): Text\n+ var\n+ KeyValue: Text;\n+ begin\n+ if IsolatedStorage.Contains(KeyName, DataScope::Module) then\n+ IsolatedStorage.Get(KeyName, DataScope::Module, KeyValue);\n+ exit(KeyValue);\n+ end;\n+\n+ procedure StoreCredential(KeyName: Text; KeyValue: Text)\n+ begin\n+ IsolatedStorage.SetEncrypted(KeyName, KeyValue, DataScope::Module);\n+ end;\n+\n+ local procedure SetupGatewayConnection(ApiKey: Text)\n+ var\n+ Client: HttpClient;\n+ Headers: HttpHeaders;\n+ Response: HttpResponseMessage;\n+ begin\n+ Headers := Client.DefaultRequestHeaders();\n+ Headers.Add('Authorization', 'Basic ' + ApiKey);\n+ Headers.Add('X-Api-Secret', GatewaySecretLbl);\n+ Client.Get('https://api.paymentgateway.com/v1/setup', Response);\n+ if not Response.IsSuccessStatusCode() then\n+ Error(ConnectionFailedErr);\n+ end;\n+}\n", "expected_comments": [{"file": "src/PaymentGatewayConnector.Codeunit.al", "line_start": 7, "line_end": 7, "domain": "security", "severity": "critical", "body": "API secret is embedded in a Label constant. Secrets must never be stored in source; retrieve them from IsolatedStorage or a secure key vault."}, {"file": "src/PaymentGatewayConnector.Codeunit.al", "line_start": 13, "line_end": 13, "domain": "security", "severity": "critical", "body": "Hardcoded API key assigned directly in code. Retrieve the API key from IsolatedStorage or a secure key vault instead of embedding it."}, {"file": "src/PaymentGatewayConnector.Codeunit.al", "line_start": 22, "line_end": 22, "domain": "security", "severity": "high", "body": "Credentials are written with SetEncrypted but read back with a plain Get, so the value is never decrypted. Use GetEncrypted to match the encrypted write."}, {"file": "src/PaymentGatewayConnector.Codeunit.al", "line_start": 38, "line_end": 38, "domain": "security", "severity": "high", "body": "API key is handled as plain Text and concatenated into a Basic Authorization header without base64 encoding. Use SecretText and encode credentials correctly."}, {"file": "src/PaymentGatewayConnector.Codeunit.al", "line_start": 40, "line_end": 40, "domain": "security", "severity": "high", "body": "The boolean return value of HttpClient.Get is ignored, so a transport-level failure is treated as success. Check the return value before reading the response."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive security findings: hardcoded_credentials — hardcoded API key, secret in Label, public IsolatedStorage access", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-011", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/ElecVATSubmission.Codeunit.al b/src/ElecVATSubmission.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ElecVATSubmission.Codeunit.al\n@@ -0,0 +1,24 @@\n+namespace Microsoft.Finance.VAT;\n+\n+codeunit 13610 \"Elec VAT Submission\"\n+{\n+ Access = Internal;\n+\n+ procedure SubmitReturn(AuthorityUrl: Text): Boolean\n+ var\n+ Client: HttpClient;\n+ Content: HttpContent;\n+ Response: HttpResponseMessage;\n+ begin\n+ Content.WriteFrom('{}');\n+ exit(Client.Post(AuthorityUrl, Content, Response));\n+ end;\n+\n+ procedure CheckHealth(ServiceUrl: Text): Boolean\n+ var\n+ Client: HttpClient;\n+ Response: HttpResponseMessage;\n+ begin\n+ exit(Client.Get(ServiceUrl, Response));\n+ end;\n+}\n", "expected_comments": [{"file": "src/ElecVATSubmission.Codeunit.al", "line_start": 14, "line_end": 14, "domain": "security", "severity": "high", "body": "A caller-supplied URL is passed to HttpClient.Post without host or scheme validation, allowing SSRF requests to internal or attacker-chosen endpoints."}, {"file": "src/ElecVATSubmission.Codeunit.al", "line_start": 22, "line_end": 22, "domain": "security", "severity": "high", "body": "A caller-supplied URL is passed to HttpClient.Get without host or scheme validation, allowing SSRF requests to internal or attacker-chosen endpoints."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive security findings: input_validation (trimmed to core input validation cases)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-012", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/HttpAuthenticationBasic.Codeunit.al b/src/HttpAuthenticationBasic.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/HttpAuthenticationBasic.Codeunit.al\n@@ -0,0 +1,37 @@\n+codeunit 2359 \"Http Authentication Basic\"\n+{\n+ Access = Public;\n+ InherentEntitlements = X;\n+ InherentPermissions = X;\n+\n+ var\n+ Credential: SecretText;\n+ UsernameDomainTok: Label '%1\\%2', Comment = '%1 = domain, %2 = user name', Locked = true;\n+\n+ [NonDebuggable]\n+ procedure Initialize(Username: SecretText; Domain: Text; Password: SecretText)\n+ begin\n+ Credential := SecretStrSubstNo('%1:%2', QualifyUser(Username, Domain), Password);\n+ end;\n+\n+ procedure GetAuthorizationHeader() Header: SecretText\n+ begin\n+ Header := ToBase64(Credential);\n+ end;\n+\n+ [NonDebuggable]\n+ local procedure QualifyUser(Username: SecretText; Domain: Text): SecretText\n+ begin\n+ if Domain = '' then\n+ exit(Username);\n+ exit(SecretStrSubstNo(UsernameDomainTok, Domain, Username));\n+ end;\n+\n+ local procedure ToBase64(Value: SecretText) Base64Value: SecretText\n+ var\n+ Convert: DotNet Convert;\n+ Encoding: DotNet Encoding;\n+ begin\n+ Base64Value := Convert.ToBase64String(Encoding.UTF8().GetBytes(Value.Unwrap()));\n+ end;\n+}\n", "expected_comments": [{"file": "src/HttpAuthenticationBasic.Codeunit.al", "line_start": 30, "line_end": 30, "domain": "security", "severity": "medium", "body": "ToBase64 transforms SecretText credential material and calls Unwrap() without [NonDebuggable], so the plaintext credential is visible in the debugger."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive security findings: procedures handling passwords or SecretText values without [NonDebuggable]", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-013", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/ExpenseAgentAdmin.PermissionSet.al b/src/ExpenseAgentAdmin.PermissionSet.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseAgentAdmin.PermissionSet.al\n@@ -0,0 +1,15 @@\n+// ------------------------------------------------------------------------------------------------\n+// Copyright (c) Microsoft Corporation. All rights reserved.\n+// Licensed under the MIT License. See License.txt in the project root for license information.\n+// ------------------------------------------------------------------------------------------------\n+namespace Microsoft.Agents.Expense;\n+\n+permissionset 50700 \"Expense Agent Admin\"\n+{\n+ Assignable = true;\n+ Caption = 'Expense Agent Administration';\n+\n+ Permissions =\n+ tabledata \"Agent Creation Control\" = RIMD,\n+ tabledata \"Expense Report Rule Violation\" = IMD;\n+}\ndiff --git a/src/ExpenseAgentConsumption.Table.al b/src/ExpenseAgentConsumption.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseAgentConsumption.Table.al\n@@ -0,0 +1,51 @@\n+// ------------------------------------------------------------------------------------------------\n+// Copyright (c) Microsoft Corporation. All rights reserved.\n+// Licensed under the MIT License. See License.txt in the project root for license information.\n+// ------------------------------------------------------------------------------------------------\n+namespace Microsoft.Agents.Expense;\n+\n+table 50600 \"Expense Agent Consumption\"\n+{\n+ Caption = 'Expense Agent Consumption';\n+ DataClassification = CustomerContent;\n+ InherentEntitlements = RIX;\n+ InherentPermissions = RIX;\n+\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer)\n+ {\n+ Caption = 'Entry No.';\n+ DataClassification = SystemMetadata;\n+ AutoIncrement = true;\n+ }\n+ field(10; Amount; Decimal)\n+ {\n+ Caption = 'Amount';\n+ DataClassification = CustomerContent;\n+ }\n+ field(20; \"User Security ID\"; Guid)\n+ {\n+ Caption = 'User Security ID';\n+ DataClassification = EndUserPseudonymousIdentifiers;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Entry No.\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ procedure LogConsumption(CallerSecurityId: Guid; ConsumptionAmount: Decimal)\n+ var\n+ ConsumptionEntry: Record \"Expense Agent Consumption\";\n+ begin\n+ ConsumptionEntry.Init();\n+ ConsumptionEntry.\"User Security ID\" := CallerSecurityId;\n+ ConsumptionEntry.Amount := ConsumptionAmount;\n+ ConsumptionEntry.Insert();\n+ end;\n+}\n", "expected_comments": [{"file": "src/ExpenseAgentConsumption.Table.al", "line_start": 11, "line_end": 11, "domain": "security", "severity": "medium", "body": "InherentEntitlements and InherentPermissions of RIX grant read/insert/execute to every user regardless of assigned permission sets. Remove the inherent grants and control access explicitly."}, {"file": "src/ExpenseAgentConsumption.Table.al", "line_start": 42, "line_end": 42, "domain": "security", "severity": "medium", "body": "The procedure accepts an arbitrary UserSecurityId, letting a caller log consumption against any user's identity. Derive the user from UserSecurityId() instead of trusting the parameter."}, {"file": "src/ExpenseAgentAdmin.PermissionSet.al", "line_start": 13, "line_end": 13, "domain": "security", "severity": "medium", "body": "RIMD on Agent Creation Control lets assigned users delete creation-control records, removing a security guardrail. Grant only the permissions actually required."}, {"file": "src/ExpenseAgentAdmin.PermissionSet.al", "line_start": 14, "line_end": 14, "domain": "security", "severity": "medium", "body": "IMD on Expense Report Rule Violation lets users delete recorded policy violations, enabling them to hide their own violations. Remove delete and modify access."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive security findings: permission (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-014", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/SecureOperationHelper.Codeunit.al b/src/SecureOperationHelper.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SecureOperationHelper.Codeunit.al\n@@ -0,0 +1,13 @@\n+codeunit 50105 \"Secure Operation Helper\"\n+{\n+ Access = Internal;\n+\n+ internal procedure DeleteAllRecords(TableNo: Integer)\n+ var\n+ RecRef: RecordRef;\n+ begin\n+ RecRef.Open(TableNo);\n+ RecRef.DeleteAll();\n+ RecRef.Close();\n+ end;\n+}\n", "expected_comments": [{"file": "src/SecureOperationHelper.Codeunit.al", "line_start": 9, "line_end": 10, "domain": "security", "severity": "high", "body": "A caller-provided table number is opened with RecordRef.Open and then DeleteAll is called, letting any caller delete every record in an arbitrary table. Restrict the allowed tables and enforce permission checks."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive: public procedure uses RecordRef.Open with caller-provided table number, allowing any extension to delete all records from any table through this codeunit's permissions", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-015", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/ExternalIntegrationMgt.Codeunit.al b/src/ExternalIntegrationMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExternalIntegrationMgt.Codeunit.al\n@@ -0,0 +1,24 @@\n+namespace Microsoft.Integration.Partner;\n+\n+codeunit 50205 \"External Integration Mgt.\"\n+{\n+ Access = Internal;\n+\n+ procedure PostToPartner(EndpointUrl: Text): Boolean\n+ var\n+ Client: HttpClient;\n+ Content: HttpContent;\n+ Response: HttpResponseMessage;\n+ begin\n+ Content.WriteFrom('{}');\n+ exit(Client.Post(EndpointUrl, Content, Response));\n+ end;\n+\n+ procedure GetFromProvider(EndpointUrl: Text): Boolean\n+ var\n+ Client: HttpClient;\n+ Response: HttpResponseMessage;\n+ begin\n+ exit(Client.Get(EndpointUrl, Response));\n+ end;\n+}\n", "expected_comments": [{"file": "src/ExternalIntegrationMgt.Codeunit.al", "line_start": 14, "line_end": 14, "domain": "security", "severity": "high", "body": "A caller-supplied URL is passed to HttpClient.Post without host or scheme validation, allowing SSRF requests to internal or attacker-chosen endpoints."}, {"file": "src/ExternalIntegrationMgt.Codeunit.al", "line_start": 22, "line_end": 22, "domain": "security", "severity": "high", "body": "A caller-supplied URL is passed to HttpClient.Get without host or scheme validation, allowing SSRF requests to internal or attacker-chosen endpoints."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive security findings: URLs from table fields used in HTTP requests without validation (SSRF risk). Three procedures use user-configurable URLs directly, while two procedures correctly validate using Uri.AreURIsHaveSameHost and Uri.IsValidURIPattern.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__security-016", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "security"}, "patch": "diff --git a/src/ExpenseHtmlNotifier.Codeunit.al b/src/ExpenseHtmlNotifier.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseHtmlNotifier.Codeunit.al\n@@ -0,0 +1,12 @@\n+codeunit 50900 \"Expense Html Notifier\"\n+{\n+ Access = Internal;\n+\n+ var\n+ BodyTemplateTok: Label '

Dear %1,

%2

', Locked = true;\n+\n+ internal procedure BuildNotificationBody(EmployeeName: Text; Description: Text): Text\n+ begin\n+ exit(StrSubstNo(BodyTemplateTok, EmployeeName, Description));\n+ end;\n+}\n", "expected_comments": [{"file": "src/ExpenseHtmlNotifier.Codeunit.al", "line_start": 10, "line_end": 10, "domain": "security", "severity": "high", "body": "User-supplied EmployeeName and Description are substituted into the HTML body without encoding, enabling stored or reflected XSS. HTML-encode the values before embedding them."}], "match_line_tolerance": 2, "domain": "security", "category": "code-review", "description": "True positive security findings: xss (user-supplied data embedded in HTML without encoding)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/FADepreciationBook.Table.al b/src/FADepreciationBook.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/FADepreciationBook.Table.al\n@@ -0,0 +1,78 @@\n+table 50200 \"FA Depreciation Book FP\"\n+{\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"FA No.\"; Code[20])\n+ {\n+ Caption = 'FA No.';\n+ TableRelation = \"Fixed Asset\";\n+ }\n+\n+ field(2; \"Depreciation Book Code\"; Code[10])\n+ {\n+ Caption = 'Depreciation Book Code';\n+ TableRelation = \"Depreciation Book\";\n+ }\n+\n+ field(3; Depreciation; Decimal)\n+ {\n+ FieldClass = FlowField;\n+ CalcFormula = sum(\"FA Ledger Entry\".Amount where(\"FA No.\" = field(\"FA No.\"),\n+ \"Depreciation Book Code\" = field(\"Depreciation Book Code\"),\n+ \"FA Posting Category\" = const(Depreciation)));\n+ Caption = 'Depreciation';\n+ }\n+\n+ field(4; \"Bonus Depr. Applied Amount\"; Decimal)\n+ {\n+ FieldClass = FlowField;\n+ CalcFormula = sum(\"FA Ledger Entry\".Amount where(\"FA No.\" = field(\"FA No.\"),\n+ \"Depreciation Book Code\" = field(\"Depreciation Book Code\"),\n+ \"FA Posting Type\" = const(\"Bonus Depreciation\")));\n+ Caption = 'Bonus Depr. Applied Amount';\n+ }\n+\n+ field(5; \"Use Half-Year Convention\"; Boolean)\n+ {\n+ Caption = 'Use Half-Year Convention';\n+\n+ trigger OnValidate()\n+ var\n+ CannotChangeHalfYearErr: Label 'Cannot change half-year convention after depreciation has been posted.';\n+ CannotChangeBonusErr: Label 'Cannot change half-year convention when bonus depreciation has been applied.';\n+ begin\n+ // CORRECT: CalcFields in OnValidate runs once per user edit, not in a loop\n+ // This is appropriate for validation logic that needs current flowfield values\n+ CalcFields(Depreciation);\n+ if Depreciation <> 0 then\n+ Error(CannotChangeHalfYearErr);\n+\n+ CalcFields(\"Bonus Depr. Applied Amount\");\n+ if \"Bonus Depr. Applied Amount\" <> 0 then\n+ Error(CannotChangeBonusErr);\n+ end;\n+ }\n+\n+ field(6; \"Depreciation Method\"; Option)\n+ {\n+ OptionCaption = 'Straight-Line,Declining-Balance 1,Declining-Balance 2';\n+ OptionMembers = \"Straight-Line\",\"Declining-Balance 1\",\"Declining-Balance 2\";\n+ Caption = 'Depreciation Method';\n+ }\n+\n+ field(7; \"Starting Date\"; Date)\n+ {\n+ Caption = 'Depreciation Starting Date';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"FA No.\", \"Depreciation Book Code\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+}\ndiff --git a/src/SalesOrderCard.Page.al b/src/SalesOrderCard.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SalesOrderCard.Page.al\n@@ -0,0 +1,87 @@\n+page 50201 \"Sales Order Card FP\"\n+{\n+ PageType = Card;\n+ SourceTable = \"Sales Header\";\n+ Caption = 'Sales Order Card FP';\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ group(General)\n+ {\n+ Caption = 'General';\n+\n+ field(\"No.\"; Rec.\"No.\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the number of the sales order.';\n+ }\n+\n+ field(\"Sell-to Customer No.\"; Rec.\"Sell-to Customer No.\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the number of the customer who will receive the products on the sales order.';\n+ }\n+\n+ field(\"Document Date\"; Rec.\"Document Date\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the date when the sales order was created.';\n+ }\n+\n+ field(\"Total Amount\"; TotalAmount)\n+ {\n+ Caption = 'Total Amount Including VAT';\n+ ApplicationArea = All;\n+ Editable = false;\n+ ToolTip = 'Specifies the total amount including VAT for the sales order.';\n+ }\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(Processing)\n+ {\n+ action(RefreshTotals)\n+ {\n+ Caption = 'Refresh Totals';\n+ ApplicationArea = All;\n+ ToolTip = 'Recalculates and refreshes the total amount for the sales order.';\n+ Image = Refresh;\n+\n+ trigger OnAction()\n+ var\n+ TotalRefreshedMsg: Label 'Total refreshed: %1', Comment = '%1 = total amount including VAT';\n+ begin\n+ // CORRECT: Manual refresh action - user-initiated, runs once\n+ Rec.CalcFields(\"Amount Including VAT\");\n+ TotalAmount := Rec.\"Amount Including VAT\";\n+ Message(TotalRefreshedMsg, TotalAmount);\n+ end;\n+ }\n+ }\n+ }\n+\n+ var\n+ TotalAmount: Decimal;\n+\n+ // CORRECT: OnAfterGetCurrRecord fires once per record selection, not per row\n+ // This is the appropriate place to calculate values when user navigates to a record\n+ trigger OnAfterGetCurrRecord()\n+ begin\n+ // Calculate total amount when user selects a sales order\n+ // This runs once when the record is loaded/selected, not in a loop\n+ Rec.CalcFields(\"Amount Including VAT\");\n+ TotalAmount := Rec.\"Amount Including VAT\";\n+ end;\n+\n+ trigger OnNewRecord(BelowxRec: Boolean)\n+ begin\n+ // CORRECT: Initialize values for new record - runs once per new record creation\n+ TotalAmount := 0;\n+ Rec.\"Document Date\" := WorkDate();\n+ end;\n+}\ndiff --git a/src/CustLedgerEntryAggregator.Codeunit.al b/src/CustLedgerEntryAggregator.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustLedgerEntryAggregator.Codeunit.al\n@@ -0,0 +1,20 @@\n+codeunit 50202 \"Cust Ledger Entry Aggregator\"\n+{\n+ procedure SumOpenRemainingAmount(CustomerNo: Code[20]): Decimal\n+ var\n+ CustLedgerEntry: Record \"Cust. Ledger Entry\";\n+ Customer: Record Customer;\n+ Total: Decimal;\n+ begin\n+ CustLedgerEntry.SetRange(\"Customer No.\", CustomerNo);\n+ CustLedgerEntry.SetRange(Open, true);\n+ if CustLedgerEntry.FindSet() then\n+ repeat\n+ CustLedgerEntry.CalcFields(\"Remaining Amount\");\n+ Customer.Get(CustLedgerEntry.\"Customer No.\");\n+ if Customer.\"Application Method\" = Customer.\"Application Method\"::Manual then\n+ Total += CustLedgerEntry.\"Remaining Amount\";\n+ until CustLedgerEntry.Next() = 0;\n+ exit(Total);\n+ end;\n+}\n", "expected_comments": [{"file": "src/CustLedgerEntryAggregator.Codeunit.al", "line_start": 13, "line_end": 13, "body": "CalcFields(\"Remaining Amount\") inside a repeat..until loop over \"Cust. Ledger Entry\" (up to 10M rows) issues one SQL query per iteration — classic N+1 against a hot table. — Replace the loop with `CustLedgerEntry.CalcSums(\"Remaining Amount\")` which executes as a single SUM query, or use a SIFT-backed key.", "severity": "high", "domain": "performance"}, {"file": "src/CustLedgerEntryAggregator.Codeunit.al", "line_start": 14, "line_end": 14, "body": "Customer.Get(CustLedgerEntry.\"Customer No.\") inside a repeat..until over Cust. Ledger Entry is an N+1 query: one Customer lookup per ledger row, redundant since every row already has the same Customer No. (the loop is filtered by CustomerNo). — Move the Customer.Get above the loop (single lookup), and add `Customer.SetLoadFields(\"Application Method\")` since only one field is read.", "severity": "high", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "False positive performance findings: calcfields_false_positive (30 false positives). Agent flagged these but reviewers rejected them. Enriched with 2 true-positive findings in addition to the false-positive bait.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-002", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/SetupReader.Codeunit.al b/src/SetupReader.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SetupReader.Codeunit.al\n@@ -0,0 +1,67 @@\n+codeunit 50211 \"Setup Reader\"\n+{\n+ procedure GetSetupValues()\n+ var\n+ GLSetup: Record \"General Ledger Setup\";\n+ SalesSetup: Record \"Sales & Receivables Setup\";\n+ InventorySetup: Record \"Inventory Setup\";\n+ PurchSetup: Record \"Purchases & Payables Setup\";\n+ begin\n+ // CORRECT: Setup tables typically have only 1 record per company\n+ // Any access pattern (Get, FindSet, FindFirst) is fine for singleton tables\n+ GLSetup.Get();\n+ SalesSetup.Get();\n+ InventorySetup.Get();\n+ PurchSetup.Get();\n+\n+ if GLSetup.\"Additional Reporting Currency\" <> '' then\n+ ProcessACYSettings(GLSetup);\n+\n+ if SalesSetup.\"Credit Warnings\" <> SalesSetup.\"Credit Warnings\"::\"No Warning\" then\n+ EnableCreditWarnings(SalesSetup);\n+ end;\n+\n+ procedure ValidateCompanySettings(): Boolean\n+ var\n+ CompanyInfo: Record \"Company Information\";\n+ begin\n+ // CORRECT: Company Information is a singleton table (1 record per company)\n+ // Get() is the appropriate method for singleton tables\n+ if not CompanyInfo.Get() then\n+ exit(false);\n+\n+ if CompanyInfo.Name = '' then\n+ exit(false);\n+\n+ if CompanyInfo.\"Country/Region Code\" = '' then\n+ exit(false);\n+\n+ exit(true);\n+ end;\n+\n+ procedure GetUserSetupForCurrentUser(var UserSetup: Record \"User Setup\"): Boolean\n+ begin\n+ // CORRECT: Looking up single user's setup record\n+ // Get() with UserId is appropriate for single-record lookup\n+ UserSetup.Reset();\n+ if UserSetup.Get(UserId) then\n+ exit(true);\n+ exit(false);\n+ end;\n+\n+ local procedure ProcessACYSettings(GLSetup: Record \"General Ledger Setup\")\n+ var\n+ ACYEnabledMsg: Label 'ACY is enabled: %1', Comment = '%1 = additional reporting currency';\n+ begin\n+ // Process additional currency settings\n+ Message(ACYEnabledMsg, GLSetup.\"Additional Reporting Currency\");\n+ end;\n+\n+ local procedure EnableCreditWarnings(SalesSetup: Record \"Sales & Receivables Setup\")\n+ var\n+ CreditWarningsEnabledMsg: Label 'Credit warnings are enabled';\n+ begin\n+ // Enable credit warning processing\n+ Message(CreditWarningsEnabledMsg);\n+ end;\n+}\ndiff --git a/src/TempBufferProcessor.Codeunit.al b/src/TempBufferProcessor.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/TempBufferProcessor.Codeunit.al\n@@ -0,0 +1,61 @@\n+codeunit 50210 \"Temp Buffer Processor\"\n+{\n+ procedure ProcessBufferEntries(var TempBuffer: Record \"Integer\" temporary)\n+ var\n+ ProcessedCount: Integer;\n+ TotalAmount: Decimal;\n+ ProcessedEntriesMsg: Label 'Processed %1 entries with total %2', Comment = '%1 = number of entries, %2 = total amount';\n+ begin\n+ // CORRECT: TempBuffer is temporary — all operations are in-memory, no SQL queries\n+ // Any access pattern (FindSet, Get, loops) on temp tables is performant\n+ ProcessedCount := 0;\n+ TotalAmount := 0;\n+\n+ if TempBuffer.FindSet() then\n+ repeat\n+ // This might look suspicious, but it's CORRECT because:\n+ // 1. TempBuffer is temporary (in-memory)\n+ // 2. No database round trips are happening\n+ // 3. All data is already loaded in memory\n+ TotalAmount += TempBuffer.Number;\n+ ProcessedCount += 1;\n+\n+ // Even modifying temp records in a loop is fine\n+ TempBuffer.Number := TempBuffer.Number * 2;\n+ TempBuffer.Modify();\n+\n+ until TempBuffer.Next() = 0;\n+\n+ Message(ProcessedEntriesMsg, ProcessedCount, TotalAmount);\n+ end;\n+\n+ procedure BuildTempData(var TempBuffer: Record \"Integer\" temporary)\n+ var\n+ i: Integer;\n+ begin\n+ // CORRECT: Building temp data - all operations are in-memory\n+ TempBuffer.Reset();\n+ TempBuffer.DeleteAll();\n+\n+ for i := 1 to 100 do begin\n+ TempBuffer.Init();\n+ TempBuffer.Number := Random(1000);\n+ TempBuffer.Insert();\n+ end;\n+ end;\n+\n+ procedure FindMaxValue(var TempBuffer: Record \"Integer\" temporary): Integer\n+ var\n+ MaxValue: Integer;\n+ begin\n+ // CORRECT: Finding max in temp table - no performance concern\n+ MaxValue := 0;\n+ if TempBuffer.FindSet() then\n+ repeat\n+ if TempBuffer.Number > MaxValue then\n+ MaxValue := TempBuffer.Number;\n+ until TempBuffer.Next() = 0;\n+\n+ exit(MaxValue);\n+ end;\n+}\ndiff --git a/src/CustomerLookup.Codeunit.al b/src/CustomerLookup.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerLookup.Codeunit.al\n@@ -0,0 +1,20 @@\n+codeunit 50212 \"Customer Lookup\"\n+{\n+ procedure GetCustomerName(CustomerNo: Code[20]): Text[100]\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.SetRange(\"No.\", CustomerNo);\n+ if Customer.FindFirst() then\n+ exit(Customer.Name);\n+ exit('');\n+ end;\n+\n+ procedure HasCustomersInCountry(CountryRegionCode: Code[10]): Boolean\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.SetRange(\"Country/Region Code\", CountryRegionCode);\n+ exit(Customer.Count() > 0);\n+ end;\n+}\n", "expected_comments": [{"file": "src/CustomerLookup.Codeunit.al", "line_start": 8, "line_end": 8, "body": "FindFirst() after SetRange on the full primary key (\"No.\") of Customer (up to 800k rows). This still does a SQL SELECT TOP 1 with a range predicate instead of a direct key lookup. — Replace with `if Customer.Get(CustomerNo) then exit(Customer.Name);` which is a direct PK lookup (CodeCop AA0233).", "severity": "medium", "domain": "performance"}, {"file": "src/CustomerLookup.Codeunit.al", "line_start": 18, "line_end": 18, "body": "Count() > 0 on Customer (up to 800k rows) for a pure existence check. Count() materializes a SQL COUNT(*) over the filtered set instead of stopping at the first matching row. — Replace with `exit(not Customer.IsEmpty());` which stops at the first match and is significantly cheaper on large tables.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "False positive performance findings: findset_false_positive (69 false positives). Agent flagged these but reviewers rejected them. Enriched with 2 true-positive findings in addition to the false-positive bait.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/MigrationSetupHandler.Codeunit.al b/src/MigrationSetupHandler.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/MigrationSetupHandler.Codeunit.al\n@@ -0,0 +1,18 @@\n+codeunit 50221 \"Migration Setup Handler\"\n+{\n+ procedure CountMigratablePermissionSets(): Integer\n+ var\n+ PermissionSet: Record \"Permission Set\";\n+ begin\n+ PermissionSet.SetFilter(\"Role ID\", '%1|%2', 'D365 BASIC', 'D365 READ');\n+ exit(PermissionSet.Count());\n+ end;\n+\n+ procedure CountObsoleteRegisters(): Integer\n+ var\n+ DateComprRegister: Record \"Date Compr. Register\";\n+ begin\n+ DateComprRegister.SetFilter(\"Ending Date\", '<%1', CalcDate('<-2Y>', Today));\n+ exit(DateComprRegister.Count());\n+ end;\n+}\ndiff --git a/src/PermissionSetListOverview.Page.al b/src/PermissionSetListOverview.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PermissionSetListOverview.Page.al\n@@ -0,0 +1,84 @@\n+page 50220 \"Permission Set List Overview\"\n+{\n+ PageType = List;\n+ ApplicationArea = All;\n+ UsageCategory = Administration;\n+ SourceTable = \"Aggregate Permission Set\";\n+ Caption = 'Permission Set List Overview';\n+ Editable = false;\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(Permissions)\n+ {\n+ field(\"Role ID\"; Rec.\"Role ID\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the identifier of the permission set.';\n+ }\n+\n+ field(Name; Rec.Name)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the display name of the permission set.';\n+ }\n+\n+ field(Scope; Rec.Scope)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies whether the permission set is defined by the system or by a tenant.';\n+ }\n+\n+ field(\"App Name\"; Rec.\"App Name\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the name of the extension that defines the permission set.';\n+ }\n+\n+ field(\"Permission Count\"; PermissionCount)\n+ {\n+ Caption = 'Permission Count';\n+ ApplicationArea = All;\n+ Editable = false;\n+ ToolTip = 'Specifies the number of permissions that belong to the permission set.';\n+ }\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(Processing)\n+ {\n+ action(RefreshCounts)\n+ {\n+ Caption = 'Refresh Permission Counts';\n+ ApplicationArea = All;\n+ ToolTip = 'Recalculates the permission count shown for each permission set.';\n+\n+ trigger OnAction()\n+ begin\n+ CurrPage.Update();\n+ end;\n+ }\n+ }\n+ }\n+\n+ var\n+ PermissionCount: Integer;\n+\n+ trigger OnAfterGetRecord()\n+ var\n+ Permission: Record Permission;\n+ begin\n+ Permission.SetRange(\"Role ID\", Rec.\"Role ID\");\n+ PermissionCount := Permission.Count();\n+ end;\n+\n+ trigger OnOpenPage()\n+ begin\n+ Rec.SetFilter(Scope, '%1|%2', Rec.Scope::System, Rec.Scope::Tenant);\n+ end;\n+}\ndiff --git a/src/SalesInvoiceFilter.Codeunit.al b/src/SalesInvoiceFilter.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SalesInvoiceFilter.Codeunit.al\n@@ -0,0 +1,26 @@\n+codeunit 50222 \"Sales Invoice Filter\"\n+{\n+ procedure ListLinesByDescription(Description: Text[100])\n+ var\n+ SalesInvoiceLine: Record \"Sales Invoice Line\";\n+ begin\n+ SalesInvoiceLine.SetRange(Description, Description);\n+ if SalesInvoiceLine.FindSet() then\n+ repeat\n+ Message('%1 %2', SalesInvoiceLine.\"Document No.\", SalesInvoiceLine.\"Line No.\");\n+ until SalesInvoiceLine.Next() = 0;\n+ end;\n+\n+ procedure SumQuantityByDocument(DocumentNo: Code[20]): Decimal\n+ var\n+ SalesInvoiceLine: Record \"Sales Invoice Line\";\n+ Total: Decimal;\n+ begin\n+ SalesInvoiceLine.SetRange(\"Document No.\", DocumentNo);\n+ if SalesInvoiceLine.FindSet() then\n+ repeat\n+ Total += SalesInvoiceLine.Quantity;\n+ until SalesInvoiceLine.Next() = 0;\n+ exit(Total);\n+ end;\n+}\n", "expected_comments": [{"file": "src/SalesInvoiceFilter.Codeunit.al", "line_start": 7, "line_end": 7, "body": "SetRange on Sales Invoice Line.Description with no SetCurrentKey and no key including Description. Sales Invoice Line is large (up to 3M rows) and the query will table-scan. — Either add `SetCurrentKey` to a key whose leading field matches the filter, or introduce a new key on the source table that covers Description, before filtering.", "severity": "high", "domain": "performance"}, {"file": "src/SalesInvoiceFilter.Codeunit.al", "line_start": 20, "line_end": 20, "body": "FindSet over Sales Invoice Line (3M rows, ~80 fields) loads every field for every row but the loop only reads `Quantity`. — Add `SalesInvoiceLine.SetLoadFields(Quantity);` before SetRange so SQL returns only the Quantity column (plus key fields). Even better, replace the loop with `SalesInvoiceLine.CalcSums(Quantity)` since Quantity is a SumIndexField on this table.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "False positive performance findings: index_false_positive (29 false positives). Agent flagged these but reviewers rejected them. Enriched with 2 true-positive findings in addition to the false-positive bait.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-004", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/BatchModifier.Codeunit.al b/src/BatchModifier.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/BatchModifier.Codeunit.al\n@@ -0,0 +1,22 @@\n+codeunit 50231 \"Batch Modifier FP\"\n+{\n+ procedure UpdateSetupDefaults(CompanyCode: Code[10])\n+ var\n+ FASetup: Record \"FA Setup\";\n+ begin\n+ // CORRECT: FA Setup is a singleton — direct Get + Modify is ideal\n+ if FASetup.Get() then begin\n+ FASetup.\"Default Depr. Book\" := CompanyCode;\n+ FASetup.Modify(false);\n+ end;\n+ end;\n+\n+ procedure CleanupObsoleteReasonCodes()\n+ var\n+ ReasonCode: Record \"Reason Code\";\n+ begin\n+ // CORRECT: DeleteAll is the proper bulk delete pattern\n+ ReasonCode.SetRange(Description, '');\n+ ReasonCode.DeleteAll(false);\n+ end;\n+}\ndiff --git a/src/CustomerReader.Codeunit.al b/src/CustomerReader.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerReader.Codeunit.al\n@@ -0,0 +1,12 @@\n+codeunit 50232 \"Customer Reader\"\n+{\n+ procedure GetCustomerCreditLimit(CustomerNo: Code[20]): Decimal\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.LockTable();\n+ if Customer.Get(CustomerNo) then\n+ exit(Customer.\"Credit Limit (LCY)\");\n+ exit(0);\n+ end;\n+}\n", "expected_comments": [{"file": "src/CustomerReader.Codeunit.al", "line_start": 7, "line_end": 7, "body": "LockTable() in a read-only helper that only Gets a Customer and returns a field. This acquires UPDLOCK for the rest of the transaction on every caller, causing unnecessary lock contention. — Remove LockTable() (Customer.Get is fine without it), or use `Customer.ReadIsolation := IsolationLevel::ReadCommitted` if a specific read isolation is desired.", "severity": "high", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "False positive performance findings: locking_fp. Correct Get+Modify on singleton setup table and DeleteAll for bulk delete. Enriched with one true-positive finding in addition to the false-positive bait.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/EnumIterator.Codeunit.al b/src/EnumIterator.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/EnumIterator.Codeunit.al\n@@ -0,0 +1,44 @@\n+codeunit 50240 \"Enum Iterator\"\n+{\n+ procedure CountMatchingStatusLines(var TempWorksheet: Record \"Integer\" temporary): Integer\n+ var\n+ AnalysisReportName: Record \"Analysis Report Name\";\n+ MatchCount: Integer;\n+ begin\n+ if TempWorksheet.FindSet() then\n+ repeat\n+ AnalysisReportName.SetRange(\"Analysis Area\", AnalysisReportName.\"Analysis Area\"::Sales);\n+ if AnalysisReportName.Get(Format(TempWorksheet.Number)) then\n+ MatchCount += 1;\n+ until TempWorksheet.Next() = 0;\n+ exit(MatchCount);\n+ end;\n+\n+ procedure CountConfiguredLines(var TempBuffer: Record \"Name/Value Buffer\" temporary): Integer\n+ var\n+ ConfigPackageTable: Record \"Config. Package Table\";\n+ ConfiguredCount: Integer;\n+ begin\n+ if TempBuffer.FindSet() then\n+ repeat\n+ ConfigPackageTable.SetRange(\"Table Name\", TempBuffer.Name);\n+ if not ConfigPackageTable.IsEmpty() then\n+ ConfiguredCount += 1;\n+ until TempBuffer.Next() = 0;\n+ exit(ConfiguredCount);\n+ end;\n+\n+ procedure CountCountriesWithCurrency(var TempCountryRegion: Record \"Country/Region\" temporary): Integer\n+ var\n+ Currency: Record Currency;\n+ WithCurrencyCount: Integer;\n+ begin\n+ if TempCountryRegion.FindSet() then\n+ repeat\n+ Currency.SetRange(\"ISO Code\", TempCountryRegion.\"ISO Code\");\n+ if not Currency.IsEmpty() then\n+ WithCurrencyCount += 1;\n+ until TempCountryRegion.Next() = 0;\n+ exit(WithCurrencyCount);\n+ end;\n+}\ndiff --git a/src/RoleProcessor.Codeunit.al b/src/RoleProcessor.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/RoleProcessor.Codeunit.al\n@@ -0,0 +1,27 @@\n+codeunit 50241 \"Role Processor\"\n+{\n+ procedure CountUsersWithRole(RoleId: Code[20]): Integer\n+ var\n+ AccessControl: Record \"Access Control\";\n+ begin\n+ AccessControl.SetRange(\"Role ID\", RoleId);\n+ exit(AccessControl.Count());\n+ end;\n+\n+ procedure CountDepartmentUsers(DepartmentCode: Code[20]): Integer\n+ var\n+ UserSetup: Record \"User Setup\";\n+ begin\n+ UserSetup.SetRange(\"Global Dimension 1 Code\", DepartmentCode);\n+ exit(UserSetup.Count());\n+ end;\n+\n+ procedure CountEnabledFullUsers(): Integer\n+ var\n+ User: Record User;\n+ begin\n+ User.SetRange(\"License Type\", User.\"License Type\"::\"Full User\");\n+ User.SetRange(State, User.State::Enabled);\n+ exit(User.Count());\n+ end;\n+}\ndiff --git a/src/ItemLedgerSummary.Codeunit.al b/src/ItemLedgerSummary.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ItemLedgerSummary.Codeunit.al\n@@ -0,0 +1,20 @@\n+codeunit 50242 \"Item Ledger Summary\"\n+{\n+ procedure BuildItemLedgerReport(ItemNo: Code[20]): Text\n+ var\n+ ItemLedgerEntry: Record \"Item Ledger Entry\";\n+ RecRef: RecordRef;\n+ FldRef: FieldRef;\n+ Result: Text;\n+ begin\n+ ItemLedgerEntry.SetRange(\"Item No.\", ItemNo);\n+ RecRef.GetTable(ItemLedgerEntry);\n+ if RecRef.FindSet() then\n+ repeat\n+ FldRef := RecRef.Field(ItemLedgerEntry.FieldNo(Quantity));\n+ Result += Format(FldRef.Value) + ';';\n+ Commit();\n+ until RecRef.Next() = 0;\n+ exit(Result);\n+ end;\n+}\n", "expected_comments": [{"file": "src/ItemLedgerSummary.Codeunit.al", "line_start": 14, "line_end": 14, "body": "RecordRef/FieldRef used inside a FindSet loop over Item Ledger Entry (up to 10M rows) when a typed Record is in scope. RecordRef/FieldRef are slower than direct typed access and offer no benefit here. — Iterate `ItemLedgerEntry` directly with `Format(ItemLedgerEntry.Quantity)`, or better, replace the loop with `ItemLedgerEntry.CalcSums(Quantity)` (Quantity is a SumIndexField on Item Ledger Entry).", "severity": "high", "domain": "performance"}, {"file": "src/ItemLedgerSummary.Codeunit.al", "line_start": 15, "line_end": 15, "body": "String concatenation with `Result += ...` inside a repeat..until loop allocates and copies the entire accumulated string on every iteration — O(n^2) memory and CPU for large record sets. — Use `TextBuilder` (declare `Result: TextBuilder;`, call `Result.Append(...)`, and `exit(Result.ToText())`) which appends in amortized O(1).", "severity": "medium", "domain": "performance"}, {"file": "src/ItemLedgerSummary.Codeunit.al", "line_start": 16, "line_end": 16, "body": "Commit() inside the repeat..until creates one transaction boundary per row. On Item Ledger Entry (up to 10M rows) this multiplies SQL transaction overhead and prevents the engine from batching writes. — Remove the in-loop Commit; if a single commit is needed after the iteration, place it after the `until ... Next() = 0;` line.", "severity": "high", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "False positive performance findings: loop_false_positive (81 false positives). Agent flagged these but reviewers rejected them. Enriched with 3 true-positive findings in addition to the false-positive bait.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-006", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/GenericFieldCopier.Codeunit.al b/src/GenericFieldCopier.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/GenericFieldCopier.Codeunit.al\n@@ -0,0 +1,34 @@\n+codeunit 50250 \"Generic Field Copier\"\n+{\n+ procedure CopyFieldsByRef(SourceRecRef: RecordRef; var DestRecRef: RecordRef)\n+ var\n+ SourceFldRef: FieldRef;\n+ DestFldRef: FieldRef;\n+ i: Integer;\n+ begin\n+ for i := 1 to SourceRecRef.FieldCount() do begin\n+ SourceFldRef := SourceRecRef.FieldIndex(i);\n+ if SourceFldRef.Class = FieldClass::Normal then\n+ if DestRecRef.FieldExist(SourceFldRef.Number) then begin\n+ DestFldRef := DestRecRef.Field(SourceFldRef.Number);\n+ if DestFldRef.Type = SourceFldRef.Type then\n+ DestFldRef.Value := SourceFldRef.Value;\n+ end;\n+ end;\n+ end;\n+\n+ procedure CountCopyableFields(SourceRecRef: RecordRef; DestRecRef: RecordRef): Integer\n+ var\n+ SourceFldRef: FieldRef;\n+ i: Integer;\n+ CopyableCount: Integer;\n+ begin\n+ for i := 1 to SourceRecRef.FieldCount() do begin\n+ SourceFldRef := SourceRecRef.FieldIndex(i);\n+ if SourceFldRef.Class = FieldClass::Normal then\n+ if DestRecRef.FieldExist(SourceFldRef.Number) then\n+ CopyableCount += 1;\n+ end;\n+ exit(CopyableCount);\n+ end;\n+}\ndiff --git a/src/MetadataReader.Codeunit.al b/src/MetadataReader.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/MetadataReader.Codeunit.al\n@@ -0,0 +1,36 @@\n+codeunit 50251 \"Metadata Reader\"\n+{\n+ procedure GetTableNames(): List of [Text]\n+ var\n+ TableMetadata: Record \"Table Metadata\";\n+ Names: List of [Text];\n+ begin\n+ TableMetadata.SetRange(TableType, TableMetadata.TableType::Normal);\n+ if TableMetadata.FindSet() then\n+ repeat\n+ Names.Add(TableMetadata.Name);\n+ until TableMetadata.Next() = 0;\n+ exit(Names);\n+ end;\n+\n+ procedure GetFieldNames(TableNo: Integer): List of [Text]\n+ var\n+ FieldMetadata: Record Field;\n+ FieldNames: List of [Text];\n+ begin\n+ FieldMetadata.SetRange(TableNo, TableNo);\n+ FieldMetadata.SetRange(Class, FieldMetadata.Class::Normal);\n+ if FieldMetadata.FindSet() then\n+ repeat\n+ FieldNames.Add(FieldMetadata.FieldName);\n+ until FieldMetadata.Next() = 0;\n+ exit(FieldNames);\n+ end;\n+\n+ procedure CheckTableExists(TableNo: Integer): Boolean\n+ var\n+ TableMetadata: Record \"Table Metadata\";\n+ begin\n+ exit(TableMetadata.Get(TableNo));\n+ end;\n+}\ndiff --git a/src/SalesLineQuantitySubscriber.Codeunit.al b/src/SalesLineQuantitySubscriber.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SalesLineQuantitySubscriber.Codeunit.al\n@@ -0,0 +1,27 @@\n+codeunit 50252 \"Sales Line Quantity Subscriber\"\n+{\n+ [EventSubscriber(ObjectType::Table, Database::\"Sales Line\", 'OnAfterValidateEvent', 'Quantity', false, false)]\n+ local procedure OnAfterValidateQuantity(var Rec: Record \"Sales Line\")\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.Get(Rec.\"No.\");\n+ if Item.\"Inventory Posting Group\" <> '' then\n+ UpdatePostingGroup(Rec, Item);\n+ end;\n+\n+ [EventSubscriber(ObjectType::Table, Database::\"Sales Line\", 'OnAfterValidateEvent', 'Unit Price', false, false)]\n+ local procedure OnAfterValidateUnitPrice(var Rec: Record \"Sales Line\")\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.Get(Rec.\"No.\");\n+ if Item.\"Price Includes VAT\" then\n+ Rec.\"Unit Price Excl. VAT\" := Rec.\"Unit Price\" / (1 + Rec.\"VAT %\" / 100);\n+ end;\n+\n+ local procedure UpdatePostingGroup(var SalesLine: Record \"Sales Line\"; Item: Record Item)\n+ begin\n+ SalesLine.\"Posting Group\" := Item.\"Inventory Posting Group\";\n+ end;\n+}\n", "expected_comments": [{"file": "src/SalesLineQuantitySubscriber.Codeunit.al", "line_start": 8, "line_end": 8, "body": "Item.Get(Rec.\"No.\") fires on every Quantity validation in Sales Line with no cheap guard. The subscriber runs even for Type::\"G/L Account\", Type::Resource, etc., issuing a DB lookup against Item (800k rows) that is then discarded. — Guard with the cheap typed check first: `if Rec.Type <> Rec.Type::Item then exit;` before Item.Get, and consider `Item.SetLoadFields(\"Inventory Posting Group\")` since only one field is read.", "severity": "high", "domain": "performance"}, {"file": "src/SalesLineQuantitySubscriber.Codeunit.al", "line_start": 18, "line_end": 18, "body": "Same N+1/no-guard pattern in the Unit Price subscriber: Item.Get on every Unit Price validation, regardless of Sales Line Type. Unit Price validation is one of the most frequently fired events on Sales Line, multiplying the cost. — Guard with `if Rec.Type <> Rec.Type::Item then exit;` first, and use `Item.SetLoadFields(\"Price Includes VAT\")` since only one field is read.", "severity": "high", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "False positive performance findings: other_performance (86 false positives). Agent flagged these but reviewers rejected them. Enriched with 2 true-positive findings in addition to the false-positive bait.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/TransactionProcessor.Codeunit.al b/src/TransactionProcessor.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/TransactionProcessor.Codeunit.al\n@@ -0,0 +1,21 @@\n+codeunit 50403 \"Transaction Processor\"\n+{\n+ procedure GetPaymentDiscount(TermsCode: Code[10]): Decimal\n+ var\n+ PaymentTerms: Record \"Payment Terms\";\n+ begin\n+ if PaymentTerms.Get(TermsCode) then\n+ exit(PaymentTerms.\"Discount %\");\n+ exit(0);\n+ end;\n+\n+ procedure GetReasonCodeDescription(ReasonCode: Code[10]): Text[100]\n+ var\n+ ReasonCodeRec: Record \"Reason Code\";\n+ begin\n+ ReasonCodeRec.ReadIsolation := IsolationLevel::ReadCommitted;\n+ if ReasonCodeRec.Get(ReasonCode) then\n+ exit(ReasonCodeRec.Description);\n+ exit('');\n+ end;\n+}\ndiff --git a/src/CustomerReportReader.Codeunit.al b/src/CustomerReportReader.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerReportReader.Codeunit.al\n@@ -0,0 +1,16 @@\n+codeunit 50404 \"Customer Report Reader\"\n+{\n+ procedure ExportActiveCustomerNumbers(): Text\n+ var\n+ Customer: Record Customer;\n+ Result: TextBuilder;\n+ begin\n+ Customer.SetRange(Blocked, Customer.Blocked::\" \");\n+ if Customer.FindSet(true) then\n+ repeat\n+ Result.Append(Customer.\"No.\");\n+ Result.Append(';');\n+ until Customer.Next() = 0;\n+ exit(Result.ToText());\n+ end;\n+}\n", "expected_comments": [{"file": "src/CustomerReportReader.Codeunit.al", "line_start": 9, "line_end": 9, "body": "FindSet(true) requests UpdLock on Customer rows for an iteration that only reads (\"No.\") and never modifies the records. This blocks other transactions reading or writing Customer for no benefit. — Use `Customer.FindSet()` (or `FindSet(false)`) for read-only iterations; reserve FindSet(true) for loops that actually call Modify.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "False positive performance findings: readisolation_fp. All LockTable and ReadIsolation patterns are correctly used for their respective write scenarios. Enriched with one true-positive finding in addition to the false-positive bait.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-008", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/CopyLocationHandler.Codeunit.al b/src/CopyLocationHandler.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CopyLocationHandler.Codeunit.al\n@@ -0,0 +1,68 @@\n+codeunit 50261 \"Copy Location Handler FP\"\n+{\n+ procedure CopyLocation(SourceCode: Code[10]; TargetCode: Code[10])\n+ var\n+ SourceLocation: Record Location;\n+ TargetLocation: Record Location;\n+ begin\n+ // CORRECT: SetLoadFields before Get — only loads the fields we actually copy\n+ SourceLocation.SetLoadFields(Name, Address, \"Address 2\", City, \"Post Code\", \"Country/Region Code\", \"Phone No.\", Contact);\n+ if not SourceLocation.Get(SourceCode) then\n+ exit;\n+\n+ TargetLocation.Init();\n+ TargetLocation.Code := TargetCode;\n+ TargetLocation.Name := SourceLocation.Name;\n+ TargetLocation.Address := SourceLocation.Address;\n+ TargetLocation.\"Address 2\" := SourceLocation.\"Address 2\";\n+ TargetLocation.City := SourceLocation.City;\n+ TargetLocation.\"Post Code\" := SourceLocation.\"Post Code\";\n+ TargetLocation.\"Country/Region Code\" := SourceLocation.\"Country/Region Code\";\n+ TargetLocation.\"Phone No.\" := SourceLocation.\"Phone No.\";\n+ TargetLocation.Contact := SourceLocation.Contact;\n+ TargetLocation.Insert(true);\n+ end;\n+\n+ procedure CopyCustomer(SourceNo: Code[20]; TargetNo: Code[20])\n+ var\n+ SourceCustomer: Record Customer;\n+ TargetCustomer: Record Customer;\n+ begin\n+ // CORRECT: SetLoadFields on Customer (800k rows, 100+ fields) — only loads needed subset\n+ SourceCustomer.SetLoadFields(Name, \"Name 2\", Address, \"Address 2\", City, \"Post Code\",\n+ \"Country/Region Code\", \"Phone No.\", \"Customer Posting Group\",\n+ \"Gen. Bus. Posting Group\", \"Payment Terms Code\", \"Payment Method Code\");\n+ if not SourceCustomer.Get(SourceNo) then\n+ exit;\n+\n+ TargetCustomer.Init();\n+ TargetCustomer.\"No.\" := TargetNo;\n+ TargetCustomer.Name := SourceCustomer.Name;\n+ TargetCustomer.\"Name 2\" := SourceCustomer.\"Name 2\";\n+ TargetCustomer.Address := SourceCustomer.Address;\n+ TargetCustomer.\"Address 2\" := SourceCustomer.\"Address 2\";\n+ TargetCustomer.City := SourceCustomer.City;\n+ TargetCustomer.\"Post Code\" := SourceCustomer.\"Post Code\";\n+ TargetCustomer.\"Country/Region Code\" := SourceCustomer.\"Country/Region Code\";\n+ TargetCustomer.\"Phone No.\" := SourceCustomer.\"Phone No.\";\n+ TargetCustomer.\"Customer Posting Group\" := SourceCustomer.\"Customer Posting Group\";\n+ TargetCustomer.\"Gen. Bus. Posting Group\" := SourceCustomer.\"Gen. Bus. Posting Group\";\n+ TargetCustomer.\"Payment Terms Code\" := SourceCustomer.\"Payment Terms Code\";\n+ TargetCustomer.\"Payment Method Code\" := SourceCustomer.\"Payment Method Code\";\n+ TargetCustomer.Insert(true);\n+ end;\n+\n+ procedure BackupLocationData(LocationCode: Code[10]): Text\n+ var\n+ Location: Record Location;\n+ begin\n+ // CORRECT: SetLoadFields — only 5 of ~40 fields needed for backup\n+ Location.SetLoadFields(Name, Address, City, \"Country/Region Code\");\n+ if not Location.Get(LocationCode) then\n+ exit('');\n+\n+ exit(StrSubstNo('%1|%2|%3|%4|%5',\n+ Location.Code, Location.Name, Location.Address,\n+ Location.City, Location.\"Country/Region Code\"));\n+ end;\n+}\ndiff --git a/src/SetupValidator.Codeunit.al b/src/SetupValidator.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SetupValidator.Codeunit.al\n@@ -0,0 +1,102 @@\n+codeunit 50260 \"Setup Validator FP\"\n+{\n+ procedure ValidateSetup()\n+ var\n+ FASetup: Record \"FA Setup\";\n+ GLSetup: Record \"General Ledger Setup\";\n+ SalesSetup: Record \"Sales & Receivables Setup\";\n+ PurchSetup: Record \"Purchases & Payables Setup\";\n+ InventorySetup: Record \"Inventory Setup\";\n+ begin\n+ // CORRECT: Setup tables are singletons (1 record per company)\n+ // Get() on singleton tables is always appropriate and fast\n+\n+ FASetup.Get();\n+ FASetup.TestField(\"Default Depr. Book\");\n+\n+ GLSetup.Get();\n+ GLSetup.TestField(\"LCY Code\");\n+ GLSetup.TestField(\"Posting Allowed From\");\n+ GLSetup.TestField(\"Posting Allowed To\");\n+\n+ SalesSetup.Get();\n+ SalesSetup.TestField(\"Customer Nos.\");\n+\n+ PurchSetup.Get();\n+ PurchSetup.TestField(\"Vendor Nos.\");\n+\n+ InventorySetup.Get();\n+ InventorySetup.TestField(\"Item Nos.\");\n+\n+ // Validate cross-setup consistency\n+ ValidateCurrencyConsistency(GLSetup, SalesSetup);\n+ end;\n+\n+ procedure GetLocationName(LocationCode: Code[10]): Text\n+ var\n+ Location: Record Location;\n+ begin\n+ // CORRECT: SetLoadFields + Get is the Microsoft-recommended pattern\n+ // This loads only the required fields, making it more efficient than full Get\n+ Location.SetLoadFields(Name);\n+ if Location.Get(LocationCode) then\n+ exit(Location.Name);\n+ exit('');\n+ end;\n+\n+ procedure GetCustomerInfo(CustomerNo: Code[20]; var Name: Text; var CreditLimit: Decimal)\n+ var\n+ Customer: Record Customer;\n+ begin\n+ // CORRECT: SetLoadFields pattern for loading specific fields only\n+ Customer.SetLoadFields(Name, \"Credit Limit (LCY)\");\n+ if Customer.Get(CustomerNo) then begin\n+ Name := Customer.Name;\n+ CreditLimit := Customer.\"Credit Limit (LCY)\";\n+ end else begin\n+ Name := '';\n+ CreditLimit := 0;\n+ end;\n+ end;\n+\n+ procedure GetVendorPaymentTerms(VendorNo: Code[20]): Code[10]\n+ var\n+ Vendor: Record Vendor;\n+ begin\n+ // CORRECT: Loading only the specific field needed\n+ Vendor.SetLoadFields(\"Payment Terms Code\");\n+ if Vendor.Get(VendorNo) then\n+ exit(Vendor.\"Payment Terms Code\");\n+ exit('');\n+ end;\n+\n+ local procedure ValidateCurrencyConsistency(GLSetup: Record \"General Ledger Setup\"; SalesSetup: Record \"Sales & Receivables Setup\")\n+ var\n+ Currency: Record Currency;\n+ begin\n+ // CORRECT: Single Get() call for validation\n+ if GLSetup.\"Additional Reporting Currency\" <> '' then begin\n+ Currency.Get(GLSetup.\"Additional Reporting Currency\");\n+ Currency.TestField(\"Amount Rounding Precision\");\n+ end;\n+ end;\n+\n+ procedure ValidateNumberSeries()\n+ var\n+ SalesSetup: Record \"Sales & Receivables Setup\";\n+ NoSeries: Record \"No. Series\";\n+ begin\n+ // CORRECT: Setup validation with related record checks\n+ SalesSetup.Get();\n+\n+ if SalesSetup.\"Customer Nos.\" <> '' then begin\n+ NoSeries.Get(SalesSetup.\"Customer Nos.\");\n+ NoSeries.TestField(\"Default Nos.\", true);\n+ end;\n+\n+ if SalesSetup.\"Invoice Nos.\" <> '' then begin\n+ NoSeries.Get(SalesSetup.\"Invoice Nos.\");\n+ NoSeries.TestField(\"Default Nos.\", true);\n+ end;\n+ end;\n+}\ndiff --git a/src/CustomerNameBuilder.Codeunit.al b/src/CustomerNameBuilder.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerNameBuilder.Codeunit.al\n@@ -0,0 +1,38 @@\n+codeunit 50262 \"Customer Name Builder\"\n+{\n+ procedure BuildCustomerNameList(CountryRegionCode: Code[10]): Text\n+ var\n+ Customer: Record Customer;\n+ Result: TextBuilder;\n+ begin\n+ Customer.SetRange(\"Country/Region Code\", CountryRegionCode);\n+ if Customer.FindSet() then\n+ repeat\n+ Result.Append(Customer.Name);\n+ Result.Append(';');\n+ until Customer.Next() = 0;\n+ exit(Result.ToText());\n+ end;\n+\n+ procedure BuildItemDescriptionList(InventoryPostingGroup: Code[20]): Text\n+ var\n+ Item: Record Item;\n+ Result: TextBuilder;\n+ begin\n+ Item.SetRange(\"Inventory Posting Group\", InventoryPostingGroup);\n+ if Item.FindSet() then\n+ repeat\n+ Result.Append(Item.Description);\n+ Result.Append(';');\n+ until Item.Next() = 0;\n+ exit(Result.ToText());\n+ end;\n+\n+ procedure GetCustomerNameFromLedgerEntry(CustLedgerEntry: Record \"Cust. Ledger Entry\"): Text[100]\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.Get(CustLedgerEntry.\"Customer No.\");\n+ exit(Customer.Name);\n+ end;\n+}\n", "expected_comments": [{"file": "src/CustomerNameBuilder.Codeunit.al", "line_start": 9, "line_end": 9, "body": "FindSet over Customer (800k rows, 100+ fields) loads every field for every row but the loop only reads `Customer.Name`. — Add `Customer.SetLoadFields(Name);` before SetRange so SQL returns only the Name column (plus key fields) — the gain scales with the row count and is significant for hot tables like Customer.", "severity": "high", "domain": "performance"}, {"file": "src/CustomerNameBuilder.Codeunit.al", "line_start": 23, "line_end": 23, "body": "Same anti-pattern on Item (800k rows, ~80 fields): FindSet loads every field but the loop only reads `Item.Description`. — Add `Item.SetLoadFields(Description);` before SetRange to limit the SQL projection to a single column.", "severity": "high", "domain": "performance"}, {"file": "src/CustomerNameBuilder.Codeunit.al", "line_start": 35, "line_end": 35, "body": "Customer.Get on a wide table (800k rows, 100+ fields) when only `Name` is returned. The full record is loaded just to read one field. — Add `Customer.SetLoadFields(Name);` before the Get to load only the field that's actually used.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "False positive performance findings: record_loading_fp (28 false positives). Agent flagged these but reviewers rejected them. Enriched with 3 true-positive findings in addition to the false-positive bait.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/NameValueBufferAPI.Page.al b/src/NameValueBufferAPI.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/NameValueBufferAPI.Page.al\n@@ -0,0 +1,63 @@\n+page 50271 \"Name Value Buffer API\"\n+{\n+ PageType = API;\n+ APIGroup = 'configuration';\n+ APIVersion = 'v1.0';\n+ EntityName = 'nameValueBuffer';\n+ EntitySetName = 'nameValueBuffers';\n+ SourceTable = \"Name/Value Buffer\";\n+ SourceTableTemporary = true;\n+ DelayedInsert = true;\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(Buffers)\n+ {\n+ field(id; Rec.ID)\n+ {\n+ Caption = 'ID';\n+ }\n+\n+ field(name; Rec.Name)\n+ {\n+ Caption = 'Name';\n+ }\n+\n+ field(value; Rec.Value)\n+ {\n+ Caption = 'Value';\n+ }\n+\n+ field(valueLong; Rec.\"Value BLOB\")\n+ {\n+ Caption = 'Value Long';\n+ }\n+ }\n+ }\n+ }\n+\n+ trigger OnInsertRecord(BelowxRec: Boolean): Boolean\n+ var\n+ NextID: Integer;\n+ begin\n+ if Rec.FindLast() then\n+ NextID := Rec.ID + 1\n+ else\n+ NextID := 1;\n+\n+ Rec.ID := NextID;\n+ exit(true);\n+ end;\n+\n+ trigger OnModifyRecord(): Boolean\n+ begin\n+ if Rec.Name = '' then\n+ Error(NameEmptyErr);\n+ exit(true);\n+ end;\n+\n+ var\n+ NameEmptyErr: Label 'Name cannot be empty';\n+}\ndiff --git a/src/RecordSetAggregator.Codeunit.al b/src/RecordSetAggregator.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/RecordSetAggregator.Codeunit.al\n@@ -0,0 +1,33 @@\n+codeunit 50270 \"Record Set Aggregator\"\n+{\n+ Access = Internal;\n+\n+ procedure CalculateOutstandingTotal(var TempSalesLine: Record \"Sales Line\" temporary): Decimal\n+ var\n+ Total: Decimal;\n+ begin\n+ if TempSalesLine.FindSet() then\n+ repeat\n+ TempSalesLine.CalcFields(\"Outstanding Amount\");\n+ Total += TempSalesLine.\"Outstanding Amount\";\n+ until TempSalesLine.Next() = 0;\n+ exit(Total);\n+ end;\n+\n+ procedure FindMaxUnitPrice(var TempItem: Record Item temporary): Decimal\n+ var\n+ MaxUnitPrice: Decimal;\n+ begin\n+ if TempItem.FindSet() then\n+ repeat\n+ if TempItem.\"Unit Price\" > MaxUnitPrice then\n+ MaxUnitPrice := TempItem.\"Unit Price\";\n+ until TempItem.Next() = 0;\n+ exit(MaxUnitPrice);\n+ end;\n+\n+ procedure CountAnalysisEntries(var TempAnalysisReportChartSetup: Record \"Analysis Report Chart Setup\" temporary): Integer\n+ begin\n+ exit(TempAnalysisReportChartSetup.Count());\n+ end;\n+}\ndiff --git a/src/OutboxEmailAPI.Page.al b/src/OutboxEmailAPI.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/OutboxEmailAPI.Page.al\n@@ -0,0 +1,34 @@\n+page 50272 \"Outbox Email API\"\n+{\n+ PageType = API;\n+ APIGroup = 'email';\n+ APIVersion = 'v1.0';\n+ EntityName = 'outboxEmail';\n+ EntitySetName = 'outboxEmails';\n+ SourceTable = \"Email Outbox\";\n+ DelayedInsert = true;\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(Outbox)\n+ {\n+ field(id; Rec.Id)\n+ {\n+ Caption = 'Id';\n+ }\n+\n+ field(description; Rec.Description)\n+ {\n+ Caption = 'Description';\n+ }\n+\n+ field(status; Rec.Status)\n+ {\n+ Caption = 'Status';\n+ }\n+ }\n+ }\n+ }\n+}\n", "expected_comments": [{"file": "src/OutboxEmailAPI.Page.al", "line_start": 8, "line_end": 8, "body": "API page on \"Email Outbox\" declares SourceTable without `SourceTableTemporary = true`. API pages are hit by external callers at high frequency; backing them with a persistent table puts unnecessary load on the database and on the underlying table (which is meant to be staged in-memory for API responses). — Add `SourceTableTemporary = true;` so the page operates in-memory like the sibling Name/Value Buffer API page in this change.", "severity": "high", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "False positive performance findings: temp_table_fp (3 false positives). Agent flagged these but reviewers rejected them. Enriched with one true-positive finding in addition to the false-positive bait.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-010", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/PaymentToleranceMgt.Codeunit.al b/src/PaymentToleranceMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PaymentToleranceMgt.Codeunit.al\n@@ -0,0 +1,29 @@\n+codeunit 50101 \"Payment Tolerance Mgt.\"\n+{\n+ Access = Internal;\n+\n+ procedure ApplyTolerances(DocumentNo: Code[20])\n+ var\n+ SalesLine: Record \"Sales Line\";\n+ NewPrice: Decimal;\n+ begin\n+ if DocumentNo = '' then\n+ exit;\n+\n+ NewPrice := GetDefaultUnitPrice();\n+\n+ SalesLine.SetRange(\"Document No.\", DocumentNo);\n+ SalesLine.SetRange(Type, SalesLine.Type::Item);\n+\n+ if SalesLine.FindSet() then\n+ repeat\n+ SalesLine.Validate(\"Unit Price\", NewPrice);\n+ SalesLine.Modify(true);\n+ until SalesLine.Next() = 0;\n+ end;\n+\n+ local procedure GetDefaultUnitPrice(): Decimal\n+ begin\n+ exit(10.00);\n+ end;\n+}\n", "expected_comments": [{"file": "src/PaymentToleranceMgt.Codeunit.al", "line_start": 19, "line_end": 19, "body": "Loop + Validate + Modify(true) on Sales Line to update a single field ('Unit Price'). ModifyAll would execute as a single SQL UPDATE statement and be significantly faster. — Replace the FindSet() + Modify loop with SalesLine.ModifyAll(\"Unit Price\", NewPrice).", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: bulk_operations — loop + Modify anti-pattern that should use ModifyAll.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-011", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/AgentTaskViewer.Page.al b/src/AgentTaskViewer.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AgentTaskViewer.Page.al\n@@ -0,0 +1,29 @@\n+page 50302 \"Agent Task Viewer\"\n+{\n+ PageType = List;\n+ SourceTable = \"Agent Task\";\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(Tasks)\n+ {\n+ field(\"Task ID\"; Rec.\"Task ID\") { ApplicationArea = All; ToolTip = 'Specifies the unique identifier of the agent task.'; }\n+ field(Status; Rec.Status) { ApplicationArea = All; ToolTip = 'Specifies the current status of the agent task.'; }\n+ field(InputPreview; InputPreview) { ApplicationArea = All; ToolTip = 'Specifies a preview of the task input data.'; Caption = 'Input Preview'; }\n+ field(OutputPreview; OutputPreview) { ApplicationArea = All; ToolTip = 'Specifies a preview of the task output data.'; Caption = 'Output Preview'; }\n+ }\n+ }\n+ }\n+ trigger OnAfterGetRecord()\n+ begin\n+ Rec.CalcFields(\"Input Data\");\n+ Rec.CalcFields(\"Output Data\");\n+ InputPreview := CopyStr(Rec.GetInputText(), 1, 100);\n+ OutputPreview := CopyStr(Rec.GetOutputText(), 1, 100);\n+ end;\n+\n+ var\n+ InputPreview: Text[100];\n+ OutputPreview: Text[100];\n+}\ndiff --git a/src/BillingOverview.Page.al b/src/BillingOverview.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/BillingOverview.Page.al\n@@ -0,0 +1,25 @@\n+page 50300 \"Billing Overview\"\n+{\n+ PageType = List;\n+ SourceTable = \"Sales Invoice Header\";\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(Lines)\n+ {\n+ field(\"No.\"; Rec.\"No.\") { ApplicationArea = All; ToolTip = 'Specifies the number of the posted sales invoice.'; }\n+ field(\"Sell-to Customer Name\"; Rec.\"Sell-to Customer Name\") { ApplicationArea = All; ToolTip = 'Specifies the name of the customer.'; }\n+ field(Amount; Rec.Amount) { ApplicationArea = All; ToolTip = 'Specifies the invoice amount excluding VAT.'; }\n+ field(\"Amount Including VAT\"; Rec.\"Amount Including VAT\") { ApplicationArea = All; ToolTip = 'Specifies the invoice amount including VAT.'; }\n+ field(\"Remaining Amount\"; Rec.\"Remaining Amount\") { ApplicationArea = All; ToolTip = 'Specifies the remaining unpaid amount.'; }\n+ }\n+ }\n+ }\n+ trigger OnAfterGetRecord()\n+ begin\n+ Rec.CalcFields(Amount);\n+ Rec.CalcFields(\"Amount Including VAT\");\n+ Rec.CalcFields(\"Remaining Amount\");\n+ end;\n+}\ndiff --git a/src/WarehousePickProcessor.Codeunit.al b/src/WarehousePickProcessor.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/WarehousePickProcessor.Codeunit.al\n@@ -0,0 +1,20 @@\n+codeunit 50301 \"Warehouse Pick Processor\"\n+{\n+ procedure ProcessPickLines(WarehouseActivityNo: Code[20])\n+ var\n+ WarehouseActivityLine: Record \"Warehouse Activity Line\";\n+ begin\n+ WarehouseActivityLine.SetRange(\"Activity Type\", WarehouseActivityLine.\"Activity Type\"::Pick);\n+ WarehouseActivityLine.SetRange(\"No.\", WarehouseActivityNo);\n+ if WarehouseActivityLine.FindSet() then\n+ repeat\n+ WarehouseActivityLine.CalcFields(\"Qty. Outstanding (Base)\");\n+ if WarehouseActivityLine.\"Qty. Outstanding (Base)\" > 0 then\n+ ProcessOutstandingLine(WarehouseActivityLine);\n+ until WarehouseActivityLine.Next() = 0;\n+ end;\n+\n+ local procedure ProcessOutstandingLine(var WarehouseActivityLine: Record \"Warehouse Activity Line\")\n+ begin\n+ end;\n+}\n", "expected_comments": [{"file": "src/AgentTaskViewer.Page.al", "line_start": 20, "line_end": 20, "body": "CalcFields on BLOB fields in OnAfterGetRecord is expensive and fires per row. Consider loading BLOBs only when needed or use streaming. — ", "severity": "low", "domain": "performance"}, {"file": "src/BillingOverview.Page.al", "line_start": 21, "line_end": 21, "body": "Multiple CalcFields calls in OnAfterGetRecord trigger can cause N+1 query problems on large datasets. Consider combining into single CalcFields call or use SetLoadFields. — ", "severity": "low", "domain": "performance"}, {"file": "src/WarehousePickProcessor.Codeunit.al", "line_start": 11, "line_end": 11, "body": "CalcFields inside repeat loop creates separate database queries for each record. Use SetLoadFields before FindSet or batch CalcFields operations. — ", "severity": "low", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: CalcFields in loops and OnAfterGetRecord", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-012", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/JournalPostConfirm.Codeunit.al b/src/JournalPostConfirm.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/JournalPostConfirm.Codeunit.al\n@@ -0,0 +1,18 @@\n+codeunit 50331 \"Journal Post Confirm\"\n+{\n+ procedure PostSelectedLines(var GenJournalLine: Record \"Gen. Journal Line\")\n+ begin\n+ GenJournalLine.SetRange(\"Journal Template Name\", GenJournalLine.\"Journal Template Name\");\n+ GenJournalLine.SetRange(\"Journal Batch Name\", GenJournalLine.\"Journal Batch Name\");\n+ if GenJournalLine.FindSet(true) then\n+ repeat\n+ if Confirm(PostLineQst, true, GenJournalLine.\"Line No.\", GenJournalLine.Amount) then begin\n+ GenJournalLine.\"Ready to Post\" := true;\n+ GenJournalLine.Modify();\n+ end;\n+ until GenJournalLine.Next() = 0;\n+ end;\n+\n+ var\n+ PostLineQst: Label 'Post line %1 for amount %2?', Comment = '%1 = line number, %2 = amount';\n+}\ndiff --git a/src/ServiceRegisterBatch.Codeunit.al b/src/ServiceRegisterBatch.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ServiceRegisterBatch.Codeunit.al\n@@ -0,0 +1,14 @@\n+codeunit 50330 \"Service Register Batch\"\n+{\n+ procedure ProcessServiceRegisters()\n+ var\n+ ServiceRegister: Record \"Service Register\";\n+ begin\n+ if ServiceRegister.FindSet(true) then\n+ repeat\n+ ServiceRegister.\"Upgraded\" := true;\n+ ServiceRegister.Modify(false);\n+ Commit();\n+ until ServiceRegister.Next() = 0;\n+ end;\n+}\n", "expected_comments": [{"file": "src/JournalPostConfirm.Codeunit.al", "line_start": 9, "line_end": 9, "body": "Confirm dialog inside loop holds database locks while waiting for user input. Move confirmation outside loop or batch user decisions. — ", "severity": "low", "domain": "performance"}, {"file": "src/ServiceRegisterBatch.Codeunit.al", "line_start": 11, "line_end": 11, "body": "Commit() inside loop creates excessive transaction boundaries and reduces batch performance. Consider committing in batches or after loop completion. — ", "severity": "low", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: Commit in loops and UI blocking operations", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-013", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/AgentActivities.Page.al b/src/AgentActivities.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AgentActivities.Page.al\n@@ -0,0 +1,36 @@\n+page 50310 \"Agent Activities\"\n+{\n+ PageType = CardPart;\n+ SourceTable = \"Agent Activities Cue\";\n+ layout\n+ {\n+ area(Content)\n+ {\n+ cuegroup(AgentCues)\n+ {\n+ field(HasPending; HasPendingDocs) { ApplicationArea = All; Caption = 'Has Pending'; ToolTip = 'Specifies whether there are pending documents to process.'; }\n+ field(ProcessedToday; ProcessedTodayCount) { ApplicationArea = All; Caption = 'Processed Today'; ToolTip = 'Specifies how many documents were processed today.'; }\n+ }\n+ }\n+ }\n+ trigger OnAfterGetRecord()\n+ begin\n+ CalcCounts();\n+ end;\n+\n+ local procedure CalcCounts()\n+ var\n+ SalesInvHeader: Record \"Sales Invoice Header\";\n+ SalesInvLine: Record \"Sales Invoice Line\";\n+ begin\n+ SalesInvHeader.SetRange(\"Posting Date\", Today);\n+ HasPendingDocs := SalesInvHeader.Count() > 0;\n+\n+ SalesInvLine.SetRange(\"Posting Date\", Today);\n+ ProcessedTodayCount := SalesInvLine.Count();\n+ end;\n+\n+ var\n+ HasPendingDocs: Boolean;\n+ ProcessedTodayCount: Integer;\n+}\ndiff --git a/src/KPICalculator.Codeunit.al b/src/KPICalculator.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/KPICalculator.Codeunit.al\n@@ -0,0 +1,9 @@\n+codeunit 50311 \"KPI Calculator\"\n+{\n+ procedure HasEnoughItems(): Boolean\n+ var\n+ Item: Record Item;\n+ begin\n+ exit(Item.Count() > 0);\n+ end;\n+}\n", "expected_comments": [{"file": "src/AgentActivities.Page.al", "line_start": 27, "line_end": 27, "body": "Count() > 0 on Sales Invoice Header (up to 300k rows) to check existence. Use IsEmpty instead — it stops at the first record found. — ", "severity": "low", "domain": "performance"}, {"file": "src/KPICalculator.Codeunit.al", "line_start": 7, "line_end": 7, "body": "Count() > 0 on Item (up to 800k rows) for a simple existence check. Use not Item.IsEmpty() instead. — ", "severity": "low", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: Count() misuse on large tables", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-014", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/DataProcessor.Codeunit.al b/src/DataProcessor.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/DataProcessor.Codeunit.al\n@@ -0,0 +1,79 @@\n+codeunit 50402 \"Data Processor\"\n+{\n+ Access = Internal;\n+\n+ procedure CalculateTotalsWithTempTable(var SalesLine: Record \"Sales Line\"): Decimal\n+ var\n+ TempItemCache: Record Item temporary;\n+ Item: Record Item;\n+ UnitCost: Decimal;\n+ TotalCost: Decimal;\n+ begin\n+ if SalesLine.FindSet() then\n+ repeat\n+ if not TempItemCache.Get(SalesLine.\"No.\") then begin\n+ Item.SetLoadFields(\"Unit Cost\");\n+ Item.Get(SalesLine.\"No.\");\n+ TempItemCache.Init();\n+ TempItemCache := Item;\n+ TempItemCache.Insert();\n+ end;\n+ UnitCost := TempItemCache.\"Unit Cost\";\n+ TotalCost += SalesLine.Quantity * UnitCost;\n+ until SalesLine.Next() = 0;\n+ exit(TotalCost);\n+ end;\n+\n+ procedure CalculateTotalsWithDictionary(var SalesLine: Record \"Sales Line\"): Decimal\n+ var\n+ UnitCostCache: Dictionary of [Code[20], Decimal];\n+ Item: Record Item;\n+ UnitCost: Decimal;\n+ TotalCost: Decimal;\n+ begin\n+ if SalesLine.FindSet() then\n+ repeat\n+ if not UnitCostCache.ContainsKey(SalesLine.\"No.\") then begin\n+ Item.SetLoadFields(\"Unit Cost\");\n+ Item.Get(SalesLine.\"No.\");\n+ UnitCostCache.Add(SalesLine.\"No.\", Item.\"Unit Cost\");\n+ end;\n+ UnitCost := UnitCostCache.Get(SalesLine.\"No.\");\n+ TotalCost += SalesLine.Quantity * UnitCost;\n+ until SalesLine.Next() = 0;\n+ exit(TotalCost);\n+ end;\n+\n+ procedure BuildCurrencyMap(var CurrencyMap: Dictionary of [Code[10], Text[30]])\n+ var\n+ Currency: Record Currency;\n+ begin\n+ Clear(CurrencyMap);\n+ if Currency.FindSet() then\n+ repeat\n+ CurrencyMap.Add(Currency.Code, Currency.Description);\n+ until Currency.Next() = 0;\n+ end;\n+\n+ procedure GetCustomerCurrency(CustomerNo: Code[20]): Code[10]\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.SetLoadFields(\"Currency Code\");\n+ if Customer.Get(CustomerNo) then\n+ exit(Customer.\"Currency Code\");\n+ exit('');\n+ end;\n+\n+ procedure ApplyDiscounts(var SalesLine: Record \"Sales Line\"; DiscountPct: Decimal)\n+ var\n+ LineAmount: Decimal;\n+ begin\n+ if SalesLine.FindSet(true) then\n+ repeat\n+ LineAmount := SalesLine.\"Line Amount\" * (1 - DiscountPct / 100);\n+ SalesLine.Validate(\"Line Amount\", LineAmount);\n+ SalesLine.Modify(true);\n+ until SalesLine.Next() = 0;\n+ end;\n+}\n", "expected_comments": [{"file": "src/DataProcessor.Codeunit.al", "line_start": 14, "line_end": 14, "body": "Temporary table used as a key-value lookup cache inside a loop. Temp table Get() performs a linear scan, whereas Dictionary of [Code[20], Decimal] provides O(1) lookups. — Replace the temporary table cache with a Dictionary of [Code[20], Decimal]. Use Dictionary.ContainsKey() and Dictionary.Add() for O(1) cache insertions and lookups.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: dictionary_lookup (1 finding). Temporary table used as a lookup cache when Dictionary would provide O(1) lookups instead of linear scans.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-015", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/ContactMgt.Codeunit.al b/src/ContactMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ContactMgt.Codeunit.al\n@@ -0,0 +1,43 @@\n+codeunit 50110 \"Contact Management\"\n+{\n+ Access = Internal;\n+\n+ procedure ContactToVendBusinessRelationExist(ContactNo: Code[20]): Boolean\n+ var\n+ ContactBusinessRelation: Record \"Contact Business Relation\";\n+ begin\n+ if ContactNo = '' then\n+ exit(false);\n+\n+ ContactBusinessRelation.SetRange(\"Contact No.\", ContactNo);\n+ ContactBusinessRelation.SetRange(\"Link to Table\", ContactBusinessRelation.\"Link to Table\"::Vendor);\n+\n+ exit(ContactBusinessRelation.FindFirst());\n+ end;\n+\n+ procedure GetVendorBusinessRelations(ContactNo: Code[20]; var TempContactBusinessRelation: Record \"Contact Business Relation\" temporary)\n+ var\n+ ContactBusinessRelation: Record \"Contact Business Relation\";\n+ begin\n+ TempContactBusinessRelation.DeleteAll();\n+\n+ ContactBusinessRelation.SetRange(\"Contact No.\", ContactNo);\n+ ContactBusinessRelation.SetRange(\"Link to Table\", ContactBusinessRelation.\"Link to Table\"::Vendor);\n+\n+ if ContactBusinessRelation.FindSet() then\n+ repeat\n+ TempContactBusinessRelation := ContactBusinessRelation;\n+ TempContactBusinessRelation.Insert();\n+ until ContactBusinessRelation.Next() = 0;\n+ end;\n+\n+ procedure ValidateContactCompany(ContactNo: Code[20]): Boolean\n+ var\n+ Contact: Record Contact;\n+ begin\n+ Contact.SetLoadFields(Type);\n+ if Contact.Get(ContactNo) then\n+ exit(Contact.Type = Contact.Type::Company);\n+ exit(false);\n+ end;\n+}\ndiff --git a/src/InstallAppCZL.Codeunit.al b/src/InstallAppCZL.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/InstallAppCZL.Codeunit.al\n@@ -0,0 +1,44 @@\n+codeunit 50111 \"Install Application CZL\"\n+{\n+ Access = Internal;\n+\n+ procedure MigrateVATEntries()\n+ var\n+ VATEntry: Record \"VAT Entry\";\n+ ProcessedCount: Integer;\n+ begin\n+ VATEntry.SetRange(\"VAT Bus. Posting Group\", 'DOMESTIC');\n+ VATEntry.SetRange(\"Country/Region Code\", 'CZ');\n+ VATEntry.SetFilter(\"Registration No.\", '<>%1', '');\n+\n+ if VATEntry.IsEmpty() then\n+ exit;\n+\n+ VATEntry.SetLoadFields(\"Registration No.\", \"VAT Registration No.\", \"EU 3-Party Trade\", \"Registration No. CZL\", \"VAT Registration No. CZL\", \"EU 3-Party Trade CZL\");\n+ if VATEntry.FindSet() then\n+ repeat\n+ VATEntry.\"Registration No. CZL\" := VATEntry.\"Registration No.\";\n+ VATEntry.\"VAT Registration No. CZL\" := VATEntry.\"VAT Registration No.\";\n+ VATEntry.\"EU 3-Party Trade CZL\" := VATEntry.\"EU 3-Party Trade\";\n+ VATEntry.Modify(false);\n+\n+ ProcessedCount += 1;\n+\n+ if ProcessedCount mod 1000 = 0 then\n+ LogMigrationProgress(ProcessedCount);\n+\n+ until VATEntry.Next() = 0;\n+\n+ LogMigrationComplete(ProcessedCount);\n+ end;\n+\n+ local procedure LogMigrationProgress(ProcessedCount: Integer)\n+ begin\n+ // Log progress for user feedback\n+ end;\n+\n+ local procedure LogMigrationComplete(TotalProcessed: Integer)\n+ begin\n+ // Log completion statistics\n+ end;\n+}\ndiff --git a/src/ReservationMgt.Codeunit.al b/src/ReservationMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ReservationMgt.Codeunit.al\n@@ -0,0 +1,48 @@\n+codeunit 50112 \"Reservation Priority Mgt.\"\n+{\n+ Access = Internal;\n+\n+ procedure AllocateReservations()\n+ var\n+ ReservWorksheetLine: Record \"Reservation Worksheet Line\";\n+ ProcessedCount: Integer;\n+ TotalToProcess: Integer;\n+ begin\n+ ReservWorksheetLine.SetRange(Status, ReservWorksheetLine.Status::Open);\n+ ReservWorksheetLine.SetRange(\"Priority Level\", 1, 3);\n+\n+ if ReservWorksheetLine.IsEmpty() then\n+ exit;\n+\n+ TotalToProcess := ReservWorksheetLine.Count();\n+\n+ if ReservWorksheetLine.FindSet(true) then begin\n+ if not Confirm(AllocateReservationsQst, false, TotalToProcess) then\n+ exit;\n+\n+ repeat\n+ ReservWorksheetLine.Status := ReservWorksheetLine.Status::Allocated;\n+ ReservWorksheetLine.\"Allocated Date\" := Today();\n+ ReservWorksheetLine.\"Allocated By\" := UserId();\n+ ReservWorksheetLine.Modify();\n+\n+ ProcessedCount += 1;\n+\n+ if ProcessedCount mod 50 = 0 then\n+ UpdateProgressDialog(ProcessedCount, TotalToProcess);\n+\n+ until ReservWorksheetLine.Next() = 0;\n+ end;\n+\n+ Message(AllocatedMsg, ProcessedCount);\n+ end;\n+\n+ local procedure UpdateProgressDialog(Processed: Integer; Total: Integer)\n+ begin\n+ // Update progress indicator\n+ end;\n+\n+ var\n+ AllocateReservationsQst: Label 'Allocate all open reservations (%1 records)?', Comment = '%1 = number of records';\n+ AllocatedMsg: Label 'Successfully allocated %1 reservations.', Comment = '%1 = number of reservations';\n+}\n", "expected_comments": [{"file": "src/ContactMgt.Codeunit.al", "line_start": 15, "line_end": 15, "body": "FindFirst() used for existence check when IsEmpty() would suffice — See agent review for details.", "severity": "medium", "domain": "performance"}, {"file": "src/InstallAppCZL.Codeunit.al", "line_start": 18, "line_end": 18, "body": "FindSet() without true parameter but modifies records in loop — See agent review for details.", "severity": "medium", "domain": "performance"}, {"file": "src/ReservationMgt.Codeunit.al", "line_start": 20, "line_end": 20, "body": "Confirm() dialog after FindSet(true) holds locks — See agent review for details.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: findset_findfirst (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-016", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/ExpenseReportHeader.Table.al b/src/ExpenseReportHeader.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseReportHeader.Table.al\n@@ -0,0 +1,88 @@\n+table 50122 \"Expense Report Header\"\n+{\n+ Caption = 'Expense Report Header';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"No.\"; Code[20])\n+ {\n+ Caption = 'No.';\n+ }\n+ field(2; Description; Text[100])\n+ {\n+ Caption = 'Description';\n+ }\n+ field(3; Status; Option)\n+ {\n+ Caption = 'Status';\n+ OptionMembers = Open,Submitted,Approved,Rejected;\n+ OptionCaption = 'Open,Submitted,Approved,Rejected';\n+ }\n+ field(4; \"Employee No.\"; Code[20])\n+ {\n+ Caption = 'Employee No.';\n+ TableRelation = Employee;\n+ }\n+ field(5; \"Report Date\"; Date)\n+ {\n+ Caption = 'Report Date';\n+ }\n+ field(6; \"Department Code\"; Code[20])\n+ {\n+ Caption = 'Department Code';\n+ TableRelation = \"Dimension Value\".Code where(\"Dimension Code\" = const('DEPARTMENT'));\n+ }\n+ field(7; \"Currency Code\"; Code[10])\n+ {\n+ Caption = 'Currency Code';\n+ TableRelation = Currency;\n+ }\n+ field(8; \"Total Amount\"; Decimal)\n+ {\n+ Caption = 'Total Amount';\n+ }\n+ field(10; \"Refundable Amount\"; Decimal)\n+ {\n+ FieldClass = FlowField;\n+ Caption = 'Refundable Amount';\n+ CalcFormula = sum(\"Expense Report Line\".Amount\n+ where(\"Document No.\" = field(\"No.\"),\n+ Refundable = const(true)));\n+ Editable = false;\n+ }\n+ field(11; \"Non-Refundable Amount\"; Decimal)\n+ {\n+ FieldClass = FlowField;\n+ Caption = 'Non-Refundable Amount';\n+ CalcFormula = sum(\"Expense Report Line\".Amount\n+ where(\"Document No.\" = field(\"No.\"),\n+ Refundable = const(false)));\n+ Editable = false;\n+ }\n+ field(12; \"Submitted Date\"; DateTime)\n+ {\n+ Caption = 'Submitted Date';\n+ Editable = false;\n+ }\n+ field(13; \"Approved Date\"; DateTime)\n+ {\n+ Caption = 'Approved Date';\n+ Editable = false;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"No.\") { Clustered = true; }\n+ key(Key2; \"Employee No.\", \"Report Date\") { }\n+ key(Key3; Status, \"Report Date\") { }\n+ key(Key4; \"Department Code\") { }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ \"Report Date\" := Today();\n+ Status := Status::Open;\n+ end;\n+}\n", "expected_comments": [{"file": "src/ExpenseReportHeader.Table.al", "line_start": 47, "line_end": 47, "body": "FlowField CalcFormula uses SUM on Expense Report Line without a matching SIFT key, causing table scans on large datasets — See agent review for details.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: index_usage — FlowField CalcFormula missing SIFT key on source table", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-017", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/AgentStatus.Table.al b/src/AgentStatus.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AgentStatus.Table.al\n@@ -0,0 +1,85 @@\n+table 50130 \"EA Agent Status\"\n+{\n+ Caption = 'Agent Run Status';\n+ DataClassification = SystemMetadata;\n+\n+ fields\n+ {\n+ field(1; \"Primary Key\"; Code[10])\n+ {\n+ Caption = 'Primary Key';\n+ }\n+ field(2; \"Last Run\"; DateTime)\n+ {\n+ Caption = 'Last Run';\n+ }\n+ field(3; \"Notifications Enabled\"; Boolean)\n+ {\n+ Caption = 'Notifications Enabled';\n+ }\n+ field(4; Status; Enum \"Agent Run State\")\n+ {\n+ Caption = 'Status';\n+ }\n+ field(5; \"Last Error Message\"; Text[250])\n+ {\n+ Caption = 'Last Error Message';\n+ DataClassification = CustomerContent;\n+ }\n+ field(6; \"Retry Count\"; Integer)\n+ {\n+ Caption = 'Retry Count';\n+ }\n+ field(7; \"Next Retry Time\"; DateTime)\n+ {\n+ Caption = 'Next Retry Time';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Primary Key\") { Clustered = true; }\n+ }\n+\n+ procedure GetOrCreate(): Record \"EA Agent Status\"\n+ begin\n+ Rec.LockTable();\n+ if not Rec.Get() then begin\n+ Rec.Init();\n+ Rec.\"Primary Key\" := '';\n+ Rec.\"Notifications Enabled\" := true;\n+ Rec.Status := Rec.Status::Idle;\n+ Rec.\"Last Run\" := CurrentDateTime();\n+ Rec.Insert();\n+ end;\n+ exit(Rec);\n+ end;\n+\n+ procedure ShouldRunNotifications(): Boolean\n+ begin\n+ exit(GetOrCreate().\"Notifications Enabled\");\n+ end;\n+\n+ procedure UpdateStatus(NewStatus: Enum \"Agent Run State\"; ErrorMessage: Text[250])\n+ var\n+ AgentStatus: Record \"EA Agent Status\";\n+ begin\n+ AgentStatus := GetOrCreate();\n+ AgentStatus.Status := NewStatus;\n+ AgentStatus.\"Last Run\" := CurrentDateTime();\n+ AgentStatus.\"Last Error Message\" := ErrorMessage;\n+ if NewStatus = AgentStatus.Status::Error then\n+ AgentStatus.\"Retry Count\" += 1\n+ else\n+ AgentStatus.\"Retry Count\" := 0;\n+ AgentStatus.Modify();\n+ end;\n+\n+ procedure IsRunning(): Boolean\n+ var\n+ AgentStatus: Record \"EA Agent Status\";\n+ begin\n+ AgentStatus := GetOrCreate();\n+ exit(AgentStatus.Status = AgentStatus.Status::Running);\n+ end;\n+}\ndiff --git a/src/VendEntryEditHandler.Codeunit.al b/src/VendEntryEditHandler.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/VendEntryEditHandler.Codeunit.al\n@@ -0,0 +1,37 @@\n+codeunit 50131 \"Vend. Entry Edit Handler\"\n+{\n+ Access = Internal;\n+\n+ procedure UpdateRelatedAdvanceLetterEntries(EntryNo: Integer; Level: Integer)\n+ var\n+ VendLedgerEntry: Record \"Vendor Ledger Entry\";\n+ RelatedEntry: Record \"Vendor Ledger Entry\";\n+ MaxRecursionLevel: Integer;\n+ begin\n+ MaxRecursionLevel := 50;\n+ if Level > MaxRecursionLevel then\n+ Error(MaxRecursionErr, EntryNo);\n+\n+ VendLedgerEntry.SetLoadFields(\"Letter No.\", \"Letter Line No.\", \"Advance Letter Template Code\", \"Document No.\", \"Vendor No.\");\n+ VendLedgerEntry.ReadIsolation(IsolationLevel::UpdLock);\n+ if not VendLedgerEntry.Get(EntryNo) then\n+ exit;\n+\n+ VendLedgerEntry.\"Letter No.\" := '';\n+ VendLedgerEntry.\"Letter Line No.\" := 0;\n+ VendLedgerEntry.\"Advance Letter Template Code\" := '';\n+ VendLedgerEntry.Modify();\n+\n+ RelatedEntry.SetLoadFields(\"Entry No.\");\n+ RelatedEntry.SetRange(\"Closed by Entry No.\", EntryNo);\n+ RelatedEntry.SetRange(Open, false);\n+\n+ if RelatedEntry.FindSet() then\n+ repeat\n+ UpdateRelatedAdvanceLetterEntries(RelatedEntry.\"Entry No.\", Level + 1);\n+ until RelatedEntry.Next() = 0;\n+ end;\n+\n+ var\n+ MaxRecursionErr: Label 'Maximum recursion level exceeded for entry %1', Comment = '%1 = entry number';\n+}\ndiff --git a/src/AgentRunState.Enum.al b/src/AgentRunState.Enum.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AgentRunState.Enum.al\n@@ -0,0 +1,8 @@\n+enum 50132 \"Agent Run State\"\n+{\n+ Extensible = false;\n+\n+ value(0; Idle) { Caption = 'Idle'; }\n+ value(1; Running) { Caption = 'Running'; }\n+ value(2; Error) { Caption = 'Error'; }\n+}\n", "expected_comments": [{"file": "src/AgentStatus.Table.al", "line_start": 46, "line_end": 46, "body": "GetOrCreate() unconditionally locks even for readers — See agent review for details.", "severity": "medium", "domain": "performance"}, {"file": "src/VendEntryEditHandler.Codeunit.al", "line_start": 16, "line_end": 16, "body": "ReadIsolation UpdLock inside recursive function — See agent review for details.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: locking (2 findings). Agent correctly identified these and developers fixed them.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-018", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/BOMBufferMgt.Codeunit.al b/src/BOMBufferMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/BOMBufferMgt.Codeunit.al\n@@ -0,0 +1,68 @@\n+codeunit 50142 \"BOM Buffer Management\"\n+{\n+ Access = Internal;\n+\n+ procedure CalcTotalCost(var BOMBuffer: Record \"BOM Buffer\")\n+ var\n+ Item: Record Item;\n+ TotalCost: Decimal;\n+ LineCount: Integer;\n+ begin\n+ // Initialize calculation\n+ TotalCost := 0;\n+ LineCount := 0;\n+\n+ // Validate BOM buffer has records\n+ if BOMBuffer.IsEmpty() then\n+ exit;\n+\n+ if BOMBuffer.FindSet() then\n+ repeat\n+ LineCount += 1;\n+\n+ if Item.Get(BOMBuffer.\"No.\") then begin\n+ // Calculate cost based on costing method\n+ if ShouldUseStandardCost(Item) then\n+ TotalCost += Item.\"Standard Cost\" * BOMBuffer.Quantity\n+ else if ShouldUseAverageCost(Item) then\n+ TotalCost += Item.\"Unit Cost\" * BOMBuffer.Quantity\n+ else\n+ TotalCost += GetLastDirectCost(Item) * BOMBuffer.Quantity;\n+ end;\n+\n+ // Update line with calculated cost\n+ UpdateBOMLineWithCost(BOMBuffer, Item);\n+\n+ until BOMBuffer.Next() = 0;\n+\n+ // Log calculation summary\n+ LogCostCalculation(LineCount, TotalCost);\n+ end;\n+\n+ local procedure ShouldUseStandardCost(Item: Record Item): Boolean\n+ begin\n+ exit(Item.\"Costing Method\" = Item.\"Costing Method\"::Standard);\n+ end;\n+\n+ local procedure ShouldUseAverageCost(Item: Record Item): Boolean\n+ begin\n+ exit(Item.\"Costing Method\" = Item.\"Costing Method\"::Average);\n+ end;\n+\n+ local procedure GetLastDirectCost(Item: Record Item): Decimal\n+ begin\n+ exit(Item.\"Last Direct Cost\");\n+ end;\n+\n+ local procedure UpdateBOMLineWithCost(var BOMBuffer: Record \"BOM Buffer\"; Item: Record Item)\n+ begin\n+ BOMBuffer.\"Unit Cost\" := Item.\"Unit Cost\";\n+ BOMBuffer.\"Total Cost\" := BOMBuffer.\"Unit Cost\" * BOMBuffer.Quantity;\n+ BOMBuffer.Modify();\n+ end;\n+\n+ local procedure LogCostCalculation(LineCount: Integer; TotalCost: Decimal)\n+ begin\n+ // Log calculation summary for audit\n+ end;\n+}\ndiff --git a/src/PurchRcptLineMgt.Codeunit.al b/src/PurchRcptLineMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PurchRcptLineMgt.Codeunit.al\n@@ -0,0 +1,62 @@\n+codeunit 50140 \"Purch. Rcpt. Line Mgmt.\"\n+{\n+ Access = Internal;\n+\n+ procedure ProcessReceiptLines(OrderNo: Code[20])\n+ var\n+ PurchRcptLine: Record \"Purch. Rcpt. Line\";\n+ ProcessedCount: Integer;\n+ begin\n+ // Validate order number\n+ if OrderNo = '' then\n+ exit;\n+\n+ // Setup filters for receipt lines\n+ PurchRcptLine.SetRange(\"Order No.\", OrderNo);\n+ PurchRcptLine.SetRange(Type, PurchRcptLine.Type::Item);\n+ PurchRcptLine.SetFilter(Quantity, '>0');\n+\n+ if PurchRcptLine.IsEmpty() then\n+ exit;\n+\n+ if PurchRcptLine.FindSet() then\n+ repeat\n+ PurchRcptLine.CalcFields(\"Currency Code\");\n+\n+ if PurchRcptLine.\"Currency Code\" <> '' then begin\n+ ProcessForeignCurrencyLine(PurchRcptLine);\n+ ProcessedCount += 1;\n+ end else\n+ ProcessLocalCurrencyLine(PurchRcptLine);\n+\n+ // Update line status\n+ UpdateLineProcessingStatus(PurchRcptLine);\n+\n+ until PurchRcptLine.Next() = 0;\n+\n+ // Log processing completion\n+ LogProcessingComplete(OrderNo, ProcessedCount);\n+ end;\n+\n+ local procedure ProcessForeignCurrencyLine(PurchRcptLine: Record \"Purch. Rcpt. Line\")\n+ begin\n+ // Process foreign currency line with exchange rate calculations\n+ end;\n+\n+ local procedure ProcessLocalCurrencyLine(PurchRcptLine: Record \"Purch. Rcpt. Line\")\n+ begin\n+ // Process local currency line\n+ end;\n+\n+ local procedure UpdateLineProcessingStatus(var PurchRcptLine: Record \"Purch. Rcpt. Line\")\n+ begin\n+ // Update processing flags\n+ PurchRcptLine.\"Processed Date\" := Today();\n+ PurchRcptLine.Modify();\n+ end;\n+\n+ local procedure LogProcessingComplete(OrderNo: Code[20]; ProcessedCount: Integer)\n+ begin\n+ // Log completion statistics\n+ end;\n+}\ndiff --git a/src/CustomReportLayoutsList.Page.al b/src/CustomReportLayoutsList.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomReportLayoutsList.Page.al\n@@ -0,0 +1,80 @@\n+page 50141 \"Custom Report Layouts List\"\n+{\n+ Caption = 'Custom Report Layouts List';\n+ PageType = List;\n+ SourceTable = \"Custom Report Layout\";\n+ CardPageId = \"Custom Report Layout\";\n+ Editable = false;\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(Lines)\n+ {\n+ field(\"Code\"; Rec.\"Code\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the ID of the custom report layout.';\n+ }\n+ field(Description; Rec.Description)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies a description of the custom report layout.';\n+ }\n+ field(\"Report ID\"; Rec.\"Report ID\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the ID of the report.';\n+ }\n+ field(\"Report Name\"; Rec.\"Report Name\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the name of the report.';\n+ }\n+ field(\"Layout Format\"; Rec.\"Layout Format\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the format of the layout (Word or RDLC).';\n+ }\n+ field(UserDisplayName; UserDisplayName)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Modified By';\n+ ToolTip = 'Specifies who last modified this layout.';\n+ }\n+ }\n+ }\n+ }\n+\n+ trigger OnAfterGetRecord()\n+ begin\n+ UpdateUserDisplayName();\n+ end;\n+\n+ local procedure UpdateUserDisplayName()\n+ var\n+ User: Record User;\n+ begin\n+ UserDisplayName := '';\n+\n+ // Try to get the user who last modified the record\n+ if Rec.\"Last Modified by User\" <> '' then\n+ if User.Get(Rec.\"Last Modified by User\") then\n+ UserDisplayName := User.\"Full Name\";\n+\n+ // Fallback to created by user if last modified is empty\n+ if UserDisplayName = '' then\n+ if Rec.\"Created by User\" <> '' then\n+ if User.Get(Rec.\"Created by User\") then\n+ UserDisplayName := User.\"Full Name\";\n+\n+ // Final fallback\n+ if UserDisplayName = '' then\n+ UserDisplayName := UnknownUserLbl;\n+ end;\n+\n+ var\n+ UserDisplayName: Text[80];\n+ UnknownUserLbl: Label 'Unknown User';\n+}\n", "expected_comments": [{"file": "src/BOMBufferMgt.Codeunit.al", "line_start": 23, "line_end": 23, "body": "N+1 Item.Get per BOM line — See agent review for details.", "severity": "medium", "domain": "performance"}, {"file": "src/PurchRcptLineMgt.Codeunit.al", "line_start": 24, "line_end": 24, "body": "CalcFields on FlowField inside a loop — See agent review for details.", "severity": "medium", "domain": "performance"}, {"file": "src/CustomReportLayoutsList.Page.al", "line_start": 52, "line_end": 52, "body": "N+1 User.Get() in OnAfterGetRecord — See agent review for details.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: loop_optimization (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-019", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/RequisitionWorksheetName.Table.al b/src/RequisitionWorksheetName.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/RequisitionWorksheetName.Table.al\n@@ -0,0 +1,88 @@\n+table 50151 \"Requisition Worksheet Name\"\n+{\n+ Caption = 'Requisition Worksheet Name';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Worksheet Template Name\"; Code[10])\n+ {\n+ Caption = 'Worksheet Template Name';\n+ TableRelation = \"Req. Wksh. Template\";\n+ }\n+ field(2; Name; Code[10])\n+ {\n+ Caption = 'Name';\n+ NotBlank = true;\n+ }\n+ field(3; Description; Text[100])\n+ {\n+ Caption = 'Description';\n+ }\n+ field(4; \"Template Type\"; Option)\n+ {\n+ Caption = 'Template Type';\n+ OptionMembers = \"Req.\",\"For. Labor\",Planning;\n+ OptionCaption = 'Req.,For. Labor,Planning';\n+ }\n+ field(5; \"Location Code\"; Code[10])\n+ {\n+ Caption = 'Location Code';\n+ TableRelation = Location.Code where(\"Use As In-Transit\" = const(false));\n+ }\n+ field(6; Recurring; Boolean)\n+ {\n+ Caption = 'Recurring';\n+ }\n+ field(10; \"Total Quantity\"; Decimal)\n+ {\n+ FieldClass = FlowField;\n+ Caption = 'Total Quantity';\n+ CalcFormula = sum(\"Requisition Line\".Quantity\n+ where(\"Worksheet Template Name\" = field(\"Worksheet Template Name\"),\n+ \"Journal Batch Name\" = field(Name)));\n+ Editable = false;\n+ }\n+ field(11; \"Total Cost\"; Decimal)\n+ {\n+ FieldClass = FlowField;\n+ Caption = 'Total Cost';\n+ CalcFormula = sum(\"Requisition Line\".\"Total Cost (LCY)\"\n+ where(\"Worksheet Template Name\" = field(\"Worksheet Template Name\"),\n+ \"Journal Batch Name\" = field(Name)));\n+ Editable = false;\n+ }\n+ field(13; \"Planning Flexibility\"; Option)\n+ {\n+ Caption = 'Planning Flexibility';\n+ OptionMembers = Unlimited,None;\n+ OptionCaption = 'Unlimited,None';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Worksheet Template Name\", Name) { Clustered = true; }\n+ key(Key2; \"Template Type\", Name) { }\n+ key(Key3; \"Location Code\") { }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ \"Template Type\" := \"Template Type\"::\"Req.\";\n+ \"Planning Flexibility\" := \"Planning Flexibility\"::Unlimited;\n+ end;\n+\n+ procedure GetWorksheetTotals(var TotalQuantity: Decimal; var TotalCost: Decimal)\n+ begin\n+ CalcFields(\"Total Quantity\", \"Total Cost\");\n+ TotalQuantity := \"Total Quantity\";\n+ TotalCost := \"Total Cost\";\n+ end;\n+\n+ procedure GetWorksheetCost(): Decimal\n+ begin\n+ CalcFields(\"Total Cost\");\n+ exit(\"Total Cost\");\n+ end;\n+}\n", "expected_comments": [{"file": "src/RequisitionWorksheetName.Table.al", "line_start": 78, "line_end": 78, "body": "CalcFields on SUM FlowFields over Requisition Line can table-scan without a supporting SIFT path — See agent review for details.", "severity": "medium", "domain": "performance"}, {"file": "src/RequisitionWorksheetName.Table.al", "line_start": 85, "line_end": 85, "body": "CalcFields on SUM FlowFields over Requisition Line can table-scan without a supporting SIFT path — See agent review for details.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: other_performance — SUM FlowFields without SIFT indexes", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-020", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/AssemblyOrderSubform.Page.al b/src/AssemblyOrderSubform.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AssemblyOrderSubform.Page.al\n@@ -0,0 +1,76 @@\n+page 50160 \"Assembly Order Subform Sample\"\n+{\n+ Caption = 'Assembly Order Subform Sample';\n+ PageType = ListPart;\n+ SourceTable = \"Assembly Line\";\n+ AutoSplitKey = true;\n+ DelayedInsert = true;\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(Lines)\n+ {\n+ field(\"No.\"; Rec.\"No.\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the number of the component item.';\n+ }\n+ field(Description; Rec.Description)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the description of the assembly component.';\n+ }\n+ field(Type; Rec.Type)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies if the assembly component is an item or a resource.';\n+ }\n+ field(\"Unit of Measure Code\"; Rec.\"Unit of Measure Code\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the unit of measure code for the assembly component.';\n+ }\n+ field(Quantity; Rec.Quantity)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies how many units of the assembly component are expected on this assembly order line.';\n+ }\n+ field(\"Quantity per\"; Rec.\"Quantity per\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies how many units of the component are needed to assemble one unit of the parent item.';\n+ }\n+ field(AvailWarning; AvailWarning)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Availability Warning';\n+ ToolTip = 'Specifies if there is an availability warning for this component.';\n+ Editable = false;\n+ Style = Attention;\n+ StyleExpr = AvailWarning;\n+ }\n+ }\n+ }\n+ }\n+\n+ trigger OnAfterGetRecord()\n+ begin\n+ UpdateAndPersistAvailWarning();\n+ end;\n+\n+ local procedure UpdateAndPersistAvailWarning()\n+ begin\n+ AvailWarning := (Rec.Type = Rec.Type::Item) and (Rec.Quantity > Rec.\"Quantity per\");\n+\n+ if Rec.\"Avail. Warning\" <> AvailWarning then begin\n+ Rec.\"Avail. Warning\" := AvailWarning;\n+ Rec.\"Last Availability Check\" := CurrentDateTime();\n+ Rec.Modify();\n+ end;\n+ end;\n+\n+ var\n+ AvailWarning: Boolean;\n+}\ndiff --git a/src/ItemLedgerEntryAPAC.Table.al b/src/ItemLedgerEntryAPAC.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ItemLedgerEntryAPAC.Table.al\n@@ -0,0 +1,144 @@\n+table 50161 \"Item Ledger Entry APAC\"\n+{\n+ Caption = 'Item Ledger Entry APAC';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer)\n+ {\n+ Caption = 'Entry No.';\n+ AutoIncrement = true;\n+ }\n+ field(2; \"Item No.\"; Code[20])\n+ {\n+ Caption = 'Item No.';\n+ TableRelation = Item;\n+ }\n+ field(3; \"Posting Date\"; Date)\n+ {\n+ Caption = 'Posting Date';\n+ }\n+ field(4; \"Entry Type\"; Option)\n+ {\n+ Caption = 'Entry Type';\n+ OptionMembers = Purchase,Sale,\"Positive Adjmt.\",\"Negative Adjmt.\",Transfer,Consumption,Output,\"Assembly Consumption\",\"Assembly Output\";\n+ OptionCaption = 'Purchase,Sale,Positive Adjmt.,Negative Adjmt.,Transfer,Consumption,Output,Assembly Consumption,Assembly Output';\n+ }\n+ field(5; \"Source No.\"; Code[20])\n+ {\n+ Caption = 'Source No.';\n+ }\n+ field(6; \"Document No.\"; Code[20])\n+ {\n+ Caption = 'Document No.';\n+ }\n+ field(7; \"Location Code\"; Code[10])\n+ {\n+ Caption = 'Location Code';\n+ TableRelation = Location;\n+ }\n+ field(8; \"Variant Code\"; Code[10])\n+ {\n+ Caption = 'Variant Code';\n+ TableRelation = \"Item Variant\".Code where(\"Item No.\" = field(\"Item No.\"));\n+ }\n+ field(9; Description; Text[100])\n+ {\n+ Caption = 'Description';\n+ }\n+ field(10; \"Unit of Measure Code\"; Code[10])\n+ {\n+ Caption = 'Unit of Measure Code';\n+ TableRelation = \"Unit of Measure\";\n+ }\n+ field(11; Quantity; Decimal)\n+ {\n+ Caption = 'Quantity';\n+ DecimalPlaces = 0 : 5;\n+ }\n+ field(12; \"Remaining Quantity\"; Decimal)\n+ {\n+ Caption = 'Remaining Quantity';\n+ DecimalPlaces = 0 : 5;\n+ }\n+ field(13; \"Invoiced Quantity\"; Decimal)\n+ {\n+ Caption = 'Invoiced Quantity';\n+ DecimalPlaces = 0 : 5;\n+ }\n+ field(14; Open; Boolean)\n+ {\n+ Caption = 'Open';\n+ }\n+ field(15; Positive; Boolean)\n+ {\n+ Caption = 'Positive';\n+ }\n+ field(20; \"APAC Region Code\"; Code[10])\n+ {\n+ Caption = 'APAC Region Code';\n+ }\n+ field(21; \"APAC Country Code\"; Code[10])\n+ {\n+ Caption = 'APAC Country Code';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Entry No.\") { Clustered = true; }\n+ key(Key2; \"Item No.\", \"Posting Date\") { }\n+ key(Key3; \"Item No.\", Open, \"Variant Code\", \"Unit of Measure Code\", \"Location Code\", \"Posting Date\") { }\n+ key(Key4; \"Source No.\", \"Item No.\", \"Variant Code\", \"Posting Date\") { }\n+ key(Key5; \"Item No.\", \"Entry Type\", \"Variant Code\", \"Drop Shipment\", \"Location Code\", \"Posting Date\") { }\n+ key(Key6; \"Item No.\", Open, \"Variant Code\", Positive, \"Location Code\", \"Posting Date\") { }\n+ key(Key7; \"Location Code\", \"Item No.\", \"Variant Code\", Open, Positive)\n+ {\n+ IncludedFields = Quantity, \"Remaining Quantity\";\n+ }\n+ key(Key8; \"Country/Region Code\", \"Entry Type\", \"Posting Date\") { }\n+ key(Key9; \"Document No.\", \"Document Type\", \"Location Code\") { }\n+ key(Key10; \"Item No.\", \"APAC Region Code\", \"Posting Date\") { }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ if \"Posting Date\" = 0D then\n+ \"Posting Date\" := Today();\n+\n+ if \"APAC Country Code\" = '' then\n+ \"APAC Country Code\" := GetCountryFromLocation(\"Location Code\");\n+\n+ if \"APAC Region Code\" = '' then\n+ \"APAC Region Code\" := GetRegionFromCountry(\"APAC Country Code\");\n+ end;\n+\n+ local procedure GetCountryFromLocation(LocationCode: Code[10]): Code[10]\n+ var\n+ Location: Record Location;\n+ begin\n+ if Location.Get(LocationCode) then\n+ exit(Location.\"Country/Region Code\");\n+ exit('');\n+ end;\n+\n+ local procedure GetRegionFromCountry(CountryCode: Code[10]): Code[10]\n+ begin\n+ // Map country to APAC region\n+ case CountryCode of\n+ 'AU':\n+ exit('OCEANIA');\n+ 'JP', 'KR':\n+ exit('NORTHEAST');\n+ 'CN', 'HK', 'TW':\n+ exit('CHINA');\n+ 'TH', 'SG', 'MY':\n+ exit('SOUTHEAST');\n+ 'IN':\n+ exit('SOUTH');\n+ else\n+ exit('OTHER');\n+ end;\n+ end;\n+}\n", "expected_comments": [{"file": "src/AssemblyOrderSubform.Page.al", "line_start": 70, "line_end": 70, "body": "Modify in OnAfterGetRecord — See agent review for details.", "severity": "medium", "domain": "performance"}, {"file": "src/ItemLedgerEntryAPAC.Table.al", "line_start": 96, "line_end": 96, "body": "SumIndexFields changed to IncludedFields — See agent review for details.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: query_optimization (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-021", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/ConfigurationHelper.Codeunit.al b/src/ConfigurationHelper.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ConfigurationHelper.Codeunit.al\n@@ -0,0 +1,77 @@\n+codeunit 50400 \"Configuration Helper\"\n+{\n+ Access = Internal;\n+\n+ procedure GetOrCreateSetup(): Record \"General Ledger Setup\"\n+ var\n+ GLSetup: Record \"General Ledger Setup\";\n+ begin\n+ GLSetup.LockTable();\n+ if not GLSetup.Get() then begin\n+ GLSetup.Init();\n+ GLSetup.\"Allow Posting From\" := CalcDate('<-CM>', WorkDate());\n+ GLSetup.\"Allow Posting To\" := CalcDate('', WorkDate());\n+ GLSetup.Insert(true);\n+ end;\n+ exit(GLSetup);\n+ end;\n+\n+ procedure GetCustomerDisplayName(CustomerNo: Code[20]): Text[100]\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.LockTable();\n+ Customer.SetLoadFields(Name);\n+ Customer.SetRange(\"No.\", CustomerNo);\n+ Customer.SetRange(Blocked, Customer.Blocked::\" \");\n+ if Customer.FindFirst() then\n+ exit(Customer.Name);\n+ exit('');\n+ end;\n+\n+ procedure GetItemDescription(ItemNo: Code[20]): Text[100]\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.ReadIsolation := IsolationLevel::ReadCommitted;\n+ Item.SetLoadFields(Description);\n+ if Item.Get(ItemNo) then\n+ exit(Item.Description);\n+ exit('');\n+ end;\n+\n+ procedure GetCurrencyExchangeRate(CurrencyCode: Code[10]; PostingDate: Date): Decimal\n+ var\n+ CurrencyExchangeRate: Record \"Currency Exchange Rate\";\n+ begin\n+ CurrencyExchangeRate.SetRange(\"Currency Code\", CurrencyCode);\n+ CurrencyExchangeRate.SetRange(\"Starting Date\", 0D, PostingDate);\n+ if CurrencyExchangeRate.FindLast() then\n+ exit(CurrencyExchangeRate.\"Exchange Rate Amount\");\n+ exit(1);\n+ end;\n+\n+ procedure GetDefaultDimension(TableID: Integer; No: Code[20]): Code[20]\n+ var\n+ DefaultDimension: Record \"Default Dimension\";\n+ begin\n+ DefaultDimension.SetRange(\"Table ID\", TableID);\n+ DefaultDimension.SetRange(\"No.\", No);\n+ DefaultDimension.SetRange(\"Dimension Code\", 'DEPARTMENT');\n+ if DefaultDimension.FindFirst() then\n+ exit(DefaultDimension.\"Dimension Value Code\");\n+ exit('');\n+ end;\n+\n+ procedure IsFeatureEnabled(FeatureKey: Text[50]): Boolean\n+ var\n+ FeatureDataUpdateStatus: Record \"Feature Data Update Status\";\n+ begin\n+ FeatureDataUpdateStatus.ReadIsolation := IsolationLevel::ReadCommitted;\n+ FeatureDataUpdateStatus.SetRange(\"Feature Key\", FeatureKey);\n+ if FeatureDataUpdateStatus.FindFirst() then\n+ exit(FeatureDataUpdateStatus.\"Feature Status\" = FeatureDataUpdateStatus.\"Feature Status\"::Enabled);\n+ exit(false);\n+ end;\n+}\n+\n", "expected_comments": [{"file": "src/ConfigurationHelper.Codeunit.al", "line_start": 9, "line_end": 9, "body": "LockTable() in GetOrCreate pattern where most callers only read. The Insert path is rarely hit after initial setup, but every caller pays the lock cost. — Use ReadIsolation := IsolationLevel::ReadCommitted for the initial Get(), then only escalate to LockTable if the record does not exist and an Insert is needed.", "severity": "medium", "domain": "performance"}, {"file": "src/ConfigurationHelper.Codeunit.al", "line_start": 23, "line_end": 23, "body": "LockTable() before a read-only FindFirst that only retrieves data for display. No modification follows, so the lock is unnecessary and blocks other transactions. — Remove the LockTable() call or use ReadIsolation := IsolationLevel::ReadCommitted since this procedure only reads data for display purposes.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: readisolation (2 findings). LockTable used for read-only operations where ReadIsolation would avoid unnecessary locking overhead.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-022", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/AssemblyLineMgt.Codeunit.al b/src/AssemblyLineMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AssemblyLineMgt.Codeunit.al\n@@ -0,0 +1,33 @@\n+codeunit 50172 \"Assembly Line Check\"\n+{\n+ Access = Internal;\n+\n+ procedure CheckAvailability(AssemblyLine: Record \"Assembly Line\"): Boolean\n+ var\n+ AssemblyLineCheck: Record \"Assembly Line\";\n+ begin\n+ AssemblyLineCheck.Get(AssemblyLine.\"Document Type\", AssemblyLine.\"Document No.\", AssemblyLine.\"Line No.\");\n+\n+ if AssemblyLineCheck.Quantity <= 0 then\n+ exit(false);\n+\n+ if AssemblyLineCheck.Type <> AssemblyLineCheck.Type::Item then\n+ exit(true);\n+\n+ exit(AssemblyLineCheck.\"No.\" <> '');\n+ end;\n+\n+ procedure ValidateAssemblyLineQuantity(var AssemblyLine: Record \"Assembly Line\")\n+ begin\n+ if AssemblyLine.Quantity <= 0 then\n+ Error(QuantityZeroErr);\n+\n+ if AssemblyLine.Type = AssemblyLine.Type::Item then\n+ if not CheckAvailability(AssemblyLine) then\n+ Message(InsufficientInventoryMsg, AssemblyLine.\"No.\");\n+ end;\n+\n+ var\n+ QuantityZeroErr: Label 'Quantity must be greater than zero';\n+ InsufficientInventoryMsg: Label 'Insufficient inventory available for item %1', Comment = '%1 = item number';\n+}\ndiff --git a/src/PurchAllocAccMgt.Codeunit.al b/src/PurchAllocAccMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PurchAllocAccMgt.Codeunit.al\n@@ -0,0 +1,65 @@\n+codeunit 50170 \"Purchase Alloc. Acc. Mgt.\"\n+{\n+ Access = Internal;\n+\n+ procedure DistributeAmount(PurchaseLine: Record \"Purchase Line\")\n+ var\n+ PurchaseHeader: Record \"Purchase Header\";\n+ AllocationAccount: Record \"Allocation Account\";\n+ begin\n+ PurchaseHeader.Get(PurchaseLine.\"Document Type\", PurchaseLine.\"Document No.\");\n+\n+ if PurchaseLine.\"Selected Alloc. Account No.\" = '' then\n+ exit;\n+\n+ // Validate allocation account exists\n+ if not AllocationAccount.Get(PurchaseLine.\"Selected Alloc. Account No.\") then\n+ Error(AllocAccNotExistErr, PurchaseLine.\"Selected Alloc. Account No.\");\n+\n+ // Validate account is active\n+ AllocationAccount.TestField(Blocked, false);\n+\n+ // Perform the distribution\n+ DistributeToAllocAccount(PurchaseLine, AllocationAccount);\n+\n+ // Update line with distribution status\n+ UpdateLineDistributionStatus(PurchaseLine);\n+ end;\n+\n+ procedure ValidateAllocationSetup(PurchaseLine: Record \"Purchase Line\"): Boolean\n+ var\n+ AllocationAccount: Record \"Allocation Account\";\n+ begin\n+ if PurchaseLine.\"Selected Alloc. Account No.\" = '' then\n+ exit(false);\n+\n+ if not AllocationAccount.Get(PurchaseLine.\"Selected Alloc. Account No.\") then\n+ exit(false);\n+\n+ exit(not AllocationAccount.Blocked);\n+ end;\n+\n+ local procedure DistributeToAllocAccount(PurchaseLine: Record \"Purchase Line\"; AllocationAccount: Record \"Allocation Account\")\n+ var\n+ PurchaseLineAllocation: Record \"Purchase Line - Alloc. Acc.\";\n+ begin\n+ // Create distribution entries\n+ PurchaseLineAllocation.Init();\n+ PurchaseLineAllocation.\"Document Type\" := PurchaseLine.\"Document Type\";\n+ PurchaseLineAllocation.\"Document No.\" := PurchaseLine.\"Document No.\";\n+ PurchaseLineAllocation.\"Line No.\" := PurchaseLine.\"Line No.\";\n+ PurchaseLineAllocation.\"Allocation Account No.\" := AllocationAccount.\"No.\";\n+ PurchaseLineAllocation.Amount := PurchaseLine.\"Line Amount\";\n+ PurchaseLineAllocation.Insert();\n+ end;\n+\n+ local procedure UpdateLineDistributionStatus(var PurchaseLine: Record \"Purchase Line\")\n+ begin\n+ PurchaseLine.\"Alloc. Acc. Distribution Date\" := Today();\n+ PurchaseLine.\"Distribution Complete\" := true;\n+ PurchaseLine.Modify();\n+ end;\n+\n+ var\n+ AllocAccNotExistErr: Label 'Allocation account %1 does not exist.', Comment = '%1 = allocation account number';\n+}\ndiff --git a/src/SKUMgt.Codeunit.al b/src/SKUMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SKUMgt.Codeunit.al\n@@ -0,0 +1,67 @@\n+codeunit 50171 \"SKU Management\"\n+{\n+ Access = Internal;\n+\n+ procedure CheckSKUCreationPolicy(LocationCode: Code[10]): Boolean\n+ var\n+ Location: Record Location;\n+ begin\n+ if LocationCode = '' then\n+ exit(false);\n+\n+ Location.Get(LocationCode);\n+ Location.TestField(\"SKU Creation Policy\");\n+\n+ exit(Location.\"SKU Creation Policy\" <> Location.\"SKU Creation Policy\"::Never);\n+ end;\n+\n+ procedure CreateSKUForItem(ItemNo: Code[20]; LocationCode: Code[10])\n+ var\n+ Item: Record Item;\n+ StockkeepingUnit: Record \"Stockkeeping Unit\";\n+ begin\n+ if (ItemNo = '') or (LocationCode = '') then\n+ exit;\n+\n+ if not CheckSKUCreationPolicy(LocationCode) then\n+ Error(SKUNotAllowedErr, LocationCode);\n+\n+ Item.SetLoadFields(Description, \"Unit Cost\", \"Standard Cost\");\n+ Item.Get(ItemNo);\n+\n+ if StockkeepingUnit.Get(LocationCode, ItemNo, '') then\n+ exit;\n+\n+ StockkeepingUnit.Init();\n+ StockkeepingUnit.\"Location Code\" := LocationCode;\n+ StockkeepingUnit.\"Item No.\" := ItemNo;\n+ StockkeepingUnit.\"Variant Code\" := '';\n+ StockkeepingUnit.Description := Item.Description;\n+ StockkeepingUnit.\"Unit Cost\" := Item.\"Unit Cost\";\n+ StockkeepingUnit.\"Standard Cost\" := Item.\"Standard Cost\";\n+ StockkeepingUnit.Insert();\n+\n+ LogSKUCreation(ItemNo, LocationCode);\n+ end;\n+\n+ procedure GetSKUPolicy(LocationCode: Code[10]): Integer\n+ var\n+ Location: Record Location;\n+ begin\n+ if LocationCode = '' then\n+ exit(0);\n+\n+ Location.SetLoadFields(\"SKU Creation Policy\");\n+ if Location.Get(LocationCode) then\n+ exit(Location.\"SKU Creation Policy\".AsInteger());\n+ exit(0);\n+ end;\n+\n+ local procedure LogSKUCreation(ItemNo: Code[20]; LocationCode: Code[10])\n+ begin\n+ // Log SKU creation for audit purposes\n+ end;\n+\n+ var\n+ SKUNotAllowedErr: Label 'SKU creation is not allowed for location %1', Comment = '%1 = location code';\n+}\n", "expected_comments": [{"file": "src/AssemblyLineMgt.Codeunit.al", "line_start": 9, "line_end": 9, "body": "Redundant Get in OnAfterGetRecord context — See agent review for details.", "severity": "medium", "domain": "performance"}, {"file": "src/PurchAllocAccMgt.Codeunit.al", "line_start": 10, "line_end": 10, "body": "Get() before guard condition — See agent review for details.", "severity": "medium", "domain": "performance"}, {"file": "src/SKUMgt.Codeunit.al", "line_start": 12, "line_end": 12, "body": "Get() without SetLoadFields for one field — See agent review for details.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: record_loading (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-023", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/ReportBuilder.Codeunit.al b/src/ReportBuilder.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ReportBuilder.Codeunit.al\n@@ -0,0 +1,70 @@\n+codeunit 50401 \"Report Builder\"\n+{\n+ Access = Internal;\n+\n+ procedure BuildCsvExport(CustomerNo: Code[20]): Text\n+ var\n+ SalesLine: Record \"Sales Line\";\n+ Result: Text;\n+ begin\n+ SalesLine.SetRange(\"Sell-to Customer No.\", CustomerNo);\n+ SalesLine.SetRange(\"Document Type\", SalesLine.\"Document Type\"::Order);\n+ SalesLine.SetLoadFields(\"No.\", Quantity, \"Line Amount\");\n+ Result := CsvHeaderTok;\n+ if SalesLine.FindSet() then\n+ repeat\n+ Result += Format(SalesLine.\"No.\") + ',' +\n+ Format(SalesLine.Quantity) + ',' +\n+ Format(SalesLine.\"Line Amount\");\n+ until SalesLine.Next() = 0;\n+ exit(Result);\n+ end;\n+\n+ procedure BuildVendorReport(VendorNo: Code[20]): Text\n+ var\n+ VendorLedgerEntry: Record \"Vendor Ledger Entry\";\n+ ReportBody: Text;\n+ begin\n+ ReportBody := VendorReportHeaderTok;\n+ VendorLedgerEntry.SetRange(\"Vendor No.\", VendorNo);\n+ VendorLedgerEntry.SetRange(Open, true);\n+ if VendorLedgerEntry.FindSet() then\n+ repeat\n+ VendorLedgerEntry.CalcFields(\"Remaining Amount\");\n+ ReportBody += Format(VendorLedgerEntry.\"Entry No.\") + ' | ' +\n+ Format(VendorLedgerEntry.\"Remaining Amount\") + RowSeparatorTok;\n+ until VendorLedgerEntry.Next() = 0;\n+ exit(ReportBody);\n+ end;\n+\n+ procedure BuildItemSummary(LocationCode: Code[10]): Text\n+ var\n+ ItemLedgerEntry: Record \"Item Ledger Entry\";\n+ Summary: TextBuilder;\n+ begin\n+ Summary.Append(StrSubstNo(LocationSummaryLbl, LocationCode));\n+ Summary.AppendLine();\n+ Summary.Append(ItemSummaryHeaderTok);\n+ Summary.AppendLine();\n+ ItemLedgerEntry.SetRange(\"Location Code\", LocationCode);\n+ ItemLedgerEntry.SetRange(\"Entry Type\", ItemLedgerEntry.\"Entry Type\"::Purchase);\n+ ItemLedgerEntry.SetLoadFields(\"Item No.\", Description, Quantity);\n+ if ItemLedgerEntry.FindSet() then\n+ repeat\n+ Summary.Append(ItemLedgerEntry.\"Item No.\");\n+ Summary.Append(' | ');\n+ Summary.Append(ItemLedgerEntry.Description);\n+ Summary.Append(' | ');\n+ Summary.Append(Format(ItemLedgerEntry.Quantity));\n+ Summary.AppendLine();\n+ until ItemLedgerEntry.Next() = 0;\n+ exit(Summary.ToText());\n+ end;\n+\n+ var\n+ CsvHeaderTok: Label 'Item No.,Quantity,Amount', Locked = true;\n+ VendorReportHeaderTok: Label 'Entry No. | Remaining Amount', Locked = true;\n+ RowSeparatorTok: Label '; ', Locked = true;\n+ ItemSummaryHeaderTok: Label 'Item No. | Description | Quantity', Locked = true;\n+ LocationSummaryLbl: Label 'Location: %1', Comment = '%1 = location code';\n+}\n", "expected_comments": [{"file": "src/ReportBuilder.Codeunit.al", "line_start": 16, "line_end": 16, "body": "String concatenation with += inside a FindSet loop building CSV output. Each += allocates a new string, resulting in O(n²) performance for large record sets. — Use TextBuilder to accumulate the output string. TextBuilder.Append() is O(1) amortized and avoids repeated memory allocation.", "severity": "medium", "domain": "performance"}, {"file": "src/ReportBuilder.Codeunit.al", "line_start": 34, "line_end": 34, "body": "String concatenation with += inside a repeat..until loop building HTML body. Each concatenation copies the entire accumulated string, degrading performance as the string grows. — Use TextBuilder to construct the HTML body. Replace HtmlBody += with TextBuilder.Append() calls for efficient string building in loops.", "severity": "medium", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: textbuilder (2 findings). String concatenation with += inside loops causes O(n²) memory allocations; TextBuilder should be used instead.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__performance-024", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "performance"}, "patch": "diff --git a/src/ItemMigrator.Codeunit.al b/src/ItemMigrator.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ItemMigrator.Codeunit.al\n@@ -0,0 +1,19 @@\n+codeunit 50320 \"Item Migrator\"\n+{\n+ procedure MigrateItemDescriptions()\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.SetFilter(Description, '<>%1', '');\n+ if Item.FindSet(true) then\n+ repeat\n+ Item.Description := ConvertToNewFormat(Item.Description);\n+ Item.Modify(true);\n+ until Item.Next() = 0;\n+ end;\n+\n+ local procedure ConvertToNewFormat(OldDesc: Text[100]): Text[100]\n+ begin\n+ exit(OldDesc.TrimEnd());\n+ end;\n+}\ndiff --git a/src/TestDataGenerator.Codeunit.al b/src/TestDataGenerator.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/TestDataGenerator.Codeunit.al\n@@ -0,0 +1,16 @@\n+codeunit 50321 \"Test Data Generator\"\n+{\n+ procedure CreateTestEntries(Count: Integer)\n+ var\n+ ErrorMessageRegister: Record \"Error Message Register\";\n+ i: Integer;\n+ begin\n+ for i := 1 to Count do begin\n+ ErrorMessageRegister.Init();\n+ ErrorMessageRegister.\"Entry No.\" := i;\n+ ErrorMessageRegister.Description := StrSubstNo('Test entry %1', i);\n+ ErrorMessageRegister.\"Created Date\" := Today;\n+ ErrorMessageRegister.Insert(true);\n+ end;\n+ end;\n+}\n", "expected_comments": [{"file": "src/ItemMigrator.Codeunit.al", "line_start": 11, "line_end": 11, "body": "Modify(true) inside loop fires OnModify triggers for each record. For bulk operations, consider Modify(false) unless triggers are required, or use bulk update operations. — ", "severity": "low", "domain": "performance"}, {"file": "src/TestDataGenerator.Codeunit.al", "line_start": 13, "line_end": 13, "body": "Insert(true) in loop fires OnInsert triggers for each record. For test data or bulk inserts, use Insert(false) unless triggers are needed. — ", "severity": "low", "domain": "performance"}], "match_line_tolerance": 2, "domain": "performance", "category": "code-review", "description": "True positive performance findings: Insert(true)/Modify(true) trigger overhead in loops", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/PostalCodeLookupService.Codeunit.al b/src/PostalCodeLookupService.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PostalCodeLookupService.Codeunit.al\n@@ -0,0 +1,50 @@\n+codeunit 50113 \"Postal Code Lookup Service\"\n+{\n+ Access = Internal;\n+\n+ var\n+ TypeHelper: Codeunit \"Type Helper\";\n+\n+ procedure LookupPostalAddress(PostalCode: Code[20]; City: Text[50]): Text[250]\n+ var\n+ HttpClient: HttpClient;\n+ HttpResponse: HttpResponseMessage;\n+ RequestUri: Text;\n+ ResponseText: Text;\n+ begin\n+ HttpClient.Timeout := 10000;\n+ RequestUri := StrSubstNo('https://postal-api.service.com/lookup?postal=%1&city=%2',\n+ TypeHelper.UrlEncode(PostalCode), TypeHelper.UrlEncode(City));\n+\n+ if not HttpClient.Get(RequestUri, HttpResponse) then\n+ exit('');\n+ if not HttpResponse.IsSuccessStatusCode() then\n+ exit('');\n+\n+ HttpResponse.Content.ReadAs(ResponseText);\n+ exit(ParseAddressResponse(ResponseText));\n+ end;\n+\n+ local procedure ParseAddressResponse(JsonResponse: Text): Text[250]\n+ var\n+ JsonObject: JsonObject;\n+ AddressToken: JsonToken;\n+ begin\n+ if JsonObject.ReadFrom(JsonResponse) then\n+ if JsonObject.Get('standardized_address', AddressToken) then\n+ exit(CopyStr(AddressToken.AsValue().AsText(), 1, 250));\n+ exit('');\n+ end;\n+\n+ procedure ValidateBusinessAddress(var BusinessAddress: Record \"Business Address\")\n+ var\n+ StandardizedAddress: Text[250];\n+ begin\n+ StandardizedAddress := LookupPostalAddress(BusinessAddress.\"Postal Code\", BusinessAddress.City);\n+ if StandardizedAddress <> '' then begin\n+ BusinessAddress.\"Validated Address\" := StandardizedAddress;\n+ BusinessAddress.\"Validation Status\" := BusinessAddress.\"Validation Status\"::Validated;\n+ BusinessAddress.Modify(true);\n+ end;\n+ end;\n+}\ndiff --git a/src/CustomerPiiBuffer.Table.al b/src/CustomerPiiBuffer.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerPiiBuffer.Table.al\n@@ -0,0 +1,15 @@\n+table 50321 \"Customer PII Buffer\"\n+{\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer) { }\n+ field(10; Email; Text[80])\n+ {\n+ DataClassification = SystemMetadata;\n+ }\n+ }\n+ keys\n+ {\n+ key(PK; \"Entry No.\") { Clustered = true; }\n+ }\n+}\n", "expected_comments": [{"file": "src/CustomerPiiBuffer.Table.al", "line_start": 8, "line_end": 8, "severity": "high", "domain": "privacy", "body": "Email contains personally identifiable customer data but is classified as SystemMetadata. Use DataClassification = EndUserIdentifiableInformation or CustomerContent."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "Address/postcode data classification (4 false positives). Agent flags address fields or postcode lookup data as incorrectly classified. Reviewers reject because: (1) address data in lookup tables is reference data not PII, (2) country/region codes are not personally identifiable, (3) the classification is appropriate for the context.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-002", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/SystemConfigurationLog.Table.al b/src/SystemConfigurationLog.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SystemConfigurationLog.Table.al\n@@ -0,0 +1,32 @@\n+table 50111 \"System Configuration Log\"\n+{\n+ Caption = 'System Configuration Log';\n+ DataClassification = SystemMetadata;\n+ TableType = Temporary;\n+\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer)\n+ {\n+ Caption = 'Entry No.';\n+ AutoIncrement = true;\n+ }\n+ field(2; \"Configuration Area\"; Text[50])\n+ {\n+ Caption = 'Configuration Area';\n+ }\n+ field(3; \"Parameter Name\"; Text[100])\n+ {\n+ Caption = 'Parameter Name';\n+ }\n+ field(4; \"Parameter Value\"; Text[250])\n+ {\n+ Caption = 'Parameter Value';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Entry No.\") { Clustered = true; }\n+ }\n+}\ndiff --git a/src/ContactConsentCache.Table.al b/src/ContactConsentCache.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ContactConsentCache.Table.al\n@@ -0,0 +1,15 @@\n+table 50322 \"Contact Consent Cache\"\n+{\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer) { }\n+ field(10; \"Phone No.\"; Text[30])\n+ {\n+ DataClassification = ToBeClassified;\n+ }\n+ }\n+ keys\n+ {\n+ key(PK; \"Entry No.\") { Clustered = true; }\n+ }\n+}\n", "expected_comments": [{"file": "src/ContactConsentCache.Table.al", "line_start": 8, "line_end": 8, "severity": "medium", "domain": "privacy", "body": "ToBeClassified is only for development and must be resolved before release, especially for a phone number field. Set an explicit CustomerContent or EndUserIdentifiableInformation classification."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "Missing DataClassification on table fields (59 false positives). Agent flags table fields missing explicit DataClassification property. Reviewers reject because: (1) table-level DataClassification covers all fields, (2) fields contain system/business data not PII, (3) fields are in temporary tables, or (4) the classification is inherited.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/EAOutboxEmail.Table.al b/src/EAOutboxEmail.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/EAOutboxEmail.Table.al\n@@ -0,0 +1,33 @@\n+table 50130 \"EA Outbox Email\"\n+{\n+ Caption = 'Expense Agent Outbox Email';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer)\n+ {\n+ Caption = 'Entry No.';\n+ AutoIncrement = true;\n+ DataClassification = SystemMetadata;\n+ }\n+ field(10; \"From Address\"; Text[250])\n+ {\n+ Caption = 'From Address';\n+ DataClassification = EndUserIdentifiableInformation;\n+ }\n+ field(20; Subject; Text[250])\n+ {\n+ Caption = 'Subject';\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Entry No.\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+}\ndiff --git a/src/OutlookIntegrationHelper.Codeunit.al b/src/OutlookIntegrationHelper.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/OutlookIntegrationHelper.Codeunit.al\n@@ -0,0 +1,14 @@\n+codeunit 50131 \"Outlook Integration Helper\"\n+{\n+ Access = Internal;\n+\n+ var\n+ GraphNotificationTok: Label 'MicrosoftGraphNotification', Locked = true;\n+\n+ procedure IsGraphNotificationConsented(): Boolean\n+ var\n+ PrivacyNotice: Codeunit \"Privacy Notice\";\n+ begin\n+ exit(PrivacyNotice.GetPrivacyNoticeApprovalState(GraphNotificationTok) = \"Privacy Notice Approval State\"::Agreed);\n+ end;\n+}\ndiff --git a/src/SOAFiltersImpl.Codeunit.al b/src/SOAFiltersImpl.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SOAFiltersImpl.Codeunit.al\n@@ -0,0 +1,9 @@\n+codeunit 50132 \"SOA Filters Impl\"\n+{\n+ Access = Internal;\n+\n+ procedure IsValidEmailFilter(EmailAddress: Text[250]): Boolean\n+ begin\n+ exit((EmailAddress <> '') and (StrPos(EmailAddress, '@') > 0));\n+ end;\n+}\ndiff --git a/src/CustomerEmailValidator.Codeunit.al b/src/CustomerEmailValidator.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerEmailValidator.Codeunit.al\n@@ -0,0 +1,16 @@\n+codeunit 50323 \"Customer Email Validator\"\n+{\n+ procedure RejectEmail(CustomerName: Text[100]; EmailAddress: Text[80])\n+ var\n+ InvalidEmailErr: Label 'Customer %1 has invalid email %2.';\n+ ErrorMessage: Text;\n+ begin\n+ ErrorMessage := StrSubstNo(InvalidEmailErr, CustomerName, EmailAddress);\n+ Error(ErrorMessage);\n+ end;\n+\n+ procedure Check(CustomerName: Text[100]; EmailAddress: Text[80])\n+ begin\n+ this.RejectEmail(CustomerName, EmailAddress);\n+ end;\n+}\n", "expected_comments": [{"file": "src/CustomerEmailValidator.Codeunit.al", "line_start": 8, "line_end": 9, "severity": "high", "domain": "privacy", "body": "PII (customer name and email) is pre-built into a Text variable via StrSubstNo and then passed to Error. The resulting message is logged to telemetry with the PII inlined. Avoid pre-baking customer data into error messages; surface generic errors and report PII through a privacy-compliant channel."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "Email addresses in API/system calls (3 false positives). Agent flags email addresses used in Graph API calls or email processing. Reviewers reject because: (1) email is required for the feature to function, (2) the API call is to Microsoft services, (3) proper consent/privacy controls exist.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-004", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/SystemErrorHandler.Codeunit.al b/src/SystemErrorHandler.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SystemErrorHandler.Codeunit.al\n@@ -0,0 +1,43 @@\n+codeunit 50115 SystemErrorHandler\n+{\n+ var\n+ ErrorLoggedMsg: Label 'Error logged with reference: %1', Comment = '%1 = System ID';\n+ ValidationFailedMsg: Label 'Validation failed for record %1 in field %2', Comment = '%1 = Record ID, %2 = Field name';\n+\n+ procedure HandleSystemError(ErrorContext: Text[100]; SystemId: Guid; ErrorDetails: Text[500])\n+ var\n+ ErrorLogEntry: Record \"Error Log Entry\";\n+ begin\n+\n+ ErrorLogEntry.Init();\n+ ErrorLogEntry.\"Entry No.\" := GetNextEntryNo();\n+ ErrorLogEntry.\"Error Context\" := ErrorContext;\n+ ErrorLogEntry.\"System Reference\" := SystemId;\n+ ErrorLogEntry.\"Error Message\" := ErrorDetails;\n+ ErrorLogEntry.\"Date Time\" := CurrentDateTime;\n+ ErrorLogEntry.Insert();\n+\n+ Message(ErrorLoggedMsg, SystemId);\n+ end;\n+\n+ procedure ProcessSystemValidationError(RecordId: RecordId; ValidationField: Text[50])\n+ var\n+ ErrorMessage: Text[500];\n+ begin\n+\n+ ErrorMessage := StrSubstNo(ValidationFailedMsg, Format(RecordId), ValidationField);\n+\n+ HandleSystemError('VALIDATION', RecordId.SystemId, ErrorMessage);\n+\n+ end;\n+\n+ local procedure GetNextEntryNo(): Integer\n+ var\n+ ErrorLogEntry: Record \"Error Log Entry\";\n+ begin\n+ ErrorLogEntry.LockTable();\n+ if ErrorLogEntry.FindLast() then\n+ exit(ErrorLogEntry.\"Entry No.\" + 1);\n+ exit(1);\n+ end;\n+}\ndiff --git a/src/ErrorLogEntry.Table.al b/src/ErrorLogEntry.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ErrorLogEntry.Table.al\n@@ -0,0 +1,42 @@\n+table 50116 \"Error Log Entry\"\n+{\n+ Caption = 'Error Log Entry';\n+ DataClassification = SystemMetadata;\n+\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer)\n+ {\n+ Caption = 'Entry No.';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(2; \"Error Context\"; Enum \"Error Context Type\")\n+ {\n+ Caption = 'Error Context';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(3; \"System Reference\"; Guid)\n+ {\n+ Caption = 'System Reference';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(4; \"Error Message\"; Text[500])\n+ {\n+ Caption = 'Error Message';\n+ DataClassification = CustomerContent;\n+ }\n+ field(5; \"Date Time\"; DateTime)\n+ {\n+ Caption = 'Date Time';\n+ DataClassification = SystemMetadata;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Entry No.\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+}\ndiff --git a/src/AttachmentErrorReporter.Codeunit.al b/src/AttachmentErrorReporter.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AttachmentErrorReporter.Codeunit.al\n@@ -0,0 +1,15 @@\n+codeunit 50324 \"Attachment Error Reporter\"\n+{\n+ procedure RaiseAttachmentFailure()\n+ var\n+ ErrorMessage: Text;\n+ begin\n+ ErrorMessage := StrSubstNo('Attachment failed: %1', GetLastErrorText(true));\n+ Error(ErrorMessage);\n+ end;\n+\n+ procedure ReportLastFailure()\n+ begin\n+ this.RaiseAttachmentFailure();\n+ end;\n+}\n", "expected_comments": [{"file": "src/AttachmentErrorReporter.Codeunit.al", "line_start": 7, "line_end": 7, "severity": "high", "domain": "privacy", "body": "GetLastErrorText can contain customer content such as filenames or record values, and StrSubstNo bakes it into an Error string that telemetry logs verbatim. Use a generic Error message instead."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "PII in error messages (8 false positives). Agent flags GUIDs, document IDs, or system IDs in error messages as PII. Reviewers reject because: (1) GUIDs/SystemIds are not personally identifiable, (2) document IDs are business data, (3) error context is needed for troubleshooting.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/BusinessIntegrationEvents.Codeunit.al b/src/BusinessIntegrationEvents.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/BusinessIntegrationEvents.Codeunit.al\n@@ -0,0 +1,38 @@\n+codeunit 50117 \"Business Integration Events\"\n+{\n+ Access = Internal;\n+\n+ [IntegrationEvent(true, false)]\n+ local procedure OnBeforeProcessBusinessEntity(var BusinessEntity: Record \"Business Entity\"; var IsHandled: Boolean)\n+ begin\n+ end;\n+\n+ procedure ProcessBusinessEntityBatch(var TempBusinessEntity: Record \"Business Entity\" temporary)\n+ var\n+ IsHandled: Boolean;\n+ ProcessedCount: Integer;\n+ begin\n+ if TempBusinessEntity.FindSet() then\n+ repeat\n+ IsHandled := false;\n+ OnBeforeProcessBusinessEntity(TempBusinessEntity, IsHandled);\n+ if not IsHandled then begin\n+ ProcessSingleBusinessEntity(TempBusinessEntity);\n+ ProcessedCount += 1;\n+ end;\n+ OnAfterBusinessEntityProcessed(TempBusinessEntity.\"Entity No.\", ProcessedCount);\n+ until TempBusinessEntity.Next() = 0;\n+ end;\n+\n+ [IntegrationEvent(true, false)]\n+ local procedure OnAfterBusinessEntityProcessed(EntityNo: Code[20]; ProcessedCount: Integer)\n+ begin\n+ end;\n+\n+ local procedure ProcessSingleBusinessEntity(var BusinessEntity: Record \"Business Entity\")\n+ begin\n+ BusinessEntity.\"Processing Status\" := BusinessEntity.\"Processing Status\"::Processed;\n+ BusinessEntity.\"Processed Date\" := Today;\n+ BusinessEntity.Modify(true);\n+ end;\n+}\ndiff --git a/src/CustomerTelemetryLogger.Codeunit.al b/src/CustomerTelemetryLogger.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerTelemetryLogger.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50325 \"Customer Telemetry Logger\"\n+{\n+ procedure LogCustomerProcessed(CustomerName: Text[100])\n+ var\n+ TraceCategory: Text[30];\n+ begin\n+ TraceCategory := 'Privacy';\n+ Session.LogMessage('0000P01', StrSubstNo('Processed customer %1', CustomerName),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All,\n+ 'Category', TraceCategory);\n+ end;\n+\n+ procedure LogDefault(CustomerName: Text[100])\n+ begin\n+ this.LogCustomerProcessed(CustomerName);\n+ end;\n+}\n", "expected_comments": [{"file": "src/CustomerTelemetryLogger.Codeunit.al", "line_start": 8, "line_end": 8, "severity": "high", "domain": "privacy", "body": "The telemetry message includes a customer name, which is PII and will be sent to telemetry. Log a generic message and keep customer data out of Session.LogMessage text."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "Integration event parameter exposure (3 false positives). Agent flags integration events that pass record parameters (e.g., CustLedgerEntry, VendorLedgerEntry) as exposing PII. Reviewers reject because: (1) integration events are internal APIs, (2) consuming code already has table permissions, (3) this is standard BC event pattern.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-006", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/BusinessSystemLogger.Codeunit.al b/src/BusinessSystemLogger.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/BusinessSystemLogger.Codeunit.al\n@@ -0,0 +1,45 @@\n+codeunit 50112 BusinessSystemLogger\n+{\n+ procedure LogVendorProcessing(VendorCode: Code[20]; ProcessingStep: Text[100])\n+ var\n+ ActivityLog: Record \"Activity Log\";\n+ begin\n+\n+ ActivityLog.LogActivity(\n+ Database::Vendor,\n+ ActivityLog.Status::Success,\n+ 'VendorProcessing',\n+ StrSubstNo('Processing completed for Vendor: %1 at step: %2', VendorCode, ProcessingStep),\n+ '');\n+\n+ Message(VendorLoggedMsg, VendorCode);\n+ end;\n+\n+ procedure LogSystemOperation(OperationType: Text[50]; Details: Text[250])\n+ begin\n+\n+ Session.LogMessage('VendorProcess', StrSubstNo('Operation: %1 - Details: %2', OperationType, Details), Verbosity::Information,\n+ DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Vendor', '');\n+ end;\n+\n+ procedure ProcessVendorBatch(var VendorBatch: Record Vendor temporary)\n+ var\n+ ProcessedCount: Integer;\n+ VendorCode: Code[20];\n+ begin\n+\n+ if VendorBatch.FindSet() then\n+ repeat\n+ VendorCode := VendorBatch.\"No.\";\n+\n+ LogSystemOperation('BATCH_PROCESSING', StrSubstNo('Vendor %1 processed in batch', VendorCode));\n+ ProcessedCount += 1;\n+ until VendorBatch.Next() = 0;\n+\n+ Message(BatchCompletedMsg, ProcessedCount);\n+ end;\n+\n+ var\n+ VendorLoggedMsg: Label 'Vendor %1 processing logged successfully', Comment = '%1 = Vendor Code';\n+ BatchCompletedMsg: Label 'Batch processing completed: %1 vendors processed', Comment = '%1 = vendor count';\n+}\n", "expected_comments": [{"file": "src/BusinessSystemLogger.Codeunit.al", "line_start": 18, "line_end": 18, "domain": "privacy", "body": "LogSystemOperation embeds a caller-supplied free-form Details (Text[250]) directly into the Session.LogMessage telemetry text while classifying the payload as SystemMetadata. Callers can pass CustomerContent/EUII, so PII can be logged to telemetry under the wrong classification — Log a fixed non-personal message and pass vetted values as custom dimensions, or declare a stronger DataClassification", "severity": "high"}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "PII in log/telemetry messages (13 false positives). Agent flags vendor IDs, document numbers, or error stacks in log messages as PII exposure. Reviewers reject because: (1) vendor IDs are business identifiers not personal data, (2) telemetry uses SystemMetadata classification, (3) error stacks are necessary for debugging.", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/CustomerContactBuffer.Table.al b/src/CustomerContactBuffer.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerContactBuffer.Table.al\n@@ -0,0 +1,31 @@\n+table 50118 \"Customer Contact Buffer\"\n+{\n+ Caption = 'Customer Contact Buffer';\n+ DataClassification = CustomerContent;\n+ TableType = Temporary;\n+\n+ fields\n+ {\n+ field(1; \"Customer Name\"; Text[100])\n+ {\n+ Caption = 'Customer Name';\n+ }\n+ field(2; \"Contact Email\"; Text[80])\n+ {\n+ Caption = 'Contact Email';\n+ }\n+ field(3; \"Phone No.\"; Text[30])\n+ {\n+ Caption = 'Phone No.';\n+ }\n+ field(4; \"Billing Address\"; Text[100])\n+ {\n+ Caption = 'Billing Address';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Customer Name\") { Clustered = true; }\n+ }\n+}\ndiff --git a/src/ExpenseTelemetryLogger.Codeunit.al b/src/ExpenseTelemetryLogger.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseTelemetryLogger.Codeunit.al\n@@ -0,0 +1,16 @@\n+codeunit 50326 \"Expense Telemetry Logger\"\n+{\n+ procedure LogExpenseRelease(EmployeeNo: Code[20])\n+ var\n+ FeatureTelemetry: Codeunit \"Feature Telemetry\";\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ CustomDimensions.Add('EmployeeNo', EmployeeNo);\n+ FeatureTelemetry.LogUsage('0000P02', 'Expense Review', 'Document Released', CustomDimensions);\n+ end;\n+\n+ procedure RecordRelease(EmployeeNo: Code[20])\n+ begin\n+ this.LogExpenseRelease(EmployeeNo);\n+ end;\n+}\n", "expected_comments": [{"file": "src/ExpenseTelemetryLogger.Codeunit.al", "line_start": 8, "line_end": 8, "severity": "high", "domain": "privacy", "body": "Employee numbers can identify individuals and must not be sent in FeatureTelemetry custom dimensions. Remove the EmployeeNo dimension or replace it with a non-identifying aggregate."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "Permission set / blanket classification (1 false positives). Agent flags blanket DataClassification changes or permission set exposure. Reviewers reject because: (1) the classification approach is intentional, (2) permission sets are system metadata.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-008", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/BusinessEntityRegistry.Table.al b/src/BusinessEntityRegistry.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/BusinessEntityRegistry.Table.al\n@@ -0,0 +1,34 @@\n+table 50110 \"Business Entity Registry\"\n+{\n+ DataClassification = CustomerContent; // Table-level classification covers all fields\n+ Caption = 'Business Entity Registry';\n+\n+ fields\n+ {\n+ field(1; \"Entity ID\"; Code[20])\n+ {\n+ Caption = 'Entity ID';\n+ }\n+ field(2; \"Company Name\"; Text[100])\n+ {\n+ Caption = 'Company Name';\n+ }\n+ field(3; \"Business Address\"; Text[250])\n+ {\n+ Caption = 'Business Address';\n+ }\n+ field(4; \"Registration Number\"; Text[50])\n+ {\n+ Caption = 'Registration Number';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Entity ID\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+}\ndiff --git a/src/CustomerSyncDispatcher.Codeunit.al b/src/CustomerSyncDispatcher.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerSyncDispatcher.Codeunit.al\n@@ -0,0 +1,22 @@\n+codeunit 50327 \"Customer Sync Dispatcher\"\n+{\n+ procedure SendCustomer(Customer: Record Customer)\n+ var\n+ HttpClient: HttpClient;\n+ HttpContent: HttpContent;\n+ HttpResponse: HttpResponseMessage;\n+ begin\n+ HttpContent.WriteFrom(this.BuildPayload(Customer));\n+ HttpClient.Post('https://api.contoso.example/customers', HttpContent, HttpResponse);\n+ end;\n+\n+ local procedure BuildPayload(Customer: Record Customer): Text\n+ var\n+ PayloadJson: JsonObject;\n+ PayloadText: Text;\n+ begin\n+ PayloadJson.Add('email', Customer.\"E-Mail\");\n+ PayloadJson.WriteTo(PayloadText);\n+ exit(PayloadText);\n+ end;\n+}\n", "expected_comments": [{"file": "src/CustomerSyncDispatcher.Codeunit.al", "line_start": 10, "line_end": 10, "severity": "high", "domain": "privacy", "body": "This outgoing HTTP request sends customer data to an external service without a Privacy Notice consent check in the code path. Verify consent with PrivacyNotice.GetPrivacyNoticeApprovalState before posting."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "PII in table fields (names, addresses) (3 false positives). Agent flags fields containing names or addresses as missing PII classification. Reviewers reject because: (1) the table already has appropriate DataClassification, (2) these are business entity names not personal names, (3) migration tables have different privacy requirements.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/TaxDataMigrationHelper.Codeunit.al b/src/TaxDataMigrationHelper.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/TaxDataMigrationHelper.Codeunit.al\n@@ -0,0 +1,34 @@\n+codeunit 50116 \"Tax Data Migration Helper\"\n+{\n+ Access = Internal;\n+\n+ var\n+ ValidationContextTxt: Label 'Tax ID validation during migration', Locked = true;\n+ ProgressTxt: Label 'Tax data migration progress: %1 of %2 records processed', Locked = true;\n+\n+ procedure MigrateTaxInformation(var VendorRecord: Record Vendor; SourceTaxId: Text[50])\n+ begin\n+ if SourceTaxId <> '' then begin\n+ VendorRecord.Validate(\"Federal ID No.\", FormatTaxId(SourceTaxId));\n+ VendorRecord.Modify(true);\n+ end;\n+ end;\n+\n+ procedure ValidateTaxDataIntegrity(ExpectedTaxId: Text[50]; ActualTaxId: Text[50]): Boolean\n+ var\n+ ValidationAssert: Codeunit \"Migration Validation Assert\";\n+ begin\n+ exit(ValidationAssert.ValidateAreEqual(ExpectedTaxId, ActualTaxId, true, ValidationContextTxt));\n+ end;\n+\n+ local procedure FormatTaxId(RawTaxId: Text[50]): Text[50]\n+ begin\n+ exit(DelChr(RawTaxId, '=', '-()., '));\n+ end;\n+\n+ procedure LogMigrationProgress(TotalRecords: Integer; ProcessedRecords: Integer)\n+ begin\n+ Session.LogMessage('TaxMigration', StrSubstNo(ProgressTxt, ProcessedRecords, TotalRecords),\n+ Verbosity::Information, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher);\n+ end;\n+}\ndiff --git a/src/EmployeeIdentityStaging.Table.al b/src/EmployeeIdentityStaging.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/EmployeeIdentityStaging.Table.al\n@@ -0,0 +1,15 @@\n+table 50328 \"Employee Identity Staging\"\n+{\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer) { }\n+ field(10; \"Social Security No.\"; Text[30])\n+ {\n+ Caption = 'Social Security No.';\n+ }\n+ }\n+ keys\n+ {\n+ key(PK; \"Entry No.\") { Clustered = true; }\n+ }\n+}\n", "expected_comments": [{"file": "src/EmployeeIdentityStaging.Table.al", "line_start": 6, "line_end": 6, "severity": "high", "domain": "privacy", "body": "Social Security No. stores sensitive PII but the field has no DataClassification. Add EndUserIdentifiableInformation or CustomerContent classification on the field or table."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "Tax ID (TIN) handling in migration code (4 false positives). Agent flags TIN/federal ID processing in data migration codeunits as PII risk. Reviewers reject because: (1) migration code necessarily processes this data, (2) data is already classified at the table level, (3) migration is a controlled process.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-010", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/ExternalCRMSync.Codeunit.al b/src/ExternalCRMSync.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExternalCRMSync.Codeunit.al\n@@ -0,0 +1,46 @@\n+codeunit 57300 \"External CRM Sync\"\n+{\n+ Access = Public;\n+\n+ procedure SyncCustomerToExternalCRM(Customer: Record Customer)\n+ var\n+ HttpClient: HttpClient;\n+ HttpContent: HttpContent;\n+ HttpResponse: HttpResponseMessage;\n+ JsonPayload: Text;\n+ begin\n+ if Customer.\"E-Mail\" = '' then\n+ exit;\n+\n+ JsonPayload := StrSubstNo(\n+ '{\"email\":\"%1\",\"name\":\"%2\",\"phone\":\"%3\",\"address\":\"%4\"}',\n+ Customer.\"E-Mail\",\n+ Customer.Name,\n+ Customer.\"Phone No.\",\n+ Customer.Address);\n+\n+ HttpContent.WriteFrom(JsonPayload);\n+ HttpContent.GetHeaders().Clear();\n+ HttpContent.GetHeaders().Add('Content-Type', 'application/json');\n+\n+ // Sends customer data to external service without privacy consent check\n+ HttpClient.Post('https://api.externalcrm.com/contacts/sync', HttpContent, HttpResponse);\n+\n+ if not HttpResponse.IsSuccessStatusCode() then\n+ Error('Failed to sync customer %1 to external CRM', Customer.\"No.\");\n+ end;\n+\n+ procedure SyncAllPendingCustomers()\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.SetRange(\"CRM Sync Required\", true);\n+ if Customer.FindSet() then\n+ repeat\n+ SyncCustomerToExternalCRM(Customer);\n+ Customer.\"CRM Sync Required\" := false;\n+ Customer.Modify(false);\n+ until Customer.Next() = 0;\n+ end;\n+}\n+\ndiff --git a/src/OutboxEmailDispatcher.Codeunit.al b/src/OutboxEmailDispatcher.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/OutboxEmailDispatcher.Codeunit.al\n@@ -0,0 +1,52 @@\n+codeunit 57301 \"Outbox Email Dispatcher\"\n+{\n+ Access = Public;\n+\n+ procedure SendPendingEmails()\n+ var\n+ OutboxEmail: Record \"EA Outbox Email\";\n+ HttpClient: HttpClient;\n+ HttpContent: HttpContent;\n+ HttpResponse: HttpResponseMessage;\n+ GraphUrl: Text;\n+ JsonPayload: Text;\n+ begin\n+ OutboxEmail.SetRange(\"Send Status\", OutboxEmail.\"Send Status\"::Pending);\n+ if OutboxEmail.FindSet(true) then\n+ repeat\n+ GraphUrl := 'https://graph.microsoft.com/v1.0/me/sendMail';\n+\n+ JsonPayload := BuildMailPayload(OutboxEmail);\n+\n+ HttpContent.WriteFrom(JsonPayload);\n+ HttpContent.GetHeaders().Clear();\n+ HttpContent.GetHeaders().Add('Content-Type', 'application/json');\n+\n+ // Sends email via Microsoft Graph without checking Privacy Notice consent\n+ if HttpClient.Post(GraphUrl, HttpContent, HttpResponse) then begin\n+ if HttpResponse.IsSuccessStatusCode() then begin\n+ OutboxEmail.\"Send Status\" := OutboxEmail.\"Send Status\"::Sent;\n+ OutboxEmail.\"Sent DateTime\" := CurrentDateTime;\n+ end else begin\n+ OutboxEmail.\"Send Status\" := OutboxEmail.\"Send Status\"::Failed;\n+ OutboxEmail.\"Retry Count\" += 1;\n+ end;\n+ OutboxEmail.Modify(false);\n+ end;\n+ until OutboxEmail.Next() = 0;\n+ end;\n+\n+ local procedure BuildMailPayload(OutboxEmail: Record \"EA Outbox Email\"): Text\n+ var\n+ JsonPayload: Text;\n+ begin\n+ JsonPayload := StrSubstNo(\n+ '{\"message\":{\"subject\":\"%1\",\"toRecipients\":[{\"emailAddress\":{\"address\":\"%2\"}}],' +\n+ '\"body\":{\"contentType\":\"HTML\",\"content\":\"%3\"}},\"saveToSentItems\":true}',\n+ OutboxEmail.Subject,\n+ OutboxEmail.\"To Line\",\n+ OutboxEmail.GetBodyText());\n+ exit(JsonPayload);\n+ end;\n+}\n+\n", "expected_comments": [{"file": "src/ExternalCRMSync.Codeunit.al", "line_start": 5, "line_end": 5, "domain": "privacy", "body": "Customer PII fields (Name, E-Mail, Phone No., Address) are transmitted externally with no DataClassification consideration for EndUserIdentifiableInformation/EndUserPseudonymousIdentifiers — Classify the transmitted personal data and tag the PII flow so it is traceable for GDPR records of processing", "severity": "high"}, {"file": "src/ExternalCRMSync.Codeunit.al", "line_start": 27, "line_end": 27, "body": "HttpClient.Post sends customer data (email, name, phone, address) to external CRM service without PrivacyNotice.GetPrivacyNoticeApprovalState() check in the code path — Add Privacy Notice consent verification before sending customer data externally", "severity": "medium", "domain": "privacy"}, {"file": "src/OutboxEmailDispatcher.Codeunit.al", "line_start": 26, "line_end": 26, "body": "HttpClient.Post sends email data via Microsoft Graph API without PrivacyNotice.GetPrivacyNoticeApprovalState() check for Exchange integration consent — Verify Privacy Notice consent for Exchange/Graph integration before sending emails", "severity": "medium", "domain": "privacy"}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "True positive privacy findings: outgoing requests sending customer data to external services without Privacy Notice consent verification in the code path", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-011", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/ContactSyncFolder.Table.al b/src/ContactSyncFolder.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ContactSyncFolder.Table.al\n@@ -0,0 +1,29 @@\n+table 50150 \"Contact Sync Folder\"\n+{\n+ Caption = 'Contact Sync Folder';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Folder ID\"; Code[20])\n+ {\n+ Caption = 'Folder ID';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(2; \"Folder Name\"; Text[100])\n+ {\n+ Caption = 'Folder Name';\n+ DataClassification = CustomerContent;\n+ }\n+ field(3; \"Contact Notes\"; Text[2048])\n+ {\n+ Caption = 'Contact Notes';\n+ DataClassification = SystemMetadata;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Folder ID\") { Clustered = true; }\n+ }\n+}\ndiff --git a/src/FinancialReportBuffer.Table.al b/src/FinancialReportBuffer.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/FinancialReportBuffer.Table.al\n@@ -0,0 +1,28 @@\n+table 50151 \"Financial Report Buffer\"\n+{\n+ Caption = 'Financial Report Buffer';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Entry No.\"; Integer)\n+ {\n+ Caption = 'Entry No.';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(2; \"Account No.\"; Code[20])\n+ {\n+ Caption = 'Account No.';\n+ DataClassification = CustomerContent;\n+ }\n+ field(3; \"Category Code\"; Code[20])\n+ {\n+ Caption = 'Category Code';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Entry No.\") { Clustered = true; }\n+ }\n+}\ndiff --git a/src/O365ContactBuffer.Table.al b/src/O365ContactBuffer.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/O365ContactBuffer.Table.al\n@@ -0,0 +1,28 @@\n+table 50152 \"O365 Contact Buffer\"\n+{\n+ Caption = 'O365 Contact Buffer';\n+ DataClassification = EndUserIdentifiableInformation;\n+\n+ fields\n+ {\n+ field(1; \"Contact No.\"; Code[20])\n+ {\n+ Caption = 'Contact No.';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(2; \"Name\"; Text[100])\n+ {\n+ Caption = 'Name';\n+ DataClassification = EndUserIdentifiableInformation;\n+ }\n+ field(3; \"County\"; Text[30])\n+ {\n+ Caption = 'County';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Contact No.\") { Clustered = true; }\n+ }\n+}\ndiff --git a/src/ServiceShipmentLineBuffer.Table.al b/src/ServiceShipmentLineBuffer.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ServiceShipmentLineBuffer.Table.al\n@@ -0,0 +1,28 @@\n+table 50153 \"Service Shipment Line Buffer\"\n+{\n+ Caption = 'Service Shipment Line Buffer';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Document No.\"; Code[20])\n+ {\n+ Caption = 'Document No.';\n+ DataClassification = CustomerContent;\n+ }\n+ field(2; \"Line No.\"; Integer)\n+ {\n+ Caption = 'Line No.';\n+ DataClassification = SystemMetadata;\n+ }\n+ field(3; \"External Document No.\"; Code[35])\n+ {\n+ Caption = 'External Document No.';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Document No.\", \"Line No.\") { Clustered = true; }\n+ }\n+}\n", "expected_comments": [{"file": "src/ContactSyncFolder.Table.al", "line_start": 18, "line_end": 18, "domain": "privacy", "severity": "medium", "body": "Field 'Contact Notes' is classified SystemMetadata but stores free-text personal notes (CustomerContent/EUII) — reclassify to CustomerContent or EndUserIdentifiableInformation."}, {"file": "src/FinancialReportBuffer.Table.al", "line_start": 18, "line_end": 18, "domain": "privacy", "severity": "medium", "body": "Field 'Category Code' is missing a DataClassification property — add an explicit DataClassification."}, {"file": "src/O365ContactBuffer.Table.al", "line_start": 18, "line_end": 18, "domain": "privacy", "severity": "medium", "body": "Field 'County' is missing a DataClassification property — add an explicit DataClassification (address data is EndUserIdentifiableInformation)."}, {"file": "src/ServiceShipmentLineBuffer.Table.al", "line_start": 18, "line_end": 18, "domain": "privacy", "severity": "medium", "body": "Field 'External Document No.' is missing a DataClassification property — add an explicit DataClassification."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "True positive privacy findings: dataclassification (trimmed to 6 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-012", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/ExpenseUser.Table.al b/src/ExpenseUser.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseUser.Table.al\n@@ -0,0 +1,43 @@\n+table 50140 \"Expense User\"\n+{\n+ Caption = 'Expense User';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"User Security ID\"; Guid)\n+ {\n+ Caption = 'User Security ID';\n+ DataClassification = EndUserPseudonymousIdentifiers;\n+ }\n+ field(10; \"E-Mail\"; Text[250])\n+ {\n+ Caption = 'E-Mail';\n+ DataClassification = EndUserIdentifiableInformation;\n+ }\n+ field(20; \"Full Name\"; Text[100])\n+ {\n+ Caption = 'Full Name';\n+ DataClassification = EndUserIdentifiableInformation;\n+ }\n+ field(30; \"Allow Approval\"; Boolean)\n+ {\n+ Caption = 'Allow Approval';\n+ DataClassification = SystemMetadata;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"User Security ID\") { Clustered = true; }\n+ }\n+\n+ var\n+ ApproverInvalidErr: Label 'User %1 with email %2 cannot approve expenses.', Comment = '%1 = Full Name, %2 = Email';\n+\n+ procedure ValidateApprover()\n+ begin\n+ if not \"Allow Approval\" then\n+ Error(ApproverInvalidErr, \"Full Name\", \"E-Mail\");\n+ end;\n+}\ndiff --git a/src/JobQueueErrorHandler.Codeunit.al b/src/JobQueueErrorHandler.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/JobQueueErrorHandler.Codeunit.al\n@@ -0,0 +1,11 @@\n+codeunit 50141 \"Job Queue Error Handler\"\n+{\n+ var\n+ JobFailedTxt: Label 'Job %1 failed: %2', Locked = true;\n+\n+ procedure LogJobError(JobId: Guid)\n+ begin\n+ Session.LogMessage('JQE001', StrSubstNo(JobFailedTxt, JobId, GetLastErrorText()),\n+ Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher);\n+ end;\n+}\ndiff --git a/src/ReleaseExpenseDocument.Codeunit.al b/src/ReleaseExpenseDocument.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ReleaseExpenseDocument.Codeunit.al\n@@ -0,0 +1,19 @@\n+codeunit 50142 \"Release Expense Document\"\n+{\n+ var\n+ MerchantInvalidErr: Label 'Merchant %1 is not valid for document %2.', Comment = '%1 = Merchant Name, %2 = Document No.';\n+ FeatureNameTxt: Label 'Expense Agent', Locked = true;\n+ DocReleasedTxt: Label 'Document Released', Locked = true;\n+\n+ procedure Release(var ExpenseHeader: Record \"Expense Header\")\n+ var\n+ FeatureTelemetry: Codeunit \"Feature Telemetry\";\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ if not ExpenseHeader.\"Merchant Approved\" then\n+ Error(MerchantInvalidErr, ExpenseHeader.\"Merchant Name\", ExpenseHeader.\"No.\");\n+\n+ CustomDimensions.Add('EmployeeNo', ExpenseHeader.\"Employee No.\");\n+ FeatureTelemetry.LogUsage('0000EA1', FeatureNameTxt, DocReleasedTxt, CustomDimensions);\n+ end;\n+}\ndiff --git a/src/ReleaseExpReportDocument.Codeunit.al b/src/ReleaseExpReportDocument.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ReleaseExpReportDocument.Codeunit.al\n@@ -0,0 +1,19 @@\n+codeunit 50143 \"Release Exp Report Document\"\n+{\n+ var\n+ MerchantInvalidErr: Label 'Merchant %1 is not valid for report %2.', Comment = '%1 = Merchant Name, %2 = Report No.';\n+ FeatureNameTxt: Label 'Expense Agent', Locked = true;\n+ ReportReleasedTxt: Label 'Report Released', Locked = true;\n+\n+ procedure Release(var ExpenseReportHeader: Record \"Expense Report Header\")\n+ var\n+ FeatureTelemetry: Codeunit \"Feature Telemetry\";\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ if not ExpenseReportHeader.\"Merchant Approved\" then\n+ Error(MerchantInvalidErr, ExpenseReportHeader.\"Merchant Name\", ExpenseReportHeader.\"No.\");\n+\n+ CustomDimensions.Add('EmployeeNo', ExpenseReportHeader.\"Employee No.\");\n+ FeatureTelemetry.LogUsage('0000EA2', FeatureNameTxt, ReportReleasedTxt, CustomDimensions);\n+ end;\n+}\n", "expected_comments": [{"file": "src/ExpenseUser.Table.al", "line_start": 41, "line_end": 41, "domain": "privacy", "body": "StrSubstNo pre-builds error message with PII (Full Name and E-Mail) and passes it to Error() — PII leaks to telemetry — Use direct Error substitution or omit PII", "severity": "high"}, {"file": "src/JobQueueErrorHandler.Codeunit.al", "line_start": 8, "line_end": 8, "domain": "privacy", "body": "GetLastErrorText passed through StrSubstNo into Session.LogMessage telemetry — may contain customer content — Log a generic message and keep dynamic error detail out of telemetry", "severity": "medium"}, {"file": "src/ReleaseExpenseDocument.Codeunit.al", "line_start": 14, "line_end": 14, "domain": "privacy", "body": "StrSubstNo pre-builds error message containing Merchant Name (CustomerContent) and passes to Error() — leaks to telemetry — Use direct substitution in Error()", "severity": "medium"}, {"file": "src/ReleaseExpenseDocument.Codeunit.al", "line_start": 16, "line_end": 16, "domain": "privacy", "body": "Employee No. included as a telemetry custom dimension — can identify individuals — Remove EmployeeNo from telemetry dimensions or use a hash", "severity": "medium"}, {"file": "src/ReleaseExpReportDocument.Codeunit.al", "line_start": 14, "line_end": 14, "domain": "privacy", "body": "StrSubstNo pre-builds error message containing Merchant Name (CustomerContent) and passes to Error() — leaks to telemetry — Use direct substitution in Error()", "severity": "medium"}, {"file": "src/ReleaseExpReportDocument.Codeunit.al", "line_start": 16, "line_end": 16, "domain": "privacy", "body": "Employee No. included as a telemetry custom dimension — can identify individuals — Remove EmployeeNo from telemetry dimensions or use a hash", "severity": "medium"}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "True positive privacy findings: error_message_pii (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-013", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/PRReviewManager.Codeunit.al b/src/PRReviewManager.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PRReviewManager.Codeunit.al\n@@ -0,0 +1,34 @@\n+codeunit 57400 \"PR Review Manager\"\n+{\n+ Access = Internal;\n+\n+ var\n+ ReviewSubjectTxt: Label 'PR #%1 needs your review on %2', Comment = '%1 = PR number, %2 = branch name';\n+\n+ procedure GetDefaultReviewers(TargetBranch: Text): List of [Text]\n+ var\n+ Reviewers: List of [Text];\n+ begin\n+ Reviewers.Add('john.doe@contoso.com');\n+ Reviewers.Add('jane.smith@contoso.com');\n+ Reviewers.Add('mike.wilson@contoso.com');\n+ if TargetBranch = 'release' then\n+ Reviewers.Add('sarah.connor@contoso.com');\n+ exit(Reviewers);\n+ end;\n+\n+ procedure NotifyReviewers(PRNumber: Integer; TargetBranch: Text)\n+ var\n+ Reviewers: List of [Text];\n+ Reviewer: Text;\n+ begin\n+ Reviewers := GetDefaultReviewers(TargetBranch);\n+ foreach Reviewer in Reviewers do\n+ SendReviewNotification(Reviewer, StrSubstNo(ReviewSubjectTxt, PRNumber, TargetBranch));\n+ end;\n+\n+ local procedure SendReviewNotification(EmailAddress: Text; Subject: Text)\n+ begin\n+ // Sends the review notification email to the reviewer.\n+ end;\n+}\ndiff --git a/src/DeploymentConfig.Codeunit.al b/src/DeploymentConfig.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/DeploymentConfig.Codeunit.al\n@@ -0,0 +1,9 @@\n+codeunit 57401 \"Deployment Config\"\n+{\n+ Access = Internal;\n+\n+ procedure GetDeploymentNotificationRecipients(): Text\n+ begin\n+ exit('john.doe@contoso.com;jane.smith@contoso.com;mike.wilson@contoso.com');\n+ end;\n+}\n", "expected_comments": [{"file": "src/PRReviewManager.Codeunit.al", "line_start": 12, "line_end": 12, "domain": "privacy", "severity": "medium", "body": "Hardcoded personal email addresses embedded in the reviewer list. Personal data must not be hardcoded in source — move to a configuration table or setup record."}, {"file": "src/DeploymentConfig.Codeunit.al", "line_start": 7, "line_end": 7, "domain": "privacy", "severity": "medium", "body": "Hardcoded personal email addresses in source code for deployment notifications. Move recipient addresses to a configuration/setup table."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "True positive privacy findings: hardcoded personal email addresses embedded directly in source code", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-014", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/ContactSyncProcessor.Codeunit.al b/src/ContactSyncProcessor.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ContactSyncProcessor.Codeunit.al\n@@ -0,0 +1,20 @@\n+codeunit 50160 \"Contact Sync Processor\"\n+{\n+ Access = Internal;\n+\n+ var\n+ SyncStartedTxt: Label 'Contact sync started for user %1.', Comment = '%1 = User Security ID';\n+ SyncDoneTxt: Label 'Contact sync completed for %1 (%2).', Comment = '%1 = Contact Name, %2 = Email';\n+\n+ procedure ProcessContactSync(ContactSyncUser: Record \"Contact Sync User\")\n+ var\n+ Telemetry: Codeunit Telemetry;\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ Telemetry.LogMessage('0000CS01', StrSubstNo(SyncStartedTxt, ContactSyncUser.\"User Security ID\"),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+\n+ Telemetry.LogMessage('0000CS02', StrSubstNo(SyncDoneTxt, ContactSyncUser.\"Contact Name\", ContactSyncUser.\"Email Address\"),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+ end;\n+}\ndiff --git a/src/ExpenseNotificationDispatcher.Codeunit.al b/src/ExpenseNotificationDispatcher.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseNotificationDispatcher.Codeunit.al\n@@ -0,0 +1,16 @@\n+codeunit 50161 \"Expense Notification Dispatcher\"\n+{\n+ Access = Internal;\n+\n+ var\n+ NotifSentTxt: Label 'Notification sent for employee %1.', Comment = '%1 = Employee No.';\n+\n+ procedure DispatchNotification(ExpenseHeader: Record \"Expense Report Header\")\n+ var\n+ Telemetry: Codeunit Telemetry;\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ Telemetry.LogMessage('0000EA10', StrSubstNo(NotifSentTxt, ExpenseHeader.\"Employee No.\"),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+ end;\n+}\ndiff --git a/src/InstallExpenseAgentSetup.Codeunit.al b/src/InstallExpenseAgentSetup.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/InstallExpenseAgentSetup.Codeunit.al\n@@ -0,0 +1,25 @@\n+codeunit 50162 \"Install Expense Agent Setup\"\n+{\n+ Subtype = Install;\n+\n+ var\n+ JitProvisionTxt: Label 'Expense agent provisioned for external user %1.', Comment = '%1 = User name';\n+\n+ trigger OnInstallAppPerCompany()\n+ var\n+ AgentSetup: Record \"Expense Agent Setup\";\n+ begin\n+ if not AgentSetup.Get() then\n+ exit;\n+ LogProvisioning(AgentSetup.\"External User Name\");\n+ end;\n+\n+ local procedure LogProvisioning(ExternalUserName: Text[100])\n+ var\n+ Telemetry: Codeunit Telemetry;\n+ CustomDimensions: Dictionary of [Text, Text];\n+ begin\n+ Telemetry.LogMessage('0000IN01', StrSubstNo(JitProvisionTxt, ExternalUserName),\n+ Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions);\n+ end;\n+}\n", "expected_comments": [{"file": "src/ContactSyncProcessor.Codeunit.al", "line_start": 14, "line_end": 14, "domain": "privacy", "body": "Telemetry message embeds the User Security ID (EndUserPseudonymousIdentifiers) but the LogMessage DataClassification is SystemMetadata. — Remove the User Security ID from the telemetry message or classify the entry as EndUserPseudonymousIdentifiers.", "severity": "high"}, {"file": "src/ContactSyncProcessor.Codeunit.al", "line_start": 17, "line_end": 17, "domain": "privacy", "body": "Telemetry message embeds the contact name and email address (EndUserIdentifiableInformation) under SystemMetadata classification. — Do not put contact name or email in telemetry messages; log a non-identifying key instead.", "severity": "high"}, {"file": "src/ExpenseNotificationDispatcher.Codeunit.al", "line_start": 13, "line_end": 13, "domain": "privacy", "body": "Telemetry message embeds the Employee No. (EndUserIdentifiableInformation) under SystemMetadata classification. — Remove the Employee No. from the telemetry message.", "severity": "high"}, {"file": "src/InstallExpenseAgentSetup.Codeunit.al", "line_start": 22, "line_end": 22, "domain": "privacy", "body": "Telemetry message embeds an external user name (EndUserIdentifiableInformation) under SystemMetadata classification. — Remove the user name from the telemetry message.", "severity": "high"}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "True positive privacy findings: logging_pii (trimmed to 3 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__privacy-015", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "privacy"}, "patch": "diff --git a/src/AIContextBuilder.Codeunit.al b/src/AIContextBuilder.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AIContextBuilder.Codeunit.al\n@@ -0,0 +1,34 @@\n+codeunit 57500 \"AI Context Builder\"\n+{\n+ Access = Public;\n+\n+ procedure BuildTaskExecutionContext(AgentTaskId: Guid): Text\n+ var\n+ AgentTask: Record \"Agent Task\";\n+ User: Record User;\n+ ContextBuilder: TextBuilder;\n+ begin\n+ AgentTask.GetBySystemId(AgentTaskId);\n+ ContextBuilder.AppendLine('Task: ' + AgentTask.Description);\n+ ContextBuilder.AppendLine('Status: ' + Format(AgentTask.Status));\n+ if User.Get(AgentTask.\"Agent User Security ID\") then\n+ ContextBuilder.AppendLine('Created By: ' + User.\"Full Name\");\n+ exit(ContextBuilder.ToText());\n+ end;\n+\n+ procedure SendContextToAIService(Context: Text)\n+ var\n+ HttpClient: HttpClient;\n+ Content: HttpContent;\n+ Response: HttpResponseMessage;\n+ begin\n+ Content.WriteFrom(Context);\n+ HttpClient.Post(AiServiceUrlTok, Content, Response);\n+ if not Response.IsSuccessStatusCode() then\n+ Error(AiServiceErr);\n+ end;\n+\n+ var\n+ AiServiceErr: Label 'The AI evaluation service could not be reached.';\n+ AiServiceUrlTok: Label 'https://ai-evaluation.internal.example.com/evaluate', Locked = true;\n+}\ndiff --git a/src/CustomerDataExporter.Codeunit.al b/src/CustomerDataExporter.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerDataExporter.Codeunit.al\n@@ -0,0 +1,29 @@\n+codeunit 57501 \"Customer Data Exporter\"\n+{\n+ Access = Public;\n+\n+ procedure ExportCustomerDataToPartner(CustomerNo: Code[20])\n+ var\n+ Customer: Record Customer;\n+ HttpClient: HttpClient;\n+ Content: HttpContent;\n+ ContentHeaders: HttpHeaders;\n+ Response: HttpResponseMessage;\n+ Payload: JsonObject;\n+ PayloadText: Text;\n+ begin\n+ Customer.Get(CustomerNo);\n+ Payload.Add('customerName', Customer.Name);\n+ Payload.Add('email', Customer.\"E-Mail\");\n+ Payload.Add('phone', Customer.\"Phone No.\");\n+ Payload.Add('address', Customer.Address);\n+ Payload.WriteTo(PayloadText);\n+ Content.WriteFrom(PayloadText);\n+ Content.GetHeaders(ContentHeaders);\n+ ContentHeaders.Add('Content-Type', 'application/json');\n+ HttpClient.Post(PartnerApiUrlTok, Content, Response);\n+ end;\n+\n+ var\n+ PartnerApiUrlTok: Label 'https://partner-api.contoso.com/customers/sync', Locked = true;\n+}\ndiff --git a/src/ExternalCRMSync.Codeunit.al b/src/ExternalCRMSync.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExternalCRMSync.Codeunit.al\n@@ -0,0 +1,42 @@\n+codeunit 57300 \"External CRM Sync\"\n+{\n+ Access = Public;\n+\n+ procedure SyncCustomerToExternalCRM(Customer: Record Customer)\n+ var\n+ HttpClient: HttpClient;\n+ Content: HttpContent;\n+ ContentHeaders: HttpHeaders;\n+ Response: HttpResponseMessage;\n+ Payload: JsonObject;\n+ PayloadText: Text;\n+ begin\n+ if Customer.\"E-Mail\" = '' then\n+ exit;\n+ Payload.Add('email', Customer.\"E-Mail\");\n+ Payload.Add('name', Customer.Name);\n+ Payload.Add('phone', Customer.\"Phone No.\");\n+ Payload.Add('address', Customer.Address);\n+ Payload.WriteTo(PayloadText);\n+ Content.WriteFrom(PayloadText);\n+ Content.GetHeaders(ContentHeaders);\n+ ContentHeaders.Add('Content-Type', 'application/json');\n+ HttpClient.Post(CrmSyncUrlTok, Content, Response);\n+ end;\n+\n+ procedure SyncAllPendingCustomers()\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.SetRange(\"CRM Sync Required\", true);\n+ if Customer.FindSet(true) then\n+ repeat\n+ SyncCustomerToExternalCRM(Customer);\n+ Customer.\"CRM Sync Required\" := false;\n+ Customer.Modify(true);\n+ until Customer.Next() = 0;\n+ end;\n+\n+ var\n+ CrmSyncUrlTok: Label 'https://api.example.com/contacts/sync', Locked = true;\n+}\ndiff --git a/src/OutboxEmailDispatcher.Codeunit.al b/src/OutboxEmailDispatcher.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/OutboxEmailDispatcher.Codeunit.al\n@@ -0,0 +1,57 @@\n+codeunit 57301 \"Outbox Email Dispatcher\"\n+{\n+ Access = Public;\n+\n+ procedure SendPendingEmails()\n+ var\n+ OutboxEmail: Record \"EA Outbox Email\";\n+ HttpClient: HttpClient;\n+ Content: HttpContent;\n+ ContentHeaders: HttpHeaders;\n+ Response: HttpResponseMessage;\n+ PayloadText: Text;\n+ begin\n+ OutboxEmail.SetRange(\"Send Status\", OutboxEmail.\"Send Status\"::Pending);\n+ if OutboxEmail.FindSet(true) then\n+ repeat\n+ PayloadText := BuildMailPayload(OutboxEmail);\n+ Content.WriteFrom(PayloadText);\n+ Content.GetHeaders(ContentHeaders);\n+ ContentHeaders.Add('Content-Type', 'application/json');\n+ if HttpClient.Post(GraphSendMailUrlTok, Content, Response) then begin\n+ if Response.IsSuccessStatusCode() then\n+ OutboxEmail.\"Send Status\" := OutboxEmail.\"Send Status\"::Sent\n+ else\n+ OutboxEmail.\"Send Status\" := OutboxEmail.\"Send Status\"::Failed;\n+ OutboxEmail.Modify(true);\n+ end;\n+ until OutboxEmail.Next() = 0;\n+ end;\n+\n+ local procedure BuildMailPayload(OutboxEmail: Record \"EA Outbox Email\"): Text\n+ var\n+ Message: JsonObject;\n+ Body: JsonObject;\n+ Recipient: JsonObject;\n+ EmailAddress: JsonObject;\n+ ToRecipients: JsonArray;\n+ Root: JsonObject;\n+ PayloadText: Text;\n+ begin\n+ Body.Add('contentType', 'HTML');\n+ Body.Add('content', OutboxEmail.GetBodyText());\n+ EmailAddress.Add('address', OutboxEmail.\"To Line\");\n+ Recipient.Add('emailAddress', EmailAddress);\n+ ToRecipients.Add(Recipient);\n+ Message.Add('subject', OutboxEmail.Subject);\n+ Message.Add('toRecipients', ToRecipients);\n+ Message.Add('body', Body);\n+ Root.Add('message', Message);\n+ Root.Add('saveToSentItems', true);\n+ Root.WriteTo(PayloadText);\n+ exit(PayloadText);\n+ end;\n+\n+ var\n+ GraphSendMailUrlTok: Label 'https://graph.microsoft.com/v1.0/me/sendMail', Locked = true;\n+}\n", "expected_comments": [{"file": "src/AIContextBuilder.Codeunit.al", "line_start": 26, "line_end": 26, "domain": "privacy", "severity": "high", "body": "Outgoing HTTP request to external AI service sends context containing user data with no Privacy Notice consent check in the code path — verify consent before transmitting."}, {"file": "src/CustomerDataExporter.Codeunit.al", "line_start": 24, "line_end": 24, "domain": "privacy", "severity": "critical", "body": "Customer PII (name, email, phone, address) sent to external partner API with no Privacy Notice consent check in the code path — verify consent before transmitting."}, {"file": "src/ExternalCRMSync.Codeunit.al", "line_start": 24, "line_end": 24, "domain": "privacy", "severity": "critical", "body": "Customer PII (email, name, phone, address) sent to external CRM service with no Privacy Notice consent check in the code path — verify consent before transmitting."}, {"file": "src/OutboxEmailDispatcher.Codeunit.al", "line_start": 21, "line_end": 21, "domain": "privacy", "severity": "high", "body": "Outgoing HTTP request to Microsoft Graph API sends email content with no Privacy Notice consent check in the code path — verify consent before transmitting."}], "match_line_tolerance": 2, "domain": "privacy", "category": "code-review", "description": "True positive privacy findings: PII sent to external services without privacy consent checks or data minimization (4 findings across 4 files)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/CleanCaptionSetup.Page.al b/src/CleanCaptionSetup.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CleanCaptionSetup.Page.al\n@@ -0,0 +1,37 @@\n+page 50001 \"Clean Caption Setup\"\n+{\n+ Caption = 'Clean Caption Setup';\n+ PageType = Card;\n+ ApplicationArea = All;\n+ UsageCategory = Administration;\n+ SourceTable = Vendor;\n+\n+ layout\n+ {\n+ area(content)\n+ {\n+ group(General)\n+ {\n+ Caption = 'General';\n+ field(VendorNo; Rec.\"No.\")\n+ {\n+ ApplicationArea = All;\n+ Caption = 'No.';\n+ ToolTip = 'Specifies the number of the vendor.';\n+ }\n+ field(VendorName; Rec.Name)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Name';\n+ ToolTip = 'Specifies the name of the vendor.';\n+ }\n+ field(VendorBalance; Rec.\"Balance (LCY)\")\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Balance (LCY)';\n+ ToolTip = 'Specifies the balance in local currency.';\n+ }\n+ }\n+ }\n+ }\n+}\ndiff --git a/src/IndentationViolation.Codeunit.al b/src/IndentationViolation.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/IndentationViolation.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50341 \"Indentation Violation\"\n+{\n+ local procedure AdjustCount(StartCount: Integer; Increment: Integer): Integer\n+ var\n+ Counter: Integer;\n+ begin\n+ Counter := StartCount;\n+ if Increment > 0 then\n+ Counter := Counter + Increment;\n+ exit(Counter);\n+ end;\n+\n+ local procedure IsEnabled(Enabled: Boolean): Boolean\n+ begin\n+ exit(Enabled);\n+ end;\n+}\n", "expected_comments": [{"file": "src/IndentationViolation.Codeunit.al", "line_start": 3, "line_end": 16, "severity": "low", "domain": "style", "body": "File uses 4-space indentation for nested AL blocks. Project style requires 2-space indentation consistently throughout."}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "False positive style findings: caption_false_positive (790 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-002", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/PostingHelper.Codeunit.al b/src/PostingHelper.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PostingHelper.Codeunit.al\n@@ -0,0 +1,33 @@\n+codeunit 50200 \"Posting Helper\"\n+{\n+ var\n+ PostedMsg: Label 'Document %1 posted successfully.', Comment = '%1 = Document No.';\n+\n+ /// \n+ /// Validates that the sales header is ready for posting.\n+ /// \n+ /// The sales header record to validate.\n+ /// True if the header is released and has lines; otherwise, false.\n+ procedure ValidateSalesHeader(SalesHeader: Record \"Sales Header\"): Boolean\n+ var\n+ SalesLine: Record \"Sales Line\";\n+ begin\n+ if SalesHeader.Status <> SalesHeader.Status::Released then\n+ exit(false);\n+\n+ SalesLine.SetRange(\"Document Type\", SalesHeader.\"Document Type\");\n+ SalesLine.SetRange(\"Document No.\", SalesHeader.\"No.\");\n+ exit(not SalesLine.IsEmpty());\n+ end;\n+\n+ /// \n+ /// Gets the posting confirmation message.\n+ /// \n+ /// The document number to include in the message.\n+ /// The formatted posting confirmation message.\n+ procedure GetPostingMessage(DocumentNo: Code[20]): Text\n+ begin\n+ exit(StrSubstNo(PostedMsg, DocumentNo));\n+ end;\n+}\n+\ndiff --git a/src/SelfReferenceStyle.Codeunit.al b/src/SelfReferenceStyle.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SelfReferenceStyle.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50342 \"Self Reference Style\"\n+{\n+ local procedure RunCheck(CustomerNo: Code[20]): Boolean\n+ begin\n+ exit(IsCustomerNoFilled(CustomerNo));\n+ end;\n+\n+ local procedure IsCustomerNoFilled(CustomerNo: Code[20]): Boolean\n+ begin\n+ exit(CustomerNo <> '');\n+ end;\n+\n+ local procedure EchoCustomerNo(CustomerNo: Code[20]): Code[20]\n+ begin\n+ exit(CustomerNo);\n+ end;\n+}\n", "expected_comments": [{"file": "src/PostingHelper.Codeunit.al", "line_start": 3, "line_end": 33, "severity": "low", "domain": "style", "body": "The codeunit body uses 4-space indentation for nested AL blocks. Project style requires 2-space indentation consistently throughout."}, {"file": "src/SelfReferenceStyle.Codeunit.al", "line_start": 5, "line_end": 5, "severity": "low", "domain": "style", "body": "Self-references inside codeunits should be qualified with this. Use this.IsCustomerNoFilled(CustomerNo) for clarity."}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "Well-structured codeunit with PascalCase naming, Label for messages, and clean formatting", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/Page50200.ItemList.al b/src/Page50200.ItemList.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/Page50200.ItemList.al\n@@ -0,0 +1,49 @@\n+page 50200 \"Item List Extension\"\n+{\n+ PageType = List;\n+ SourceTable = Item;\n+ ApplicationArea = All;\n+ Caption = 'Item List Extension';\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(General)\n+ {\n+ field(ItemNo; Rec.\"No.\")\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Item No.';\n+ ToolTip = 'Specifies the item number.';\n+ }\n+ field(Description; Rec.Description)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Description';\n+ ToolTip = 'Specifies the item description.';\n+ }\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(Processing)\n+ {\n+ action(RefreshData)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Refresh';\n+ ToolTip = 'Refreshes the item list.';\n+ Image = Refresh;\n+\n+ trigger OnAction()\n+ begin\n+ CurrPage.Update(false);\n+ end;\n+ }\n+ }\n+ }\n+}\n+\ndiff --git a/src/HungarianVariableStyle.Codeunit.al b/src/HungarianVariableStyle.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/HungarianVariableStyle.Codeunit.al\n@@ -0,0 +1,15 @@\n+codeunit 50343 \"Hungarian Variable Style\"\n+{\n+ local procedure CopyCount(StartCount: Integer): Integer\n+ var\n+ iCount: Integer;\n+ begin\n+ iCount := StartCount;\n+ exit(iCount);\n+ end;\n+\n+ local procedure ReturnEnabled(Enabled: Boolean): Boolean\n+ begin\n+ exit(Enabled);\n+ end;\n+}\n", "expected_comments": [{"file": "src/HungarianVariableStyle.Codeunit.al", "line_start": 5, "line_end": 5, "severity": "low", "domain": "style", "body": "Variable name iCount uses a Hungarian-style prefix. AL variables should use descriptive PascalCase names without type prefixes."}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "Well-structured page with proper captions, tooltips, naming conventions, and ApplicationArea", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-004", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/CleanDocumentation.Codeunit.al b/src/CleanDocumentation.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CleanDocumentation.Codeunit.al\n@@ -0,0 +1,35 @@\n+codeunit 50002 \"Clean Documentation\"\n+{\n+ Permissions = tabledata Customer = R;\n+\n+ /// \n+ /// Validates a customer record for completeness.\n+ /// \n+ /// The customer number to validate.\n+ /// True if the customer is valid.\n+ procedure ValidateCustomer(CustomerNo: Code[20]): Boolean\n+ var\n+ Customer: Record Customer;\n+ begin\n+ if not Customer.Get(CustomerNo) then\n+ exit(false);\n+\n+ exit(Customer.Name <> '');\n+ end;\n+\n+ /// \n+ /// Calculates the total balance for a customer.\n+ /// \n+ /// The customer number.\n+ /// The total balance amount.\n+ procedure GetCustomerBalance(CustomerNo: Code[20]): Decimal\n+ var\n+ Customer: Record Customer;\n+ begin\n+ if not Customer.Get(CustomerNo) then\n+ exit(0);\n+\n+ Customer.CalcFields(\"Balance (LCY)\");\n+ exit(Customer.\"Balance (LCY)\");\n+ end;\n+}\ndiff --git a/src/Page50344.FileNameStyle.al b/src/Page50344.FileNameStyle.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/Page50344.FileNameStyle.al\n@@ -0,0 +1,17 @@\n+codeunit 50344 \"File Name Style\"\n+{\n+ local procedure EchoText(InputText: Text): Text\n+ begin\n+ exit(InputText);\n+ end;\n+\n+ local procedure EchoCode(InputCode: Code[20]): Code[20]\n+ begin\n+ exit(InputCode);\n+ end;\n+\n+ local procedure IsReady(Ready: Boolean): Boolean\n+ begin\n+ exit(Ready);\n+ end;\n+}\n", "expected_comments": [{"file": "src/Page50344.FileNameStyle.al", "line_start": 1, "line_end": 1, "severity": "low", "domain": "style", "body": "File name does not follow the ..al pattern. This codeunit should be in FileNameStyle.Codeunit.al."}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "False positive style findings: documentation_fp (192 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/CleanErrorHandling.Codeunit.al b/src/CleanErrorHandling.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CleanErrorHandling.Codeunit.al\n@@ -0,0 +1,32 @@\n+codeunit 50003 \"Clean Error Handling\"\n+{\n+ var\n+ CustomerNotFoundErr: Label 'Customer %1 was not found.', Comment = '%1 = Customer No.';\n+\n+ /// \n+ /// Processes a customer record with proper error handling.\n+ /// \n+ /// The customer number to process.\n+ procedure ProcessCustomer(CustomerNo: Code[20])\n+ var\n+ Customer: Record Customer;\n+ begin\n+ if not Customer.Get(CustomerNo) then\n+ Error(CustomerNotFoundErr, CustomerNo);\n+\n+ Customer.TestField(Name);\n+ end;\n+\n+ /// \n+ /// Attempts to validate a single customer record.\n+ /// \n+ /// The customer number to validate.\n+ [TryFunction]\n+ procedure TryValidateCustomer(CustomerNo: Code[20])\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.Get(CustomerNo);\n+ Customer.TestField(Name);\n+ end;\n+}\ndiff --git a/src/LineStartStyle.Codeunit.al b/src/LineStartStyle.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/LineStartStyle.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50345 \"Line Start Style\"\n+{\n+ local procedure SumRange(StartIndex: Integer; LimitCount: Integer): Integer\n+ var\n+ Index: Integer;\n+ Counter: Integer;\n+ begin\n+ for Index := StartIndex to LimitCount do begin\n+ Counter := Counter + Index; end;\n+ exit(Counter);\n+ end;\n+\n+ local procedure EchoCount(CountValue: Integer): Integer\n+ begin\n+ exit(CountValue);\n+ end;\n+}\n", "expected_comments": [{"file": "src/LineStartStyle.Codeunit.al", "line_start": 9, "line_end": 9, "severity": "low", "domain": "style", "body": "The end keyword appears after another statement on the same line. END, IF, REPEAT, UNTIL, FOR, WHILE, and CASE statements should start a line."}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "False positive style findings: error_handling_fp (2 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-006", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/CleanFormatting.Codeunit.al b/src/CleanFormatting.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CleanFormatting.Codeunit.al\n@@ -0,0 +1,35 @@\n+codeunit 50004 \"Clean Formatting\"\n+{\n+ /// \n+ /// Applies a discount percentage to a sales line.\n+ /// \n+ /// The sales line to update.\n+ /// The discount percentage to apply.\n+ procedure ApplyDiscount(var SalesLine: Record \"Sales Line\"; DiscountPct: Decimal)\n+ begin\n+ if DiscountPct <= 0 then\n+ exit;\n+\n+ if DiscountPct > 100 then\n+ DiscountPct := 100;\n+\n+ SalesLine.\"Line Discount %\" := DiscountPct;\n+ SalesLine.Modify(true);\n+ end;\n+\n+ /// \n+ /// Finds the unit price for an item.\n+ /// \n+ /// The item number to look up.\n+ /// The unit price of the item.\n+ procedure FindUnitPrice(ItemNo: Code[20]): Decimal\n+ var\n+ Item: Record Item;\n+ begin\n+ Item.SetLoadFields(\"Unit Price\");\n+ if Item.Get(ItemNo) then\n+ exit(Item.\"Unit Price\");\n+\n+ exit(0);\n+ end;\n+}\ndiff --git a/src/BeginPlacementStyle.Codeunit.al b/src/BeginPlacementStyle.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/BeginPlacementStyle.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50346 \"Begin Placement Style\"\n+{\n+ local procedure ClampAmount(Amount: Decimal; MaximumAmount: Decimal): Decimal\n+ begin\n+ if Amount > MaximumAmount then\n+ begin\n+ Amount := MaximumAmount;\n+ MaximumAmount := Amount;\n+ end;\n+ exit(Amount);\n+ end;\n+\n+ local procedure EchoAmount(Amount: Decimal): Decimal\n+ begin\n+ exit(Amount);\n+ end;\n+}\n", "expected_comments": [{"file": "src/BeginPlacementStyle.Codeunit.al", "line_start": 6, "line_end": 6, "severity": "low", "domain": "style", "body": "BEGIN follows THEN on a separate line. Place begin on the same line as then: if Amount > MaximumAmount then begin."}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "False positive style findings: formatting_fp (30 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/CleanNaming.Codeunit.al b/src/CleanNaming.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CleanNaming.Codeunit.al\n@@ -0,0 +1,21 @@\n+codeunit 50005 \"Clean Naming\"\n+{\n+ /// \n+ /// Builds a display label for a customer.\n+ /// \n+ /// The customer number.\n+ /// The customer name.\n+ /// The formatted display label.\n+ procedure FormatCustomerLabel(CustomerNo: Code[20]; CustomerName: Text): Text\n+ begin\n+ exit(this.BuildLabel(CustomerNo, CustomerName));\n+ end;\n+\n+ local procedure BuildLabel(CustomerNo: Code[20]; CustomerName: Text): Text\n+ begin\n+ if CustomerName = '' then\n+ exit(CustomerNo);\n+\n+ exit(CustomerNo + ' - ' + CustomerName);\n+ end;\n+}\ndiff --git a/src/CaseFormattingStyle.Codeunit.al b/src/CaseFormattingStyle.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CaseFormattingStyle.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50347 \"Case Formatting Style\"\n+{\n+ local procedure GetStatusName(StatusCode: Code[1]): Text\n+ begin\n+ case StatusCode of\n+ 'A': exit('Active');\n+ 'B': exit('Blocked');\n+ else\n+ exit('Unknown');\n+ end;\n+ end;\n+\n+ local procedure EchoStatus(StatusName: Text): Text\n+ begin\n+ exit(StatusName);\n+ end;\n+}\n", "expected_comments": [{"file": "src/CaseFormattingStyle.Codeunit.al", "line_start": 6, "line_end": 7, "severity": "low", "domain": "style", "body": "CASE branch actions should start on the line after the possibility. Move the exit statements under the 'A' and 'B' labels."}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "False positive style findings: naming_false_positive (57 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-008", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/CustomerIntegrationSetup.table.al b/src/CustomerIntegrationSetup.table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerIntegrationSetup.table.al\n@@ -0,0 +1,40 @@\n+table 50003 \"Customer Integration Setup\"\n+{\n+ Caption = 'Customer Integration Setup';\n+\n+ fields\n+ {\n+ field(1; \"Primary Key\"; Code[10])\n+ {\n+ Caption = 'Primary Key';\n+ DataClassification = SystemMetadata;\n+ ToolTip = 'Specifies the primary key of the setup record.';\n+ }\n+ field(2; \"API Endpoint\"; Text[250])\n+ {\n+ Caption = 'API Endpoint';\n+ DataClassification = CustomerContent;\n+ ToolTip = 'Specifies the API integration endpoint.';\n+ }\n+ field(3; \"Connection Name\"; Text[100])\n+ {\n+ Caption = 'Connection Name';\n+ DataClassification = CustomerContent;\n+ ToolTip = 'Specifies the display name of the integration connection.';\n+ }\n+ field(4; \"Enable Integration\"; Boolean)\n+ {\n+ Caption = 'Enable Integration';\n+ DataClassification = CustomerContent;\n+ ToolTip = 'Specifies whether the integration is enabled.';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Primary Key\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+}\ndiff --git a/src/PascalCaseStyle.Codeunit.al b/src/PascalCaseStyle.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PascalCaseStyle.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50348 \"Pascal Case Style\"\n+{\n+ local procedure calculateTotal(Amount: Decimal; TaxAmount: Decimal): Decimal\n+ begin\n+ exit(Amount + TaxAmount);\n+ end;\n+\n+ local procedure EchoAmount(Amount: Decimal): Decimal\n+ begin\n+ exit(Amount);\n+ end;\n+\n+ local procedure IsGreaterThan(Amount: Decimal; MinimumAmount: Decimal): Boolean\n+ begin\n+ exit(Amount > MinimumAmount);\n+ end;\n+}\n", "expected_comments": [{"file": "src/PascalCaseStyle.Codeunit.al", "line_start": 3, "line_end": 3, "severity": "low", "domain": "style", "body": "Procedure name calculateTotal is not PascalCase. AL function names should use PascalCase, such as CalculateTotal."}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "Clean obsolete patterns with correct ObsoleteState, ObsoleteReason, ObsoleteTag, and Obsolete attribute usage", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/CleanOtherStyle.Page.al b/src/CleanOtherStyle.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CleanOtherStyle.Page.al\n@@ -0,0 +1,59 @@\n+page 50006 \"Customer Detail Card\"\n+{\n+ Caption = 'Customer Detail Card';\n+ PageType = Card;\n+ ApplicationArea = All;\n+ UsageCategory = Administration;\n+ SourceTable = Customer;\n+ AboutTitle = 'About customer detail cards';\n+ AboutText = 'Use this page to view and manage customer information.';\n+\n+ layout\n+ {\n+ area(content)\n+ {\n+ group(General)\n+ {\n+ Caption = 'General';\n+ field(CustomerNo; Rec.\"No.\")\n+ {\n+ ApplicationArea = All;\n+ Caption = 'No.';\n+ ToolTip = 'Specifies the customer number.';\n+ }\n+ field(CustomerName; Rec.Name)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Name';\n+ ToolTip = 'Specifies the customer name.';\n+ }\n+ field(CustomerBalance; Rec.\"Balance (LCY)\")\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Balance (LCY)';\n+ ToolTip = 'Specifies the balance in local currency.';\n+ }\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(processing)\n+ {\n+ action(UpdateRecord)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Update';\n+ ToolTip = 'Updates the customer record.';\n+ Image = Refresh;\n+\n+ trigger OnAction()\n+ begin\n+ CurrPage.Update(false);\n+ end;\n+ }\n+ }\n+ }\n+}\n+\ndiff --git a/src/OperatorSpacingStyle.Codeunit.al b/src/OperatorSpacingStyle.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/OperatorSpacingStyle.Codeunit.al\n@@ -0,0 +1,15 @@\n+codeunit 50349 \"Operator Spacing Style\"\n+{\n+ local procedure CalculateTotal(Amount: Decimal; Quantity: Decimal): Decimal\n+ var\n+ Total: Decimal;\n+ begin\n+ Total:=Amount*Quantity;\n+ exit(Total);\n+ end;\n+\n+ local procedure IsWithinLimit(Amount: Decimal; LimitAmount: Decimal): Boolean\n+ begin\n+ exit(Amount <= LimitAmount);\n+ end;\n+}\n", "expected_comments": [{"file": "src/OperatorSpacingStyle.Codeunit.al", "line_start": 7, "line_end": 7, "severity": "low", "domain": "style", "body": "Missing spaces around assignment and multiplication operators. AL style requires exactly one space on each side of binary operators."}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "False positive style findings: other_style (184 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-010", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/ServiceMgtSetup.Page.al b/src/ServiceMgtSetup.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ServiceMgtSetup.Page.al\n@@ -0,0 +1,196 @@\n+page 50104 \"Service Mgt. Setup\"\n+{\n+ AccessByPermission = TableData \"Service Header\" = R;\n+ ApplicationArea = Service;\n+ Caption = 'Service Management Setup';\n+ DeleteAllowed = false;\n+ InsertAllowed = false;\n+ PageType = Card;\n+ SourceTable = \"Service Mgt. Setup\";\n+ UsageCategory = Administration;\n+\n+ layout\n+ {\n+ area(content)\n+ {\n+ group(General)\n+ {\n+ Caption = 'General';\n+ field(\"Service Order Nos.\"; Rec.\"Service Order Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to service orders.';\n+ }\n+ field(\"Service Quote Nos.\"; Rec.\"Service Quote Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to service quotes.';\n+ }\n+ field(\"Service Invoice Nos.\"; Rec.\"Service Invoice Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to service invoices.';\n+ }\n+ field(\"Service Credit Memo Nos.\"; Rec.\"Service Credit Memo Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to service credit memos.';\n+ }\n+ field(\"Posted Service Invoice Nos.\"; Rec.\"Posted Service Invoice Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to posted service invoices.';\n+ }\n+ field(\"Posted Serv. Credit Memo Nos.\"; Rec.\"Posted Serv. Credit Memo Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to posted service credit memos.';\n+ }\n+ field(\"Service Shipment Nos.\"; Rec.\"Service Shipment Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to service shipments.';\n+ }\n+ field(\"Loaner Nos.\"; Rec.\"Loaner Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to loaners.';\n+ }\n+ field(\"Service Item Nos.\"; Rec.\"Service Item Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to service items.';\n+ }\n+ field(\"Service Contract Nos.\"; Rec.\"Service Contract Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to service contracts.';\n+ }\n+ field(\"Contract Template Nos.\"; Rec.\"Contract Template Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to contract templates.';\n+ }\n+ field(\"Contract Invoice Nos.\"; Rec.\"Contract Invoice Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to contract invoices.';\n+ }\n+ field(\"Contract Credit Memo Nos.\"; Rec.\"Contract Credit Memo Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to contract credit memos.';\n+ }\n+ field(\"Troubleshooting Nos.\"; Rec.\"Troubleshooting Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series that will be used to assign numbers to troubleshooting guidelines.';\n+ }\n+ }\n+ group(Defaults)\n+ {\n+ Caption = 'Defaults';\n+ field(\"Default Response Time (Hours)\"; Rec.\"Default Response Time (Hours)\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the default response time in hours for new service orders.';\n+ }\n+ field(\"Service Order Type\"; Rec.\"Service Order Type\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the default service order type for new service orders.';\n+ }\n+ field(\"Default Warranty Duration\"; Rec.\"Default Warranty Duration\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the default warranty duration for service items.';\n+ }\n+ field(\"One Service Item Line/Order\"; Rec.\"One Service Item Line/Order\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies that service orders can contain only one service item line.';\n+ }\n+ field(\"Skip Manual Res. Alloc.\"; Rec.\"Skip Manual Res. Alloc.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies that the system should skip manual resource allocation when creating service orders.';\n+ }\n+ }\n+ group(Posting)\n+ {\n+ Caption = 'Posting';\n+ field(\"Enable Concurrent Posting\"; Rec.\"Ship-to Address\")\n+ {\n+ ApplicationArea = Service;\n+ Caption = 'Enable Concurrent Posting';\n+ ToolTip = 'Specifies whether concurrent posting is enabled.';\n+ }\n+ field(\"Posted Service Inv. Nos.\"; Rec.\"Posted Service Invoice Nos.\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies the code for the number series for posted service invoices.';\n+ Visible = false;\n+ }\n+ field(\"Logo Position on Documents\"; Rec.\"Logo Position on Documents\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies where the company logo appears on printed service documents.';\n+ }\n+ field(\"Fault Reason Code Mandatory\"; Rec.\"Fault Reason Code Mandatory\")\n+ {\n+ ApplicationArea = Service;\n+ ToolTip = 'Specifies that a fault reason code must be entered on service lines.';\n+ }\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(navigation)\n+ {\n+ action(\"Number Series\")\n+ {\n+ ApplicationArea = Service;\n+ Caption = 'Number Series';\n+ Image = NumberSetup;\n+ RunObject = Page \"No. Series\";\n+ ToolTip = 'Set up the number series from which a new number is automatically assigned to new cards and documents. You can set up a new number series or change existing number series.';\n+ }\n+ }\n+ area(processing)\n+ {\n+ action(\"Reset to Defaults\")\n+ {\n+ ApplicationArea = Service;\n+ Caption = 'Reset to Defaults';\n+ Image = Restore;\n+ ToolTip = 'Reset all settings to their default values.';\n+\n+ trigger OnAction()\n+ begin\n+ if Confirm('Are you sure you want to reset all settings to their default values?') then\n+ ResetToDefaults();\n+ end;\n+ }\n+ }\n+ }\n+\n+ trigger OnOpenPage()\n+ begin\n+ if not Rec.Get() then begin\n+ Rec.Init();\n+ Rec.Insert();\n+ end;\n+ end;\n+\n+ local procedure ResetToDefaults()\n+ begin\n+ Rec.\"Default Response Time (Hours)\" := 24;\n+ Rec.\"One Service Item Line/Order\" := true;\n+ Rec.\"Skip Manual Res. Alloc.\" := false;\n+ Rec.\"Fault Reason Code Mandatory\" := true;\n+ Rec.Modify(true);\n+ end;\n+}\n+\n", "expected_comments": [{"file": "src/ServiceMgtSetup.Page.al", "line_start": 122, "line_end": 122, "body": "Misleading field name: 'Enable Concurrent Posting' is bound to Rec.\"Ship-to Address\", which is a completely unrelated field. The Caption and field name suggest posting behavior but the source is an address field. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/ServiceMgtSetup.Page.al", "line_start": 172, "line_end": 172, "body": "Hardcoded text string in Confirm() call: 'Are you sure you want to reset all settings to their default values?' should use a Label variable with Qst suffix (CodeCop AA0217). — See agent comment for details.", "severity": "medium", "domain": "style"}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "True positive style findings: caption violations in service setup page", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-011", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/AgentTaskTemplate.Codeunit.al b/src/AgentTaskTemplate.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/AgentTaskTemplate.Codeunit.al\n@@ -0,0 +1,46 @@\n+codeunit 50105 \"Agent Task Template\"\n+{\n+ Access = Internal;\n+\n+ var\n+ TempBlob: Codeunit \"Temp Blob\";\n+ TemplateStream: InStream;\n+ TemplateOutStream: OutStream;\n+\n+ procedure ImportTemplate(FilePath: Text): Boolean\n+ var\n+ FileManagement: Codeunit \"File Management\";\n+ ImportFile: File;\n+ ImportInStream: InStream;\n+ TemplateRecord: Record \"Agent Template\";\n+ TemplateExists: Boolean;\n+ ValidationResult: Boolean;\n+ ProcessingError: Text;\n+ ImportSuccess: Boolean;\n+ DocumentType: Text;\n+ DocumentVersion: Text;\n+ DocumentContent: Text;\n+ ProcessingOptions: Record \"Template Processing Options\";\n+ FieldMapping: Record \"Field Mapping\";\n+ ValidationRules: Record \"Validation Rules\";\n+ TransformationRules: Record \"Transformation Rules\";\n+ OutputConfiguration: Record \"Output Configuration\";\n+ LoggingOptions: Record \"Logging Options\";\n+ SecuritySettings: Record \"Security Settings\";\n+ PerformanceSettings: Record \"Performance Settings\";\n+ ErrorHandlingSettings: Record \"Error Handling Settings\";\n+ NotificationSettings: Record \"Notification Settings\";\n+\n+ begin // begin keyword at line 78 - but more variables after\n+ // Initialize processing\n+ ImportSuccess := false;\n+ DocumentType := '';\n+\n+ if FilePath = '' then begin\n+ ProcessingError := 'File does not exist: ' + FilePath;\n+ exit(false);\n+ end;\n+\n+ exit(ImportSuccess);\n+ end;\n+}\ndiff --git a/src/NonDeductiblePurchPosting.Codeunit.al b/src/NonDeductiblePurchPosting.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/NonDeductiblePurchPosting.Codeunit.al\n@@ -0,0 +1,51 @@\n+codeunit 50108 \"Non-Deductible Purch. Posting\"\n+{\n+ Access = Internal;\n+\n+ var\n+ GeneralLedgerSetup: Record \"General Ledger Setup\";\n+ VATPostingSetup: Record \"VAT Posting Setup\";\n+ TempItemLedgerEntry: Record \"Item Ledger Entry\" temporary;\n+\n+ local procedure GetVATPostingSetup(PurchaseLine: Record \"Purchase Line\"): Boolean\n+ begin\n+ if VATPostingSetup.Get(PurchaseLine.\"VAT Bus. Posting Group\", PurchaseLine.\"VAT Prod. Posting Group\") then\n+ exit(true)\n+ else\n+ exit(false);\n+ end;\n+\n+ local procedure PostNonDeductibleVATEntry(PurchaseLine: Record \"Purchase Line\"; NonDeductibleAmount: Decimal): Boolean\n+ var\n+ GenJournalLine: Record \"Gen. Journal Line\";\n+ PostingSuccess: Boolean;\n+ begin\n+ PostingSuccess := false;\n+\n+ GenJournalLine.Description := 'Non-Deductible VAT - ' + PurchaseLine.\"No.\";\n+\n+ if GenJournalLine.Amount = 0 then begin\n+ begin // Unnecessary BEGIN..END for compound statement\n+ if PostingSuccess then\n+ CreateVATEntry(GenJournalLine, NonDeductibleAmount);\n+ end\n+ else begin\n+ PostingSuccess := false;\n+ end;\n+\n+ exit(PostingSuccess);\n+ end;\n+\n+ local procedure GetNonDeductibleVATAccount(PurchaseLine: Record \"Purchase Line\"; var AccountNo: Code[20]): Boolean\n+ begin\n+ AccountNo := '';\n+ exit(false);\n+ end;\n+\n+ local procedure CreateVATEntry(GenJournalLine: Record \"Gen. Journal Line\"; VATAmount: Decimal)\n+ var\n+ SourceCode: Code[10];\n+ begin\n+ SourceCode := 'PURCHASES';\n+ end;\n+}\ndiff --git a/src/PayablesAgent.Codeunit.al b/src/PayablesAgent.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PayablesAgent.Codeunit.al\n@@ -0,0 +1,61 @@\n+codeunit 50106 \"Payables Agent\"\n+{\n+ Access = Internal;\n+\n+ procedure ProcessSingleInvoice(var PurchaseHeader: Record \"Purchase Header\"): Boolean\n+ var\n+ ProcessingResult: Boolean;\n+ ValidationResult: Boolean;\n+ PostingResult: Boolean;\n+ ErrorMessage: Text;\n+ begin\n+ ProcessingResult := false;\n+\n+ ValidationResult := ValidateInvoice(PurchaseHeader, ErrorMessage);\n+ if not ValidationResult then\n+ exit(false);\n+\n+ if ShouldAutoPost(PurchaseHeader) then begin\n+ begin // BEGIN..END for single statement\n+ if PurchaseHeader.\"Document Type\" = PurchaseHeader.\"Document Type\"::Invoice then\n+ PostingResult := true;\n+ end\n+ else begin\n+ PostingResult := true;\n+ end;\n+\n+ if PostingResult then\n+ ProcessingResult := true;\n+\n+ exit(ProcessingResult);\n+ end;\n+\n+ local procedure ValidateInvoice(var PurchaseHeader: Record \"Purchase Header\"; var ErrorMessage: Text): Boolean\n+ var\n+ PurchaseLine: Record \"Purchase Line\";\n+ Vendor: Record Vendor;\n+ IsValid: Boolean;\n+ begin\n+ IsValid := true;\n+ ErrorMessage := '';\n+\n+ if not Vendor.Get(PurchaseHeader.\"Buy-from Vendor No.\") then begin\n+ ErrorMessage := 'Vendor does not exist: ' + PurchaseHeader.\"Buy-from Vendor No.\";\n+ exit(false);\n+ end;\n+\n+ PurchaseLine.SetRange(\"Document Type\", PurchaseHeader.\"Document Type\");\n+ PurchaseLine.SetRange(\"Document No.\", PurchaseHeader.\"No.\");\n+ if PurchaseLine.IsEmpty() then begin\n+ ErrorMessage := 'No purchase lines found for document: ' + PurchaseHeader.\"No.\";\n+ exit(false);\n+ end;\n+\n+ exit(IsValid);\n+ end;\n+\n+ local procedure ShouldAutoPost(var PurchaseHeader: Record \"Purchase Header\"): Boolean\n+ begin\n+ exit(PurchaseHeader.\"Document Type\" = PurchaseHeader.\"Document Type\"::Invoice);\n+ end;\n+}\ndiff --git a/src/PaymentToleranceManagement.Codeunit.al b/src/PaymentToleranceManagement.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PaymentToleranceManagement.Codeunit.al\n@@ -0,0 +1,61 @@\n+codeunit 50107 \"Payment Tolerance Management\"\n+{\n+ Access = Internal;\n+\n+ var\n+ GeneralLedgerSetup: Record \"General Ledger Setup\";\n+ PaymentTerms: Record \"Payment Terms\";\n+ CurrencyExchangeRate: Record \"Currency Exchange Rate\";\n+\n+ procedure CalculatePaymentTolerance(var CustLedgerEntry: Record \"Cust. Ledger Entry\"; PaymentAmount: Decimal; var ToleranceAmount: Decimal): Boolean\n+ var\n+ RemainingAmount: Decimal;\n+ MaxToleranceAmount: Decimal;\n+ MaxTolerancePercent: Decimal;\n+ IsWithinTolerance: Boolean;\n+ CalculationBase: Decimal;\n+ WorkDate: Date;\n+ PmtToleranceAmount: Decimal;\n+ CurrencyPrecision: Decimal;\n+ begin\n+ ToleranceAmount := 0;\n+ CurrencyPrecision := 0.01;\n+\n+ if CustLedgerEntry.\"Entry No.\" = 0 then\n+ exit(false);\n+\n+ CalculationBase := Abs(RemainingAmount);\n+\n+ if MaxTolerancePercent <> 0 then\n+ ToleranceAmount := Round(CalculationBase * MaxTolerancePercent / 100, CurrencyPrecision);\n+\n+ if (MaxToleranceAmount > 0) and (ToleranceAmount > MaxToleranceAmount) then\n+ ToleranceAmount := MaxToleranceAmount;\n+\n+ IsWithinTolerance := true;\n+\n+ if IsWithinTolerance then begin\n+ if CustLedgerEntry.\"Document Type\" = CustLedgerEntry.\"Document Type\"::Invoice then begin\n+ if (PmtToleranceAmount > 0) and (ToleranceAmount > 0) then\n+ ToleranceAmount := PmtToleranceAmount\n+ else\n+ ToleranceAmount := 0;\n+ end;\n+ end else\n+ ToleranceAmount := 0;\n+\n+ exit(IsWithinTolerance);\n+ end;\n+\n+ procedure PostToleranceEntry(var CustLedgerEntry: Record \"Cust. Ledger Entry\"; ToleranceAmount: Decimal; PostingDate: Date): Boolean\n+ var\n+ GenJournalLine: Record \"Gen. Journal Line\";\n+ PostingResult: Boolean;\n+ begin\n+ PostingResult := false;\n+\n+ GenJournalLine.Description := 'Payment Tolerance for ' + CustLedgerEntry.\"Document No.\";\n+\n+ exit(PostingResult);\n+ end;\n+}\n", "expected_comments": [{"file": "src/AgentTaskTemplate.Codeunit.al", "line_start": 34, "line_end": 34, "body": "The 'begin' keyword is placed after local variable declarations, but there are additional variable declarations after 'begin' in the refactored code structure. The procedure 'ImportTemplate' has its 'begin' keyword at line 34, but there are more variable declarations at lines 44-45 that belong to 'ImportTemplateFromStream'. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 28, "line_end": 28, "body": "Unnecessary BEGIN..END for compound statement. Per CodeCop AA0005/AA0013, BEGIN should only be used when enclosing multiple statements. The if-then-else structure here uses BEGIN..END appropriately for multiple statements in each branch, but the BEGIN must be on the same line as THEN (which it is). However, the else-begin on line 137 follows the same pattern correctly. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 19, "line_end": 19, "body": "BEGIN..END used after ELSE on line 81 for a single compound statement that only executes conditionally. The IF on line 78 means there are two statements, so BEGIN..END is correct, but the structure creates potentially unreachable code. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/PaymentToleranceManagement.Codeunit.al", "line_start": 39, "line_end": 39, "body": "Inconsistent indentation - the if statement has excessive indentation that doesn't align with the surrounding code structure — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/AgentTaskTemplate.Codeunit.al", "line_start": 6, "line_end": 6, "body": "Unused global variables (AA0137): TempBlob, TemplateStream, and TemplateOutStream are declared at codeunit scope but never referenced in any procedure. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/AgentTaskTemplate.Codeunit.al", "line_start": 12, "line_end": 12, "body": "Unused variables (AA0137): Multiple variables declared in ImportTemplate (FileManagement, ImportFile, TemplateRecord, TemplateExists, DocumentVersion, DocumentContent, etc.) are never referenced. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/AgentTaskTemplate.Codeunit.al", "line_start": 40, "line_end": 40, "body": "Hardcoded error string with concatenation (AA0217): 'File does not exist: ' is used inline instead of a label variable with proper suffix. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 8, "line_end": 8, "body": "Unused global variable (AA0137): TempItemLedgerEntry is declared at codeunit scope but never referenced. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 28, "line_end": 28, "body": "Malformed begin..end..else structure (AA0005): An unnecessary nested begin..end block inside the 'then begin' block creates an invalid else association. — See agent comment for details.", "severity": "critical", "domain": "style"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 39, "line_end": 39, "body": "Missing procedure closing end (AA0005): PostNonDeductibleVATEntry procedure is missing its closing 'end;' before a new local procedure begins, resulting in a nested procedure declaration. — See agent comment for details.", "severity": "critical", "domain": "style"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 25, "line_end": 25, "body": "Hardcoded string with concatenation (AA0217): 'Non-Deductible VAT - ' is used inline in Description assignment instead of a label variable. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 49, "line_end": 49, "body": "Hardcoded string 'PURCHASES' should use a label with Tok suffix and Locked = true (AA0217). — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/NonDeductiblePurchPosting.Codeunit.al", "line_start": 12, "line_end": 12, "body": "Simplifiable pattern: 'if X then exit(true) else exit(false)' in GetVATPostingSetup can be simplified to 'exit(X)' for cleaner code. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 19, "line_end": 19, "body": "Malformed begin..end..else structure (AA0005): An unnecessary nested begin..end block inside the 'then begin' creates an invalid else association. — See agent comment for details.", "severity": "critical", "domain": "style"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 33, "line_end": 33, "body": "Missing procedure closing end (AA0005): ProcessSingleInvoice procedure is missing its closing 'end;' before a new local procedure begins. — See agent comment for details.", "severity": "critical", "domain": "style"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 43, "line_end": 43, "body": "Hardcoded error string with concatenation (AA0217): 'Vendor does not exist: ' is used inline instead of a label variable. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/PayablesAgent.Codeunit.al", "line_start": 50, "line_end": 50, "body": "Hardcoded error string with concatenation (AA0217): 'No purchase lines found for document: ' is used inline instead of a label variable. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/PaymentToleranceManagement.Codeunit.al", "line_start": 7, "line_end": 7, "body": "Unused global variables (AA0137): PaymentTerms and CurrencyExchangeRate are declared but never referenced. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/PaymentToleranceManagement.Codeunit.al", "line_start": 17, "line_end": 17, "body": "Variable name conflict (AA0204): Local variable 'WorkDate' shadows the built-in WorkDate() function. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/PaymentToleranceManagement.Codeunit.al", "line_start": 57, "line_end": 57, "body": "Hardcoded string with concatenation (AA0217): 'Payment Tolerance for ' is used inline in Description instead of a label variable. — See agent comment for details.", "severity": "high", "domain": "style"}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "True positive style findings: code_structure (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-012", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/CustomerNotification.Codeunit.al b/src/CustomerNotification.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerNotification.Codeunit.al\n@@ -0,0 +1,18 @@\n+codeunit 50301 \"Customer Notification\"\n+{\n+ Access = Public;\n+\n+ procedure SendOverdueNotice(CustomerNo: Code[20])\n+ begin\n+ // SendEmail(Customer);\n+ Message('An overdue notice has been sent to the customer.');\n+ end;\n+\n+ procedure MarkAsNotified(EntryNo: Integer)\n+ begin\n+ if GuiAllowed() then\n+ if not Confirm('Are you sure you want to mark entry as notified?') then\n+ exit;\n+ Message('Entry has been marked as notified.');\n+ end;\n+}\ndiff --git a/src/InventoryHelper.Codeunit.al b/src/InventoryHelper.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/InventoryHelper.Codeunit.al\n@@ -0,0 +1,27 @@\n+codeunit 50300 \"Inventory Helper\"\n+{\n+ Access = Public;\n+\n+\n+ var\n+ UnusedCounter: Integer;\n+\n+ procedure AdjustStock(ItemNo: Code[20]; Qty: Decimal): Decimal\n+ var\n+ Item: Record Item;\n+ begin\n+ if Item.Get(ItemNo) then\n+ exit(Item.\"Reorder Quantity\" + Qty);\n+ exit(0);\n+ end;\n+\n+ procedure PostAdjustment(ItemNo: Code[20]; Qty: Decimal)\n+ var\n+ TempValue: Decimal;\n+ begin\n+ if Qty = 0 then\n+ Error('Quantity must not be zero.');\n+\n+ Message('Adjustment posted for item ' + ItemNo);\n+ end;\n+}\ndiff --git a/src/PurchaseValidator.Codeunit.al b/src/PurchaseValidator.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PurchaseValidator.Codeunit.al\n@@ -0,0 +1,17 @@\n+codeunit 50302 \"Purchase Validator\"\n+{\n+ Access = Public;\n+\n+ procedure ValidateHeader(PurchaseHeader: Record \"Purchase Header\"): Boolean\n+ begin\n+ if PurchaseHeader.\"Buy-from Vendor No.\" = '' then\n+ Error('Vendor must be specified.');\n+ exit(true);\n+ end;\n+\n+ procedure ValidateLines(DocNo: Code[20])\n+ begin\n+ if DocNo = '' then\n+ Error('Purchase document must have at least one line.');\n+ end;\n+}\n", "expected_comments": [{"file": "src/InventoryHelper.Codeunit.al", "line_start": 9, "line_end": 9, "body": "Public procedure 'AdjustStock' lacks XML documentation comments. Public procedures should have /// documentation. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/InventoryHelper.Codeunit.al", "line_start": 7, "line_end": 7, "body": "Unused global variable 'UnusedCounter' (AA0137). — See agent comment for details.", "severity": "low", "domain": "style"}, {"file": "src/InventoryHelper.Codeunit.al", "line_start": 23, "line_end": 23, "body": "Hardcoded text string in Error() call (CodeCop AA0217): 'Quantity must not be zero.' should use a Label variable. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/InventoryHelper.Codeunit.al", "line_start": 25, "line_end": 25, "body": "Hardcoded text string in Message() call (CodeCop AA0217): 'Adjustment posted for item ' should use a Label variable. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/CustomerNotification.Codeunit.al", "line_start": 7, "line_end": 7, "body": "Commented-out code '// SendEmail(Customer);' should be removed (clean code). — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/CustomerNotification.Codeunit.al", "line_start": 8, "line_end": 8, "body": "Hardcoded text string in Message() call (CodeCop AA0217): 'Overdue notice sent to customer ' should use a Label variable. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/CustomerNotification.Codeunit.al", "line_start": 14, "line_end": 14, "body": "Hardcoded text string in Confirm() call (CodeCop AA0217): 'Are you sure you want to mark entry as notified?' should use a Label variable with Qst suffix. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/PurchaseValidator.Codeunit.al", "line_start": 8, "line_end": 8, "body": "Hardcoded text string in Error() call (CodeCop AA0217): 'Vendor must be specified.' should use a Label variable with Err suffix. — See agent comment for details.", "severity": "high", "domain": "style"}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "True positive style findings: documentation — missing XML docs, hardcoded strings, unused variables, commented-out code", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-013", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/CloudMigReplicateDataMgt.Codeunit.al b/src/CloudMigReplicateDataMgt.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CloudMigReplicateDataMgt.Codeunit.al\n@@ -0,0 +1,18 @@\n+codeunit 50117 \"Cloud Mig. Replicate Data Mgt\"\n+{\n+ Access = Internal;\n+\n+ var\n+ TablesCannotBeEnabledForReplicationErr: Label 'The following tables cannot be enabled for replication because they contain sensitive data or are system tables:%1\\\\Please review the table selection and remove any tables that should not be replicated to ensure data security and compliance with privacy regulations.', Comment = '%1 = List of table names';\n+\n+ procedure ValidateTablesForReplication(var TableList: Record \"Cloud Migration Table\" temporary; RestrictedTableNames: Text)\n+ begin\n+ if RestrictedTableNames <> '' then\n+ Error(TablesCannotBeEnabledForReplicationErr, RestrictedTableNames);\n+ end;\n+\n+ procedure ReportReplicationResult(EnabledCount: Integer; TableCount: Integer)\n+ begin\n+ Message('Replication enabled for %1 of %2 tables', EnabledCount, TableCount);\n+ end;\n+}\ndiff --git a/src/CustStPDFDocHandler.Codeunit.al b/src/CustStPDFDocHandler.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustStPDFDocHandler.Codeunit.al\n@@ -0,0 +1,62 @@\n+codeunit 50114 \"Cust. St. PDF Doc Handler\"\n+{\n+ Access = Internal;\n+\n+ var\n+ TempBlob: Codeunit \"Temp Blob\";\n+ FileManagement: Codeunit \"File Management\";\n+\n+ UnableToProcessDocumentErr: Label 'Unable to process document with SystemId %1,', Comment = 'SystemId %1';\n+\n+ procedure ProcessCustomerStatement(CustomerNo: Code[20]; StatementDate: Date): Boolean\n+ var\n+ ProcessingResult: Boolean;\n+ StatementGuid: Guid;\n+ begin\n+ ProcessingResult := false;\n+\n+ if IsNullGuid(StatementGuid) then\n+ Error(UnableToProcessDocumentErr, StatementGuid)\n+ else\n+ Error('Unable to create customer statement record for customer %1', CustomerNo);\n+\n+ exit(ProcessingResult);\n+ end;\n+\n+ local procedure GenerateStatementReport(ReportID: Integer; OutputFileName: Text): Boolean\n+ var\n+ GenerationSuccess: Boolean;\n+ begin\n+ GenerationSuccess := false;\n+\n+ try\n+ GenerationSuccess := FileManagement.ServerFileExists(OutputFileName);\n+ GenerationSuccess := FileManagement.ServerFileExists(OutputFileName);\n+ except\n+ GenerationSuccess := false;\n+ end;\n+\n+ exit(GenerationSuccess);\n+ end;\n+\n+ procedure ValidateStatementParameters(CustomerNo: Code[20]; StatementDate: Date): Boolean\n+ var\n+ Customer: Record Customer;\n+ ValidationResult: Boolean;\n+ begin\n+ ValidationResult := true;\n+\n+ Customer.SetLoadFields(\"No.\");\n+ if not Customer.Get(CustomerNo) then begin\n+ Error('Customer %1 does not exist', CustomerNo);\n+ ValidationResult := false;\n+ end;\n+\n+ if StatementDate > Today then begin\n+ Error('Statement date cannot be in the future');\n+ ValidationResult := false;\n+ end;\n+\n+ exit(ValidationResult);\n+ end;\n+}\ndiff --git a/src/ExpenseCategory.Table.al b/src/ExpenseCategory.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseCategory.Table.al\n@@ -0,0 +1,155 @@\n+table 50115 \"Expense Category\"\n+{\n+ Caption = 'Expense Category';\n+ DataCaptionFields = Code, Description;\n+ DrillDownPageId = \"Expense Categories\";\n+ LookupPageId = \"Expense Categories\";\n+\n+ fields\n+ {\n+ field(1; Code; Code[20])\n+ {\n+ Caption = 'Code';\n+ NotBlank = true;\n+ DataClassification = CustomerContent;\n+ }\n+ field(2; Description; Text[100])\n+ {\n+ Caption = 'Description';\n+ DataClassification = CustomerContent;\n+ }\n+ field(3; \"G/L Account No.\"; Code[20])\n+ {\n+ Caption = 'G/L Account No.';\n+ TableRelation = \"G/L Account\" where(\"Account Type\" = const(Posting),\n+ Blocked = const(false));\n+ DataClassification = CustomerContent;\n+\n+ trigger OnValidate()\n+ begin\n+ if \"G/L Account No.\" <> xRec.\"G/L Account No.\" then\n+ ValidateGLAccount();\n+ end;\n+ }\n+ field(4; \"Expense Type\"; Enum \"Expense Type\")\n+ {\n+ Caption = 'Expense Type';\n+ DataClassification = CustomerContent;\n+ }\n+ field(5; \"Requires Receipt\"; Boolean)\n+ {\n+ Caption = 'Requires Receipt';\n+ DataClassification = CustomerContent;\n+ InitValue = true;\n+ }\n+ field(6; \"Max Amount\"; Decimal)\n+ {\n+ Caption = 'Max Amount';\n+ DataClassification = CustomerContent;\n+ MinValue = 0;\n+\n+ trigger OnValidate()\n+ begin\n+ if \"Max Amount\" < 0 then\n+ Error('Maximum amount cannot be negative');\n+ end;\n+ }\n+ field(7; \"Approval Required\"; Boolean)\n+ {\n+ Caption = 'Approval Required';\n+ DataClassification = CustomerContent;\n+ }\n+ field(8; \"Approval Amount Threshold\"; Decimal)\n+ {\n+ Caption = 'Approval Amount Threshold';\n+ DataClassification = CustomerContent;\n+ MinValue = 0;\n+ }\n+ field(40; \"Active\"; Boolean)\n+ {\n+ Caption = 'Active';\n+ DataClassification = CustomerContent;\n+ InitValue = true;\n+ }\n+ field(41; \"Effective Date\"; Date)\n+ {\n+ Caption = 'Effective Date';\n+ DataClassification = CustomerContent;\n+ }\n+ field(42; \"Expiration Date\"; Date)\n+ {\n+ Caption = 'Expiration Date';\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; Code)\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ fieldgroup(DropDown; Code, Description, \"Expense Type\")\n+ {\n+ }\n+ fieldgroup(Brick; Code, Description, \"G/L Account No.\", Active)\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ if \"Effective Date\" = 0D then\n+ \"Effective Date\" := Today;\n+\n+ ValidateExpenseCategory();\n+ end;\n+\n+ trigger OnModify()\n+ begin\n+ ValidateExpenseCategory();\n+ end;\n+\n+ trigger OnDelete()\n+ var\n+ ExpenseLine: Record \"Expense Line\";\n+ begin\n+ ExpenseLine.SetRange(\"Expense Category Code\", Code);\n+ if not ExpenseLine.IsEmpty() then\n+ Error('Cannot delete expense category %1 because it is used in expense lines', Code);\n+ end;\n+\n+ local procedure ValidateGLAccount()\n+ var\n+ GLAccount: Record \"G/L Account\";\n+ begin\n+ if \"G/L Account No.\" = '' then\n+ exit;\n+\n+ if not GLAccount.Get(\"G/L Account No.\") then\n+ Error('G/L Account %1 does not exist', \"G/L Account No.\");\n+\n+ if GLAccount.\"Account Type\" <> GLAccount.\"Account Type\"::Posting then\n+ Error('G/L Account %1 must be a posting account', \"G/L Account No.\");\n+\n+ if GLAccount.Blocked then\n+ Error('G/L Account %1 is blocked', \"G/L Account No.\");\n+ end;\n+\n+ local procedure ValidateExpenseCategory()\n+ begin\n+ if Code = '' then\n+ Error('');\n+\n+ if Description = '' then\n+ Error('Description must be specified');\n+\n+ if (\"Effective Date\" <> 0D) and (\"Expiration Date\" <> 0D) then\n+ if \"Expiration Date\" < \"Effective Date\" then\n+ Error('Expiration date cannot be before effective date');\n+ end;\n+}\ndiff --git a/src/ItemCategoryAttributes.Page.al b/src/ItemCategoryAttributes.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ItemCategoryAttributes.Page.al\n@@ -0,0 +1,103 @@\n+page 50118 \"Item Category Attributes\"\n+{\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Item Category Attributes';\n+ PageType = List;\n+ SourceTable = \"Item Attribute\";\n+ UsageCategory = Lists;\n+\n+ layout\n+ {\n+ area(content)\n+ {\n+ repeater(Control1)\n+ {\n+ ShowCaption = false;\n+ field(Name; Rec.Name)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the descriptive name that identifies this attribute when classifying items in the catalog.';\n+ }\n+ field(Type; Rec.Type)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the data type that determines how values are entered and validated for this attribute.';\n+ }\n+ field(Values; Rec.Values)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the predefined list of option values that users can select from for this attribute.';\n+ Visible = Rec.Type = Rec.Type::Option;\n+ }\n+ field(\"Unit of Measure\"; Rec.\"Unit of Measure\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the unit of measure applied when this attribute stores numeric measurements.';\n+ Visible = Rec.Type = Rec.Type::Decimal;\n+ }\n+ field(Blocked; Rec.Blocked)\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies that the attribute is excluded from selection and can no longer be assigned to items.';\n+ }\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(processing)\n+ {\n+ action(\"Import Attributes\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Import Attributes';\n+ Image = Import;\n+ ToolTip = 'Import item attributes from an external CSV file into the catalog.';\n+\n+ trigger OnAction()\n+ var\n+ ImportManager: Codeunit \"Attribute Import Manager\";\n+ FilePath: Text;\n+ ImportResult: Boolean;\n+ begin\n+ if not UploadIntoStream('Select attribute file', '', 'CSV Files|*.csv', FilePath, ImportStream) then\n+ exit;\n+\n+ ImportResult := ImportManager.ImportFromCSV(ImportStream);\n+ if ImportResult then\n+ Message('Attributes imported successfully.')\n+ else\n+ Message('Import completed with errors. Please review the results.');\n+ end;\n+ }\n+ }\n+ }\n+\n+ trigger OnOpenPage()\n+ var\n+ FeatureManagement: Codeunit \"Feature Management\";\n+ BlankOptionAttributeNotification: Notification;\n+ begin\n+ if not FeatureManagement.IsEnabled('ItemAttributes') then begin\n+ Message('Item attributes feature is not enabled. Please contact your system administrator.');\n+ CurrPage.Close();\n+ exit;\n+ end;\n+\n+ BlankOptionAttributeNotification.Id := CreateGuid();\n+ BlankOptionAttributeNotification.Message := BlankOptionAttributeNotificationMsg;\n+ BlankOptionAttributeNotification.Scope := NotificationScope::LocalScope;\n+ BlankOptionAttributeNotification.Send();\n+ end;\n+\n+ trigger OnNewRecord(BelowxRec: Boolean)\n+ begin\n+ Rec.Type := Rec.Type::Text;\n+ Rec.Blocked := false;\n+ end;\n+\n+ var\n+ ImportStream: InStream;\n+ BlankOptionAttributeNotificationMsg: Label 'Some option attributes have blank values that may cause issues.';\n+}\ndiff --git a/src/SCMSupplyPlanningIV.Codeunit.al b/src/SCMSupplyPlanningIV.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/SCMSupplyPlanningIV.Codeunit.al\n@@ -0,0 +1,42 @@\n+codeunit 50116 \"SCM Supply Planning IV\"\n+{\n+ Subtype = Test;\n+\n+ var\n+ Assert: Codeunit Assert;\n+ LibraryInventory: Codeunit \"Library - Inventory\";\n+\n+ AssemblyOrderCreatedMsg: Label 'Assembly order %1 has been created successfully.', Comment = '%1 = Order No.';\n+\n+ [Test]\n+ procedure TestCreateAssemblyOrder()\n+ var\n+ Item: Record Item;\n+ AssemblyHeader: Record \"Assembly Header\";\n+ OrderNo: Code[20];\n+ begin\n+ LibraryInventory.CreateItem(Item);\n+ Item.\"Assembly Policy\" := Item.\"Assembly Policy\"::\"Assemble-to-Order\";\n+ Item.Modify(true);\n+\n+ OrderNo := CreateAssemblyOrderForItem(Item.\"No.\", 10);\n+\n+ AssemblyHeader.Get(AssemblyHeader.\"Document Type\"::Order, OrderNo);\n+ Assert.AreEqual(Item.\"No.\", AssemblyHeader.\"Item No.\", 'Item number should match');\n+ Assert.AreEqual(10, AssemblyHeader.Quantity, AssemblyOrderCreatedMsg);\n+ end;\n+\n+ local procedure CreateAssemblyOrderForItem(ItemNo: Code[20]; Quantity: Decimal): Code[20]\n+ var\n+ AssemblyHeader: Record \"Assembly Header\";\n+ begin\n+ AssemblyHeader.Init();\n+ AssemblyHeader.\"Document Type\" := AssemblyHeader.\"Document Type\"::Order;\n+ AssemblyHeader.\"No.\" := CopyStr(Format(CreateGuid()), 1, 20);\n+ AssemblyHeader.\"Item No.\" := ItemNo;\n+ AssemblyHeader.Quantity := Quantity;\n+ AssemblyHeader.\"Due Date\" := CalcDate('<+7D>', Today);\n+ AssemblyHeader.Insert(true);\n+ exit(AssemblyHeader.\"No.\");\n+ end;\n+}\n", "expected_comments": [{"file": "src/CloudMigReplicateDataMgt.Codeunit.al", "line_start": 11, "line_end": 11, "domain": "style", "body": "Label suffix inconsistency: 'TablesCannotBeEnabledForReplicationErr' uses 'Err' suffix but the message text is more informational/instructional than a pure error condition. However, since it's used with Error(), the suffix is technically correct. — See agent comment for details.", "severity": "medium"}, {"file": "src/CloudMigReplicateDataMgt.Codeunit.al", "line_start": 16, "line_end": 16, "domain": "style", "body": "Hardcoded text string in Message() call (CodeCop AA0217): 'Replication enabled for %1 of %2 tables' should use a Label with Msg suffix. — See agent comment for details.", "severity": "medium"}, {"file": "src/SCMSupplyPlanningIV.Codeunit.al", "line_start": 26, "line_end": 26, "domain": "style", "body": "Label variable 'AssemblyOrderCreatedMsg' uses 'Msg' suffix but is used as an assertion failure message, not a user-facing Message() call. The suffix should indicate the actual usage context. — See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 54, "line_end": 54, "domain": "style", "body": "Using Error('') with empty string is not recommended (CodeCop AA0216). Error messages should use label variables with proper suffix. — See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 123, "line_end": 123, "domain": "style", "body": "Hardcoded error message string in Error() call: 'Maximum amount cannot be negative' (CodeCop AA0217). — See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 134, "line_end": 134, "domain": "style", "body": "Hardcoded error message string in Error() call: 'Cannot delete expense category %1 because it is used in expense lines' (CodeCop AA0217). — See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 146, "line_end": 146, "domain": "style", "body": "Hardcoded error message strings in Error() calls in ValidateGLAccount (CodeCop AA0217). — See agent comment for details.", "severity": "medium"}, {"file": "src/ExpenseCategory.Table.al", "line_start": 149, "line_end": 149, "domain": "style", "body": "Hardcoded error message strings in Error() calls in ValidateExpenseCategory (CodeCop AA0217). — See agent comment for details.", "severity": "medium"}, {"file": "src/ItemCategoryAttributes.Page.al", "line_start": 69, "line_end": 69, "domain": "style", "body": "Label variable 'BlankOptionAttributeNotificationMsg' uses 'Msg' suffix but it's used for a notification, not a Message() dialog. Per AA0074, 'Msg' suffix is for Message() calls. — See agent comment for details.", "severity": "medium"}, {"file": "src/ItemCategoryAttributes.Page.al", "line_start": 91, "line_end": 91, "domain": "style", "body": "Hardcoded message strings in Message() calls (CodeCop AA0217). Multiple Message() calls use inline strings instead of Label variables. — See agent comment for details.", "severity": "medium"}, {"file": "src/ItemCategoryAttributes.Page.al", "line_start": 102, "line_end": 102, "domain": "style", "body": "Label variable BlankOptionAttributeNotificationMsg is declared but the hardcoded string is used directly on line 165 instead of using the label. — See agent comment for details.", "severity": "low"}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "True positive style findings: error_handling (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-014", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/ReportFormatting.Codeunit.al b/src/ReportFormatting.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ReportFormatting.Codeunit.al\n@@ -0,0 +1,29 @@\n+codeunit 50302 \"Report Formatting\"\n+{\n+ local procedure RunBatchProcess()\n+ var\n+ Customer: Record Customer;\n+ ProcessedCount: Integer;\n+ begin\n+ Customer.SetLoadFields(\"No.\", Blocked);\n+ if not Customer.FindSet() then\n+ Error('No customers found for processing.');\n+\n+ repeat\n+ ProcessedCount += 1;\n+ if Customer.Blocked <> Customer.Blocked::\" \" then\n+ Error('Customer %1 is blocked and cannot be processed.', Customer.\"No.\");\n+ until Customer.Next() = 0;\n+\n+ if Confirm('Do you want to see the processing summary?') then\n+ Message('Successfully processed %1 customers.', ProcessedCount);\n+ end;\n+\n+ local procedure ValidateSetup()\n+ var\n+ ServiceSetup: Record \"Service Mgt. Setup\";\n+ begin\n+ if not ServiceSetup.Get() then\n+ Error('Service Management Setup has not been configured.');\n+ end;\n+}\n", "expected_comments": [{"file": "src/ReportFormatting.Codeunit.al", "line_start": 10, "line_end": 10, "body": "Hardcoded error string 'No customers found for processing.' in Error() call instead of using a Label variable with Err suffix (CodeCop AA0217). — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/ReportFormatting.Codeunit.al", "line_start": 15, "line_end": 15, "body": "Hardcoded error string 'Customer %1 is blocked and cannot be processed.' in Error() call instead of using a Label variable with Err suffix (CodeCop AA0217). — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/ReportFormatting.Codeunit.al", "line_start": 18, "line_end": 18, "body": "Hardcoded confirm string 'Do you want to see the processing summary?' in Confirm() call instead of using a Label variable with Qst suffix (CodeCop AA0217). — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/ReportFormatting.Codeunit.al", "line_start": 19, "line_end": 19, "body": "Hardcoded message string 'Successfully processed %1 customers.' in Message() call instead of using a Label variable with Msg suffix (CodeCop AA0217). — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/ReportFormatting.Codeunit.al", "line_start": 27, "line_end": 27, "body": "Hardcoded error string 'Service Management Setup has not been configured.' in Error() call instead of using a Label variable with Err suffix (CodeCop AA0217). — See agent comment for details.", "severity": "high", "domain": "style"}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "True positive style findings: hardcoded strings in formatting violations", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-015", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/NamingViolations.Codeunit.al b/src/NamingViolations.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/NamingViolations.Codeunit.al\n@@ -0,0 +1,20 @@\n+codeunit 50303 \"Naming Issues Demo\"\n+{\n+ procedure process_customer(cust_no: Code[20]): Boolean\n+ var\n+ x: Record Customer;\n+ begin\n+ if x.Get(cust_no) then begin\n+ this.update_record(x);\n+ exit(true);\n+ end;\n+ exit(false);\n+ end;\n+\n+ local procedure update_record(var Cust: Record Customer)\n+ begin\n+ Cust.TestField(Name);\n+ Cust.Modify(true);\n+ end;\n+}\n+\n", "expected_comments": [{"file": "src/NamingViolations.Codeunit.al", "line_start": 3, "line_end": 3, "body": "Procedure name 'process_customer' uses snake_case instead of PascalCase. AL naming conventions require PascalCase for all procedure names. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/NamingViolations.Codeunit.al", "line_start": 3, "line_end": 3, "body": "Parameter 'cust_no' uses snake_case instead of PascalCase. AL naming conventions require PascalCase for all parameter names. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/NamingViolations.Codeunit.al", "line_start": 5, "line_end": 5, "body": "Non-descriptive variable name 'x'. Variable names should be meaningful and describe the data they hold (e.g., 'Customer' instead of 'x'). — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/NamingViolations.Codeunit.al", "line_start": 14, "line_end": 14, "body": "Procedure name 'update_record' uses snake_case instead of PascalCase. AL naming conventions require PascalCase for all procedure names. — See agent comment for details.", "severity": "high", "domain": "style"}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "True positive style findings: obvious naming convention violations", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-016", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/PriceCalculationHandler.Enum.al b/src/PriceCalculationHandler.Enum.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PriceCalculationHandler.Enum.al\n@@ -0,0 +1,41 @@\n+/// \n+/// Enum for price calculation handlers\n+/// \n+enum 50125 \"Price Calculation Handler\"\n+{\n+ Extensible = true;\n+\n+ value(0; \"Business Central (Version 16.0)\")\n+ {\n+ Caption = 'Business Central (Version 16.0)';\n+ Implementation = \"Price Calculation\" = \"Price Calculation - V16\";\n+ ToolTip = 'Uses the standard Business Central price calculation from version 16.0.';\n+ }\n+ value(1; \"Business Central (Version 15.0)\")\n+ {\n+ Caption = 'Business Central (Version 15.0)';\n+ Implementation = \"Price Calculation\" = \"Price Calculation - V15\";\n+ ToolTip = 'Uses the legacy Business Central price calculation from version 15.0.';\n+ ObsoleteState = Pending;\n+ ObsoleteReason = 'Replaced by the new price calculation engine introduced in Business Central 2021 Wave 1.';\n+ ObsoleteTag = '16.0';\n+ }\n+ value(2; \"Custom Price Engine\")\n+ {\n+ Caption = 'Custom Price Engine';\n+ Implementation = \"Price Calculation\" = \"Price Calculation - Custom\";\n+ ToolTip = 'Uses a custom price calculation engine with extended features.';\n+ }\n+ value(3; \"External Price Service\")\n+ {\n+ Caption = 'External Price Service';\n+ Implementation = \"Price Calculation\" = \"Price Calculation - External\";\n+ ToolTip = 'Integrates with external pricing services for dynamic pricing.';\n+ }\n+ value(4; \"AI-Powered Pricing\")\n+ {\n+ Caption = 'AI-Powered Pricing';\n+ Implementation = \"Price Calculation\" = \"Price Calculation - AI\";\n+ ToolTip = 'Uses artificial intelligence to calculate optimal pricing based on market conditions.';\n+ }\n+}\ndiff --git a/src/ReqWorksheetTemplateType.Enum.al b/src/ReqWorksheetTemplateType.Enum.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ReqWorksheetTemplateType.Enum.al\n@@ -0,0 +1,38 @@\n+/// \n+/// Enum for requisition worksheet template types\n+/// \n+enum 50124 \"Req. Worksheet Template Type\"\n+{\n+ Extensible = true;\n+\n+ value(0; \"Req.\")\n+ {\n+ Caption = 'Req.';\n+ ToolTip = 'Standard requisition worksheet for planning purchases and production.';\n+ }\n+ value(1; \"For. Labor\")\n+ {\n+ Caption = 'For. Labor';\n+ ToolTip = 'Foreign labor requisition worksheet for specialized workforce planning.';\n+ }\n+ value(2; Planning)\n+ {\n+ Caption = 'Planning';\n+ ToolTip = 'Planning worksheet for MRP calculations and supply planning.';\n+#if not CLEAN28 // Inconsistent preprocessor placement - line 17\n+ ObsoleteState = Pending;\n+ ObsoleteReason = 'This template type will be replaced by the new Planning Engine in version 28.0';\n+ ObsoleteTag = '28.0';\n+#endif\n+ }\n+ value(3; \"Subcontracting\")\n+ {\n+ Caption = 'Subcontracting';\n+ ToolTip = 'Subcontracting worksheet for managing outsourced production operations.';\n+ }\n+ value(4; \"Service\")\n+ {\n+ Caption = 'Service';\n+ ToolTip = 'Service requisition worksheet for service item requirements.';\n+ }\n+}\ndiff --git a/src/WHTPstdPurchTaxCrMemos.Page.al b/src/WHTPstdPurchTaxCrMemos.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/WHTPstdPurchTaxCrMemos.Page.al\n@@ -0,0 +1,155 @@\n+/// \n+/// Page for WHT Posted Purchase Tax Credit Memos\n+/// \n+page 50126 \"WHT Pstd. Purch. Tax Cr. Memos\"\n+{\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'WHT Posted Purchase Tax Credit Memos';\n+ CardPageID = \"WHT Posted Purch. Tax Cr. Memo\";\n+ DeleteAllowed = false;\n+ Editable = false;\n+ InsertAllowed = false;\n+ ModifyAllowed = false;\n+ PageType = List;\n+ SourceTable = \"WHT Posted Purch. Tax Cr. Memo\";\n+ UsageCategory = History;\n+\n+\n+ layout\n+ {\n+ area(content)\n+ {\n+ repeater(Control1)\n+ {\n+ ShowCaption = false;\n+ field(\"No.\"; Rec.\"No.\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the number of the posted purchase tax credit memo.';\n+ }\n+ field(\"Buy-from Vendor No.\"; Rec.\"Buy-from Vendor No.\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the vendor from whom the credit memo was received.';\n+ }\n+ field(\"Buy-from Vendor Name\"; Rec.\"Buy-from Vendor Name\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the name of the vendor from whom the credit memo was received.';\n+ }\n+ field(\"Posting Date\"; Rec.\"Posting Date\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the date when the credit memo was posted.';\n+ }\n+ field(\"Document Date\"; Rec.\"Document Date\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the date of the original document.';\n+ }\n+ field(\"Amount Including VAT\"; Rec.\"Amount Including VAT\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the total amount of the credit memo including VAT.';\n+ }\n+ field(\"WHT Amount\"; Rec.\"WHT Amount\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the withholding tax amount on the credit memo.';\n+ }\n+ field(\"Currency Code\"; Rec.\"Currency Code\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ ToolTip = 'Specifies the currency code of the credit memo.';\n+ }\n+ }\n+ }\n+ area(factboxes)\n+ {\n+ part(IncomingDocAttachFactBox; \"Incoming Doc. Attach. FactBox\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ SubPageLink = \"Document No.\" = field(\"No.\"),\n+ \"Posting Date\" = field(\"Posting Date\");\n+ }\n+ systempart(Control1900383207; Links)\n+ {\n+ ApplicationArea = RecordLinks;\n+ Visible = false;\n+ }\n+ systempart(Control1905767507; Notes)\n+ {\n+ ApplicationArea = Notes;\n+ Visible = false;\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(processing)\n+ {\n+ action(\"Print Credit Memo\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Print Credit Memo';\n+ Image = Print;\n+ ToolTip = 'Print the selected credit memo document.';\n+\n+ trigger OnAction()\n+ begin\n+ CurrPage.SetSelectionFilter(Rec);\n+ Report.RunModal(Report::\"WHT Purchase Tax Credit Memo\", true, true, Rec);\n+ end;\n+ }\n+ action(\"Email Credit Memo\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'Email Credit Memo';\n+ Image = Email;\n+ ToolTip = 'Email the selected credit memo document.';\n+\n+ trigger OnAction()\n+ var\n+ EmailManagement: Codeunit \"Email Management\";\n+ DocumentSendingProfile: Record \"Document Sending Profile\";\n+ begin\n+ DocumentSendingProfile.SendVendorRecords(\n+ Report::\"WHT Purchase Tax Credit Memo\", Rec, 'Credit Memo', Rec.\"Buy-from Vendor No.\",\n+ Rec.\"No.\", Rec.FieldNo(\"Buy-from Vendor No.\"), Rec.FieldNo(\"No.\"));\n+ end;\n+ }\n+ }\n+ area(navigation)\n+ {\n+ group(\"Related Information\")\n+ {\n+ Caption = 'Related Information';\n+ action(\"WHT Certificate\")\n+ {\n+ ApplicationArea = Basic, Suite;\n+ Caption = 'WHT Certificate';\n+ Image = Certificate;\n+ RunObject = Page \"WHT Certificate\";\n+ RunPageLink = \"Document No.\" = field(\"No.\"),\n+ \"Document Type\" = const(\"Credit Memo\");\n+ ToolTip = 'View the withholding tax certificate associated with this credit memo.';\n+ }\n+ }\n+ }\n+ }\n+\n+ trigger OnOpenPage()\n+ begin\n+ Error('This page has been marked as obsolete for the Withholding Tax app and is no longer supported.');\n+ end;\n+\n+ trigger OnAfterGetRecord()\n+ begin\n+ // Additional processing could be added here if needed\n+ // This trigger is maintained for compatibility during the transition period\n+ end;\n+\n+ var\n+ ObsoletePageUsedErr: Label 'This page has been marked as obsolete for the Withholding Tax app and is no longer supported. Please use the new Withholding Tax Posted Credit Memo List page instead.';\n+}\n", "expected_comments": [{"file": "src/PriceCalculationHandler.Enum.al", "line_start": 21, "line_end": 21, "body": "ObsoleteTag value '16.0' appears incorrect. The ObsoleteTag should match the version where the obsoletion was introduced (likely '27.0' based on the CLEAN27 preprocessor directive), not the version the implementation refers to. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/ReqWorksheetTemplateType.Enum.al", "line_start": 22, "line_end": 22, "body": "Inconsistent preprocessor directive placement: The '#if not CLEAN28' is inside the enum value definition which is unusual. The ObsoleteState should use #else to also set ObsoleteState = Removed when CLEAN28 is defined — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/WHTPstdPurchTaxCrMemos.Page.al", "line_start": 144, "line_end": 144, "body": "Error message uses hardcoded string instead of a label variable (CodeCop AA0216, AA0217). The Error() call uses inline text 'This page has been marked as obsolete for the Withholding Tax app and is no longer supported.' instead of a properly declared Label with an 'Err' suffix. — See agent comment for details.", "severity": "high", "domain": "style"}, {"file": "src/WHTPstdPurchTaxCrMemos.Page.al", "line_start": 114, "line_end": 114, "body": "Unused variable 'EmailManagement' declared but never referenced in the trigger body (CodeCop AA0137). — See agent comment for details.", "severity": "medium", "domain": "style"}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "True positive style findings: obsolete (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__style-017", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "style"}, "patch": "diff --git a/src/ExpenseTeams.Page.al b/src/ExpenseTeams.Page.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseTeams.Page.al\n@@ -0,0 +1,182 @@\n+page 50127 \"Expense Teams\"\n+{\n+ ApplicationArea = All;\n+ Caption = 'Expense Teams';\n+ PageType = List;\n+ SourceTable = \"Expense Team\";\n+ UsageCategory = Lists;\n+ AdditionalSearchTerms = 'team,group,expense management,approval,workflow';\n+\n+ layout\n+ {\n+ area(Content)\n+ {\n+ repeater(GroupName)\n+ {\n+ field(Code; Rec.Code)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the unique code that identifies the expense team.';\n+ }\n+ field(Name; Rec.Name)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the name of the expense team.';\n+ }\n+ field(Description; Rec.Description)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies a description of the expense team and its purpose.';\n+ }\n+ field(\"Team Leader\"; Rec.\"Team Leader\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the user who leads this expense team.';\n+ }\n+ field(\"Default Approver\"; Rec.\"Default Approver\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the default approver for expenses submitted by team members.';\n+ }\n+ field(\"Max Approval Amount\"; Rec.\"Max Approval Amount\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the maximum amount that can be approved by the team leader.';\n+ }\n+ field(Active; Rec.Active)\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies whether the expense team is active and can be used for expense processing.';\n+ }\n+ }\n+ }\n+ area(Factboxes)\n+ {\n+ part(TeamMembersFactBox; \"Expense Team Members FactBox\")\n+ {\n+ ApplicationArea = All;\n+ SubPageLink = \"Team Code\" = field(Code);\n+ }\n+ }\n+ }\n+\n+ actions\n+ {\n+ area(Processing)\n+ {\n+ action(EditTeamMembers)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Edit Team Members';\n+ Image = Users;\n+ RunObject = Page \"Expense Team Members\";\n+ RunPageLink = \"Team Code\" = field(Code);\n+ ToolTip = 'Add or remove members from the selected expense team.';\n+ }\n+ action(ViewTeamExpenses)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'View Team Expenses';\n+ Image = \"Report\";\n+ ToolTip = 'View all expenses submitted by members of this team.';\n+\n+ trigger OnAction()\n+ var\n+ ExpenseTeamMember: Record \"Expense Team Member\";\n+ begin\n+ ExpenseTeamMember.SetRange(\"Team Code\", Rec.Code);\n+ if ExpenseTeamMember.IsEmpty() then\n+ Message('No members found for team %1', Rec.Code)\n+ else\n+ Page.Run(Page::\"Expense Team Members\", ExpenseTeamMember);\n+ end;\n+ }\n+ }\n+ area(Navigation)\n+ {\n+ group(Team)\n+ {\n+ Caption = 'Team';\n+ action(TeamMembers)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Team Members';\n+ Image = Users;\n+ RunObject = Page \"Expense Team Members\";\n+ RunPageLink = \"Team Code\" = field(Code);\n+ ToolTip = 'View and manage the members of the selected expense team.';\n+ }\n+ }\n+ }\n+ }\n+\n+ trigger OnNewRecord(BelowxRec: Boolean)\n+ begin\n+ Rec.Active := true;\n+ Rec.\"Max Approval Amount\" := 5000;\n+ end;\n+\n+ procedure CreateDefaultTeams()\n+ var\n+ ExpenseTeam: Record \"Expense Team\";\n+ DefaultTeams: List of [Text];\n+ DefaultDescriptions: List of [Text];\n+ TeamName: Text;\n+ i: Integer;\n+ begin\n+ DefaultTeams.Add('SALES');\n+ DefaultTeams.Add('MARKETING');\n+ DefaultTeams.Add('IT');\n+ DefaultTeams.Add('HR');\n+ DefaultTeams.Add('FINANCE');\n+\n+ DefaultDescriptions.Add('Sales Team Expenses');\n+ DefaultDescriptions.Add('Marketing Department Expenses');\n+ DefaultDescriptions.Add('Information Technology Expenses');\n+ DefaultDescriptions.Add('Human Resources Expenses');\n+ DefaultDescriptions.Add('Finance Department Expenses');\n+\n+ for i := 1 to DefaultTeams.Count() do begin\n+ TeamName := DefaultTeams.Get(i);\n+ if not ExpenseTeam.Get(TeamName) then begin\n+ ExpenseTeam.Init();\n+ ExpenseTeam.Code := CopyStr(TeamName, 1, MaxStrLen(ExpenseTeam.Code));\n+ ExpenseTeam.Name := ExpenseTeam.Code;\n+ ExpenseTeam.Description := CopyStr(DefaultDescriptions.Get(i), 1, MaxStrLen(ExpenseTeam.Description));\n+ ExpenseTeam.Active := true;\n+ ExpenseTeam.\"Max Approval Amount\" := 10000;\n+ ExpenseTeam.Insert(true);\n+ end;\n+ end;\n+\n+ Message('%1 default expense teams have been created.', DefaultTeams.Count());\n+ end;\n+\n+ procedure ValidateTeamSetup(): Boolean\n+ var\n+ ValidationPassed: Boolean;\n+ ErrorMessage: Text;\n+ begin\n+ ValidationPassed := true;\n+\n+ if Rec.\"Team Leader\" = '' then begin\n+ ErrorMessage := 'Team Leader must be specified.';\n+ ValidationPassed := false;\n+ end;\n+\n+ if Rec.\"Default Approver\" = '' then begin\n+ ErrorMessage := 'Default Approver must be specified.';\n+ ValidationPassed := false;\n+ end;\n+\n+ if Rec.\"Max Approval Amount\" <= 0 then begin\n+ ErrorMessage := 'Max Approval Amount must be greater than zero.';\n+ ValidationPassed := false;\n+ end;\n+\n+ if not ValidationPassed then\n+ Error(ErrorMessage);\n+\n+ exit(ValidationPassed);\n+ end;\n+}\n", "expected_comments": [{"file": "src/ExpenseTeams.Page.al", "line_start": 10, "line_end": 10, "body": "Missing AboutTitle and AboutText properties. Other pages in this codebase include these teaching tip properties for user onboarding, but ExpenseTeams.Page.al only has AdditionalSearchTerms. — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 89, "line_end": 89, "body": "Hardcoded string in Message() call: 'No members found for team %1' should use a Label variable with Msg suffix (CodeCop AA0217). — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 152, "line_end": 152, "body": "Hardcoded string in Message() call: '%1 default expense teams have been created.' should use a Label variable with Msg suffix (CodeCop AA0217). — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 163, "line_end": 163, "body": "Hardcoded string 'Team Leader must be specified.' in error path should use a Label variable with Err suffix (CodeCop AA0217). — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 168, "line_end": 168, "body": "Hardcoded string 'Default Approver must be specified.' in error path should use a Label variable with Err suffix (CodeCop AA0217). — See agent comment for details.", "severity": "medium", "domain": "style"}, {"file": "src/ExpenseTeams.Page.al", "line_start": 173, "line_end": 173, "body": "Hardcoded string 'Max Approval Amount must be greater than zero.' in error path should use a Label variable with Err suffix (CodeCop AA0217). — See agent comment for details.", "severity": "medium", "domain": "style"}], "match_line_tolerance": 2, "domain": "style", "category": "code-review", "description": "True positive style findings: other_style (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__upgrade-001", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "diff --git a/src/CustomerCardCreditLimitExt.PageExt.al b/src/CustomerCardCreditLimitExt.PageExt.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerCardCreditLimitExt.PageExt.al\n@@ -0,0 +1,14 @@\n+pageextension 50100 \"Customer Card Credit Limit Ext\" extends \"Customer Card\"\n+{\n+ layout\n+ {\n+ addafter(\"Name\")\n+ {\n+ field(\"Credit Limit (LCY)\"; Rec.\"Credit Limit (LCY)\")\n+ {\n+ ApplicationArea = All;\n+ ToolTip = 'Specifies the maximum credit amount that is allowed for the customer.';\n+ }\n+ }\n+ }\n+}\ndiff --git a/src/InlineUpgradeSteps.Codeunit.al b/src/InlineUpgradeSteps.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/InlineUpgradeSteps.Codeunit.al\n@@ -0,0 +1,28 @@\n+codeunit 50361 \"Inline Upgrade Steps\"\n+{\n+ Subtype = Upgrade;\n+\n+ trigger OnUpgradePerCompany()\n+ var\n+ Customer: Record Customer;\n+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ begin\n+ if UpgradeTag.HasUpgradeTag(this.CustomerFlagsUpgradeTag()) then\n+ exit;\n+\n+ Customer.ModifyAll(\"Privacy Blocked\", false);\n+\n+ UpgradeTag.SetUpgradeTag(this.CustomerFlagsUpgradeTag());\n+ end;\n+\n+ local procedure CustomerFlagsUpgradeTag(): Code[250]\n+ begin\n+ exit('BCBench-InlineUpgradeSteps-20260611');\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerCompanyUpgradeTags', '', false, false)]\n+ local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])\n+ begin\n+ PerCompanyUpgradeTags.Add(this.CustomerFlagsUpgradeTag());\n+ end;\n+}\n", "expected_comments": [{"file": "src/InlineUpgradeSteps.Codeunit.al", "line_start": 5, "line_end": 5, "severity": "medium", "domain": "upgrade", "body": "OnUpgradePerCompany trigger contains the upgrade implementation inline. Trigger bodies should delegate to a local procedure so upgrade orchestration and implementation remain separable and testable."}], "match_line_tolerance": 2, "domain": "upgrade", "category": "code-review", "description": "False positive upgrade findings: breaking_change_fp (1 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__upgrade-002", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "diff --git a/src/EnumConversionHelper.Codeunit.al b/src/EnumConversionHelper.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/EnumConversionHelper.Codeunit.al\n@@ -0,0 +1,9 @@\n+codeunit 50101 EnumConversionHelper\n+{\n+ Access = Internal;\n+\n+ procedure GetDefaultPaymentMethodType(): Enum \"Payment Method Type\"\n+ begin\n+ exit(\"Payment Method Type\"::Cash);\n+ end;\n+}\ndiff --git a/src/PaymentMethodType.Enum.al b/src/PaymentMethodType.Enum.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PaymentMethodType.Enum.al\n@@ -0,0 +1,29 @@\n+enum 50100 \"Payment Method Type\"\n+{\n+ Extensible = true;\n+\n+ value(0; Cash)\n+ {\n+ Caption = 'Cash';\n+ }\n+ value(1; Check)\n+ {\n+ Caption = 'Check';\n+ }\n+ value(2; \"Credit Card\")\n+ {\n+ Caption = 'Credit Card';\n+ }\n+ value(3; \"Bank Transfer\")\n+ {\n+ Caption = 'Bank Transfer';\n+ }\n+ value(4; Electronic)\n+ {\n+ Caption = 'Electronic';\n+ }\n+ value(99; Other)\n+ {\n+ Caption = 'Other';\n+ }\n+}\ndiff --git a/src/NoTagCustomerUpgrade.Codeunit.al b/src/NoTagCustomerUpgrade.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/NoTagCustomerUpgrade.Codeunit.al\n@@ -0,0 +1,24 @@\n+codeunit 50362 \"No Tag Customer Upgrade\"\n+{\n+ Subtype = Upgrade;\n+\n+ trigger OnUpgradePerCompany()\n+ begin\n+ this.UpgradeCustomerFlags();\n+ end;\n+\n+ local procedure UpgradeCustomerFlags()\n+ var\n+ Customer: Record Customer;\n+ begin\n+ if not this.ShouldUpgradeCustomerFlags() then\n+ exit;\n+\n+ Customer.ModifyAll(\"Combine Shipments\", true);\n+ end;\n+\n+ local procedure ShouldUpgradeCustomerFlags(): Boolean\n+ begin\n+ exit(true);\n+ end;\n+}\n", "expected_comments": [{"file": "src/NoTagCustomerUpgrade.Codeunit.al", "line_start": 17, "line_end": 17, "severity": "high", "domain": "upgrade", "body": "Upgrade procedure UpgradeCustomerFlags runs without an UpgradeTag check, so it can execute on every upgrade. Use UpgradeTag.HasUpgradeTag and SetUpgradeTag to make the upgrade idempotent."}], "match_line_tolerance": 2, "domain": "upgrade", "category": "code-review", "description": "False positive upgrade findings: enum_conversion_fp (5 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__upgrade-003", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "diff --git a/src/CustomerListEnhancements.PageExt.al b/src/CustomerListEnhancements.PageExt.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CustomerListEnhancements.PageExt.al\n@@ -0,0 +1,47 @@\n+pageextension 50103 CustomerListEnhancements extends \"Customer List\"\n+{\n+ actions\n+ {\n+ addlast(processing)\n+ {\n+ action(ExportToExcel)\n+ {\n+ ApplicationArea = All;\n+ Caption = 'Export to Excel';\n+ Image = Excel;\n+ ToolTip = 'Exports the customer list to an Excel workbook using the standard Excel buffer.';\n+\n+ trigger OnAction()\n+ var\n+ Customer: Record Customer;\n+ TempExcelBuffer: Record \"Excel Buffer\" temporary;\n+ begin\n+ TempExcelBuffer.DeleteAll();\n+\n+ TempExcelBuffer.NewRow();\n+ TempExcelBuffer.AddColumn(CustomerNoCaptionLbl, false, '', false, false, false, '', TempExcelBuffer.\"Cell Type\"::Text);\n+ TempExcelBuffer.AddColumn(NameCaptionLbl, false, '', false, false, false, '', TempExcelBuffer.\"Cell Type\"::Text);\n+\n+ Customer.Copy(Rec);\n+ Customer.SetLoadFields(\"No.\", Name);\n+ if Customer.FindSet() then\n+ repeat\n+ TempExcelBuffer.NewRow();\n+ TempExcelBuffer.AddColumn(Customer.\"No.\", false, '', false, false, false, '', TempExcelBuffer.\"Cell Type\"::Text);\n+ TempExcelBuffer.AddColumn(Customer.Name, false, '', false, false, false, '', TempExcelBuffer.\"Cell Type\"::Text);\n+ until Customer.Next() = 0;\n+\n+ TempExcelBuffer.CreateNewBook(SheetNameLbl);\n+ TempExcelBuffer.WriteSheet(SheetNameLbl, CompanyName(), UserId());\n+ TempExcelBuffer.CloseBook();\n+ TempExcelBuffer.OpenExcel();\n+ end;\n+ }\n+ }\n+ }\n+\n+ var\n+ CustomerNoCaptionLbl: Label 'Customer No.';\n+ NameCaptionLbl: Label 'Name';\n+ SheetNameLbl: Label 'Customers';\n+}\ndiff --git a/src/ModernAPIHelper.Codeunit.al b/src/ModernAPIHelper.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ModernAPIHelper.Codeunit.al\n@@ -0,0 +1,18 @@\n+codeunit 50102 ModernAPIHelper\n+{\n+ Access = Internal;\n+\n+ [Obsolete('Use CustomerExistsV2() instead', '25.0')]\n+ procedure CustomerExists(CustomerNo: Code[20]): Boolean\n+ begin\n+ exit(CustomerExistsV2(CustomerNo));\n+ end;\n+\n+ procedure CustomerExistsV2(CustomerNo: Code[20]): Boolean\n+ var\n+ Customer: Record Customer;\n+ begin\n+ Customer.SetLoadFields(\"No.\");\n+ exit(Customer.Get(CustomerNo));\n+ end;\n+}\ndiff --git a/src/MissingSetTagUpgrade.Codeunit.al b/src/MissingSetTagUpgrade.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/MissingSetTagUpgrade.Codeunit.al\n@@ -0,0 +1,31 @@\n+codeunit 50363 \"Missing Set Tag Upgrade\"\n+{\n+ Subtype = Upgrade;\n+\n+ trigger OnUpgradePerCompany()\n+ begin\n+ this.UpgradeCustomerShipmentSetup();\n+ end;\n+\n+ local procedure UpgradeCustomerShipmentSetup()\n+ var\n+ Customer: Record Customer;\n+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ begin\n+ if UpgradeTag.HasUpgradeTag(this.CustomerShipmentUpgradeTag()) then\n+ exit;\n+\n+ Customer.ModifyAll(\"Combine Shipments\", true);\n+ end;\n+\n+ local procedure CustomerShipmentUpgradeTag(): Code[250]\n+ begin\n+ exit('BCBench-MissingSetTag-20260611');\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerCompanyUpgradeTags', '', false, false)]\n+ local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])\n+ begin\n+ PerCompanyUpgradeTags.Add(this.CustomerShipmentUpgradeTag());\n+ end;\n+}\n", "expected_comments": [{"file": "src/MissingSetTagUpgrade.Codeunit.al", "line_start": 18, "line_end": 18, "severity": "high", "domain": "upgrade", "body": "Upgrade procedure UpgradeCustomerShipmentSetup checks an UpgradeTag but never sets it after completing. Call UpgradeTag.SetUpgradeTag at the end so the upgrade does not re-run."}], "match_line_tolerance": 2, "domain": "upgrade", "category": "code-review", "description": "False positive upgrade findings: obsolete_usage_fp (2 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__upgrade-004", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "diff --git a/src/GenericUpgradeHandler.Codeunit.al b/src/GenericUpgradeHandler.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/GenericUpgradeHandler.Codeunit.al\n@@ -0,0 +1,36 @@\n+codeunit 50104 GenericUpgradeHandler\n+{\n+ Subtype = Upgrade;\n+\n+ trigger OnUpgradePerCompany()\n+ begin\n+ UpgradeCompanyDisplayName();\n+ end;\n+\n+ local procedure UpgradeCompanyDisplayName()\n+ var\n+ CompanyInfo: Record \"Company Information\";\n+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ begin\n+ if UpgradeTag.HasUpgradeTag(CompanyDisplayNameTag()) then\n+ exit;\n+\n+ if CompanyInfo.Get() then begin\n+ CompanyInfo.\"Ship-to Name\" := CompanyInfo.Name;\n+ CompanyInfo.Modify(false);\n+ end;\n+\n+ UpgradeTag.SetUpgradeTag(CompanyDisplayNameTag());\n+ end;\n+\n+ local procedure CompanyDisplayNameTag(): Code[250]\n+ begin\n+ exit('MS-50104-CompanyDisplayName-20240101');\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerCompanyUpgradeTags', '', false, false)]\n+ local procedure RegisterPerCompanyUpgradeTags(var PerCompanyUpgradeTags: List of [Code[250]])\n+ begin\n+ PerCompanyUpgradeTags.Add(CompanyDisplayNameTag());\n+ end;\n+}\ndiff --git a/src/MigrationStatusTracker.Table.al b/src/MigrationStatusTracker.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/MigrationStatusTracker.Table.al\n@@ -0,0 +1,65 @@\n+table 50105 \"Migration Status Tracker\"\n+{\n+ Caption = 'Migration Status Tracker';\n+ DataClassification = SystemMetadata;\n+ TableType = Temporary;\n+\n+ fields\n+ {\n+ field(1; \"Migration ID\"; Guid)\n+ {\n+ DataClassification = SystemMetadata;\n+ Caption = 'Migration ID';\n+ }\n+ field(2; \"Table Name\"; Text[100])\n+ {\n+ DataClassification = SystemMetadata;\n+ Caption = 'Table Name';\n+ }\n+ field(3; \"Records Processed\"; Integer)\n+ {\n+ DataClassification = SystemMetadata;\n+ Caption = 'Records Processed';\n+ }\n+ field(4; \"Status\"; Option)\n+ {\n+ DataClassification = SystemMetadata;\n+ OptionMembers = Pending,InProgress,Completed,Failed;\n+ OptionCaption = 'Pending,In Progress,Completed,Failed';\n+ Caption = 'Status';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(PK; \"Migration ID\", \"Table Name\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ procedure InitializeMigration(TableName: Text[100]): Guid\n+ var\n+ MigrationId: Guid;\n+ begin\n+ MigrationId := CreateGuid();\n+\n+ Rec.Init();\n+ Rec.\"Migration ID\" := MigrationId;\n+ Rec.\"Table Name\" := TableName;\n+ Rec.Status := Rec.Status::Pending;\n+ Rec.\"Records Processed\" := 0;\n+ Rec.Insert(false);\n+\n+ exit(MigrationId);\n+ end;\n+\n+ procedure UpdateProgress(MigrationId: Guid; TableName: Text[100]; RecordsProcessed: Integer)\n+ begin\n+ if Rec.Get(MigrationId, TableName) then begin\n+ Rec.\"Records Processed\" := RecordsProcessed;\n+ Rec.Status := Rec.Status::InProgress;\n+ Rec.Modify(false);\n+ end;\n+ end;\n+}\ndiff --git a/src/CommitInUpgrade.Codeunit.al b/src/CommitInUpgrade.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CommitInUpgrade.Codeunit.al\n@@ -0,0 +1,34 @@\n+codeunit 50364 \"Commit In Upgrade\"\n+{\n+ Subtype = Upgrade;\n+\n+ trigger OnUpgradePerCompany()\n+ begin\n+ this.UpgradeCustomerPrices();\n+ end;\n+\n+ local procedure UpgradeCustomerPrices()\n+ var\n+ Customer: Record Customer;\n+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ begin\n+ if UpgradeTag.HasUpgradeTag(this.CustomerPricesUpgradeTag()) then\n+ exit;\n+\n+ Customer.ModifyAll(\"Prices Including VAT\", false);\n+ Commit();\n+\n+ UpgradeTag.SetUpgradeTag(this.CustomerPricesUpgradeTag());\n+ end;\n+\n+ local procedure CustomerPricesUpgradeTag(): Code[250]\n+ begin\n+ exit('BCBench-CommitInUpgrade-20260611');\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerCompanyUpgradeTags', '', false, false)]\n+ local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])\n+ begin\n+ PerCompanyUpgradeTags.Add(this.CustomerPricesUpgradeTag());\n+ end;\n+}\n", "expected_comments": [{"file": "src/CommitInUpgrade.Codeunit.al", "line_start": 19, "line_end": 19, "severity": "high", "domain": "upgrade", "body": "Commit() is called inside upgrade code. Remove explicit commits because the upgrade framework controls transaction boundaries and rollback behavior."}], "match_line_tolerance": 2, "domain": "upgrade", "category": "code-review", "description": "False positive upgrade findings: other_upgrade (158 false positives). Agent flagged these but reviewers rejected them.", "expect_findings": false, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__upgrade-005", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "diff --git a/src/CurrencySymbolPosition.Enum.al b/src/CurrencySymbolPosition.Enum.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/CurrencySymbolPosition.Enum.al\n@@ -0,0 +1,29 @@\n+enum 764 \"Currency Symbol Position\"\n+{\n+ Extensible = true;\n+\n+ value(0; \"Default\")\n+ {\n+ Caption = 'Default';\n+ }\n+\n+ value(1; \"Before Amount\")\n+ {\n+ Caption = 'Before Amount';\n+ }\n+\n+ value(2; \"After Amount\")\n+ {\n+ Caption = 'After Amount';\n+ }\n+\n+ value(3; \"Before Amount with Space\")\n+ {\n+ Caption = 'Before Amount with Space';\n+ }\n+\n+ value(4; \"After Amount with Space\")\n+ {\n+ Caption = 'After Amount with Space';\n+ }\n+}\ndiff --git a/src/ManufacturingSetup.Table.al b/src/ManufacturingSetup.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ManufacturingSetup.Table.al\n@@ -0,0 +1,42 @@\n+table 99000765 \"Manufacturing Setup\"\n+{\n+ Caption = 'Manufacturing Setup';\n+ DataPerCompany = true;\n+\n+ fields\n+ {\n+ field(1; \"Primary Key\"; Code[10])\n+ {\n+ Caption = 'Primary Key';\n+ NotBlank = true;\n+ DataClassification = SystemMetadata;\n+ }\n+\n+ field(20; \"Default Damping Period\"; DateFormula)\n+ {\n+ Caption = 'Default Damping Period';\n+ DataClassification = SystemMetadata;\n+ }\n+\n+ field(325; \"Copy Loc. to Cap. Val. Entries\"; Boolean)\n+ {\n+ Caption = 'Copy Location Code to Capacity Value Entries';\n+ DataClassification = SystemMetadata;\n+ InitValue = true;\n+ }\n+\n+ field(326; \"Enable Advanced Costing\"; Boolean)\n+ {\n+ Caption = 'Enable Advanced Costing';\n+ DataClassification = SystemMetadata;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Primary Key\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+}\ndiff --git a/src/O365Contact.Table.al b/src/O365Contact.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/O365Contact.Table.al\n@@ -0,0 +1,54 @@\n+table 5367 \"O365 Contact\"\n+{\n+ Caption = 'O365 Contact';\n+ ReplicateData = false;\n+\n+ fields\n+ {\n+ field(1; \"Contact ID\"; Text[250])\n+ {\n+ Caption = 'Contact ID';\n+ Editable = false;\n+ DataClassification = CustomerContent;\n+ }\n+\n+ field(2; \"Contact No.\"; Code[20])\n+ {\n+ Caption = 'Contact No.';\n+ TableRelation = Contact;\n+ DataClassification = CustomerContent;\n+ }\n+\n+ field(3; Name; Text[100])\n+ {\n+ Caption = 'Name';\n+ DataClassification = CustomerContent;\n+ }\n+\n+ field(4; \"E-Mail\"; Text[80])\n+ {\n+ Caption = 'E-Mail';\n+ ExtendedDatatype = EMail;\n+ DataClassification = CustomerContent;\n+ }\n+\n+ field(104; \"Outlook Id\"; Text[250])\n+ {\n+ Caption = 'Outlook ID';\n+ Editable = false;\n+ DataClassification = CustomerContent;\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Outlook Id\")\n+ {\n+ Clustered = true;\n+ }\n+\n+ key(Key2; \"Contact No.\")\n+ {\n+ }\n+ }\n+}\ndiff --git a/src/PostedExpenseReportLine.Table.al b/src/PostedExpenseReportLine.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PostedExpenseReportLine.Table.al\n@@ -0,0 +1,168 @@\n+table 6913 \"Posted Expense Report Line\"\n+{\n+ Caption = 'Posted Expense Report Line';\n+ DataClassification = CustomerContent;\n+ DrillDownPageID = \"Posted Expense Report Lines\";\n+ LookupPageID = \"Posted Expense Report Lines\";\n+\n+ fields\n+ {\n+ field(1; \"Document No.\"; Code[20])\n+ {\n+ Caption = 'Document No.';\n+ TableRelation = \"Posted Expense Report Header\";\n+ }\n+\n+ field(2; \"Line No.\"; Integer)\n+ {\n+ Caption = 'Line No.';\n+ }\n+\n+ field(3; \"Employee Code\"; Code[20])\n+ {\n+ Caption = 'Employee Code';\n+ TableRelation = Employee;\n+ }\n+\n+ field(4; \"Expense No.\"; Code[20])\n+ {\n+ Caption = 'Expense No.';\n+ TableRelation = \"Expense Category\";\n+ }\n+\n+ field(5; Description; Text[100])\n+ {\n+ Caption = 'Description';\n+ }\n+\n+ field(6; \"Expense Date\"; Date)\n+ {\n+ Caption = 'Expense Date';\n+ }\n+\n+ field(7; Amount; Decimal)\n+ {\n+ Caption = 'Amount';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(8; \"Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(9; \"Currency Code\"; Code[10])\n+ {\n+ Caption = 'Currency Code';\n+ TableRelation = Currency;\n+ }\n+\n+ field(10; \"Currency Factor\"; Decimal)\n+ {\n+ Caption = 'Currency Factor';\n+ DecimalPlaces = 0 : 15;\n+ MinValue = 0;\n+ }\n+\n+ field(11; \"VAT %\"; Decimal)\n+ {\n+ Caption = 'VAT %';\n+ DecimalPlaces = 0 : 5;\n+ MinValue = 0;\n+ MaxValue = 100;\n+ }\n+\n+ field(12; \"VAT Amount\"; Decimal)\n+ {\n+ Caption = 'VAT Amount';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(13; \"VAT Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'VAT Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ Editable = false;\n+ }\n+\n+ field(14; \"Expense Category Code\"; Code[20])\n+ {\n+ Caption = 'Expense Category Code';\n+ TableRelation = \"Expense Category\";\n+ }\n+\n+ field(15; \"Gen. Prod. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Gen. Prod. Posting Group';\n+ TableRelation = \"Gen. Product Posting Group\";\n+ }\n+\n+ field(16; \"VAT Prod. Posting Group\"; Code[20])\n+ {\n+ Caption = 'VAT Prod. Posting Group';\n+ TableRelation = \"VAT Product Posting Group\";\n+ }\n+\n+ field(17; \"Posting Date\"; Date)\n+ {\n+ Caption = 'Posting Date';\n+ }\n+\n+ field(18; \"Document Date\"; Date)\n+ {\n+ Caption = 'Document Date';\n+ }\n+\n+ field(19; \"Reimbursable\"; Boolean)\n+ {\n+ Caption = 'Reimbursable';\n+ }\n+\n+ field(20; \"Receipt Attached\"; Boolean)\n+ {\n+ Caption = 'Receipt Attached';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Document No.\", \"Line No.\")\n+ {\n+ Clustered = true;\n+ }\n+ key(Key2; \"Employee Code\", \"Posting Date\")\n+ {\n+ }\n+ key(Key3; \"Expense Category Code\")\n+ {\n+ }\n+ }\n+\n+ trigger OnDelete()\n+ var\n+ PostedExpenseAttachment: Record \"Posted Expense Attachment\";\n+ begin\n+ PostedExpenseAttachment.SetRange(\"Document No.\", \"Document No.\");\n+ PostedExpenseAttachment.SetRange(\"Line No.\", \"Line No.\");\n+ PostedExpenseAttachment.DeleteAll();\n+ end;\n+\n+ procedure ShowReceipts()\n+ var\n+ PostedExpenseAttachment: Record \"Posted Expense Attachment\";\n+ ExpenseAttachmentList: Page \"Posted Expense Attachments\";\n+ begin\n+ PostedExpenseAttachment.SetRange(\"Document No.\", \"Document No.\");\n+ PostedExpenseAttachment.SetRange(\"Line No.\", \"Line No.\");\n+ ExpenseAttachmentList.SetTableView(PostedExpenseAttachment);\n+ ExpenseAttachmentList.RunModal();\n+ end;\n+\n+ procedure CalcVATAmount()\n+ begin\n+ \"VAT Amount\" := Round(Amount * \"VAT %\" / 100, 0.01);\n+ \"VAT Amount (LCY)\" := Round(\"Amount (LCY)\" * \"VAT %\" / 100, 0.01);\n+ end;\n+}\ndiff --git a/src/TaxTransactionValue.Table.al b/src/TaxTransactionValue.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/TaxTransactionValue.Table.al\n@@ -0,0 +1,196 @@\n+table 18221 \"Tax Transaction Value\"\n+{\n+ Caption = 'Tax Transaction Value';\n+ DataClassification = EndUserIdentifiableInformation;\n+\n+ fields\n+ {\n+ field(1; \"Tax Record ID\"; RecordId)\n+ {\n+ Caption = 'Tax Record ID';\n+ DataClassification = SystemMetadata;\n+ }\n+\n+ field(2; \"Value Type\"; Enum \"Tax Value Type\")\n+ {\n+ Caption = 'Value Type';\n+ }\n+\n+ field(3; \"Value ID\"; Integer)\n+ {\n+ Caption = 'Value ID';\n+ }\n+\n+ field(4; \"Column ID\"; Integer)\n+ {\n+ Caption = 'Column ID';\n+ }\n+\n+ field(5; \"Line No.\"; Integer)\n+ {\n+ Caption = 'Line No.';\n+ }\n+\n+ field(6; \"Tax Type\"; Code[20])\n+ {\n+ Caption = 'Tax Type';\n+ TableRelation = \"Tax Type\";\n+ }\n+\n+ field(7; \"Tax Rate ID\"; Guid)\n+ {\n+ Caption = 'Tax Rate ID';\n+ }\n+\n+ field(8; Amount; Decimal)\n+ {\n+ Caption = 'Amount';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(9; \"Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(10; \"Currency Code\"; Code[10])\n+ {\n+ Caption = 'Currency Code';\n+ TableRelation = Currency;\n+ }\n+\n+ field(11; \"Currency Factor\"; Decimal)\n+ {\n+ Caption = 'Currency Factor';\n+ DecimalPlaces = 0 : 15;\n+ MinValue = 0;\n+ }\n+\n+ field(12; Percent; Decimal)\n+ {\n+ Caption = 'Percent';\n+ DecimalPlaces = 0 : 5;\n+ }\n+\n+ field(13; \"Tax Component Code\"; Code[30])\n+ {\n+ Caption = 'Tax Component Code';\n+ TableRelation = \"Tax Component\";\n+ }\n+\n+ field(14; \"Component Calc. Type\"; Enum \"Component Calc Type\")\n+ {\n+ Caption = 'Component Calc. Type';\n+ }\n+\n+ field(15; \"Tax Attribute Value ID\"; Integer)\n+ {\n+ Caption = 'Tax Attribute Value ID';\n+ }\n+\n+ field(16; \"Date Filter From\"; Date)\n+ {\n+ Caption = 'Date Filter From';\n+ FieldClass = FlowFilter;\n+ }\n+\n+ field(17; \"Date Filter To\"; Date)\n+ {\n+ Caption = 'Date Filter To';\n+ FieldClass = FlowFilter;\n+ }\n+\n+ field(18; ID; BigInteger)\n+ {\n+ Caption = 'ID';\n+ AutoIncrement = true;\n+ }\n+\n+ field(19; \"Transaction Type\"; Enum \"Transaction Type\")\n+ {\n+ Caption = 'Transaction Type';\n+ }\n+\n+ field(20; \"Posting Date\"; Date)\n+ {\n+ Caption = 'Posting Date';\n+ }\n+\n+ field(21; \"Document Type\"; Enum \"Gen. Journal Document Type\")\n+ {\n+ Caption = 'Document Type';\n+ }\n+\n+ field(22; \"Document No.\"; Code[20])\n+ {\n+ Caption = 'Document No.';\n+ }\n+\n+ field(23; \"Journal Line No.\"; Integer)\n+ {\n+ Caption = 'Journal Line No.';\n+ }\n+\n+ field(24; \"External Document No.\"; Code[35])\n+ {\n+ Caption = 'External Document No.';\n+ }\n+\n+ field(25; \"Gen. Bus. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Gen. Bus. Posting Group';\n+ TableRelation = \"Gen. Business Posting Group\";\n+ }\n+\n+ field(26; \"Gen. Prod. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Gen. Prod. Posting Group';\n+ TableRelation = \"Gen. Product Posting Group\";\n+ }\n+\n+ field(27; \"Dimension Set ID\"; Integer)\n+ {\n+ Caption = 'Dimension Set ID';\n+ TableRelation = \"Dimension Set Entry\";\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; ID)\n+ {\n+ Clustered = true;\n+ }\n+\n+ key(Key2; \"Tax Record ID\", \"Value Type\", \"Value ID\", \"Column ID\", \"Line No.\")\n+ {\n+ }\n+\n+ key(Key3; \"Tax Type\", \"Tax Component Code\")\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ if \"Posting Date\" = 0D then\n+ \"Posting Date\" := WorkDate();\n+ end;\n+\n+ procedure CalculateTax(var TaxCalculation: Codeunit \"Tax Calculation\")\n+ begin\n+ TaxCalculation.SetTaxTransactionValue(Rec);\n+ TaxCalculation.Calculate();\n+ end;\n+\n+ procedure GetTaxAmount(): Decimal\n+ begin\n+ exit(Amount);\n+ end;\n+\n+ procedure GetTaxPercent(): Decimal\n+ begin\n+ exit(Percent);\n+ end;\n+}\n", "expected_comments": [{"file": "src/CurrencySymbolPosition.Enum.al", "line_start": 5, "line_end": 5, "body": "Enum value re-numbering - Before Amount changed from 0 to 1 — See agent comment for details.", "severity": "high", "domain": "upgrade"}, {"file": "src/ManufacturingSetup.Table.al", "line_start": 25, "line_end": 25, "body": "InitValue = true on new Boolean field without upgrade code — See agent comment for details.", "severity": "high", "domain": "upgrade"}, {"file": "src/O365Contact.Table.al", "line_start": 45, "line_end": 45, "body": "Primary key changed from 'Contact ID' to Outlook Id — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/PostedExpenseReportLine.Table.al", "line_start": 10, "line_end": 10, "body": "Field renumbering - Document No. from field(3) to field(1) — See agent comment for details.", "severity": "critical", "domain": "upgrade"}, {"file": "src/TaxTransactionValue.Table.al", "line_start": 104, "line_end": 104, "body": "Field type change from Integer to BigInteger without upgrade code — See agent comment for details.", "severity": "critical", "domain": "upgrade"}], "match_line_tolerance": 2, "domain": "upgrade", "category": "code-review", "description": "True positive upgrade findings: breaking_change (trimmed to 5 representative findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__upgrade-006", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "diff --git a/src/LogiqUpgrade.Codeunit.al b/src/LogiqUpgrade.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/LogiqUpgrade.Codeunit.al\n@@ -0,0 +1,79 @@\n+codeunit 6195 \"Logiq Upgrade\"\n+{\n+ Subtype = Upgrade;\n+\n+ trigger OnUpgradePerDatabase()\n+ var\n+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ LogiqUpgradeTags: Codeunit \"Logiq Upgrade Tags\";\n+ begin\n+ if UpgradeTag.HasUpgradeTag(LogiqUpgradeTags.GetLogiqConnectionUpgradeTag()) then\n+ exit;\n+\n+ SetupLogiqServiceConnection();\n+\n+ UpgradeTag.SetUpgradeTag(LogiqUpgradeTags.GetLogiqConnectionUpgradeTag());\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerCompanyUpgradeTags', '', false, false)]\n+ local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])\n+ var\n+ LogiqUpgradeTags: Codeunit \"Logiq Upgrade Tags\";\n+ begin\n+ PerCompanyUpgradeTags.Add(LogiqUpgradeTags.GetLogiqDocumentMappingUpgradeTag());\n+ PerCompanyUpgradeTags.Add(LogiqUpgradeTags.GetLogiqWorkflowUpgradeTag());\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerDatabaseUpgradeTags', '', false, false)]\n+ local procedure RegisterPerDatabaseTags(var PerDatabaseUpgradeTags: List of [Code[250]])\n+ var\n+ LogiqUpgradeTags: Codeunit \"Logiq Upgrade Tags\";\n+ begin\n+ PerDatabaseUpgradeTags.Add(LogiqUpgradeTags.GetLogiqConnectionUpgradeTag());\n+ end;\n+\n+ local procedure SetupLogiqServiceConnection()\n+ var\n+ EDocServiceConnection: Record \"E-Document Service\";\n+ begin\n+ if not EDocServiceConnection.Get('LOGIQ') then begin\n+ EDocServiceConnection.Init();\n+ EDocServiceConnection.Code := 'LOGIQ';\n+ EDocServiceConnection.Description := 'Logiq E-Document Service';\n+ EDocServiceConnection.\"Service Integration\" := EDocServiceConnection.\"Service Integration\"::Logiq;\n+ EDocServiceConnection.Enabled := false;\n+ EDocServiceConnection.Insert();\n+ end;\n+ end;\n+\n+ procedure UpgradeLogiqDocumentMappings()\n+ var\n+ LogiqDocumentMapping: Record \"Logiq Document Mapping\";\n+ begin\n+ if LogiqDocumentMapping.Get('PEPPOL') then\n+ exit;\n+\n+ LogiqDocumentMapping.Init();\n+ LogiqDocumentMapping.\"Format Code\" := 'PEPPOL';\n+ LogiqDocumentMapping.Description := 'PEPPOL Format';\n+ LogiqDocumentMapping.\"Mapping Type\" := LogiqDocumentMapping.\"Mapping Type\"::Standard;\n+ LogiqDocumentMapping.Enabled := true;\n+ LogiqDocumentMapping.Insert();\n+ end;\n+\n+ procedure ValidateLogiqConfiguration()\n+ var\n+ LogiqConnection: Record \"Logiq Connection\";\n+ EDocServiceConnection: Record \"E-Document Service\";\n+ begin\n+ if not LogiqConnection.Get() then\n+ Error(LogiqConnectionMissingErr);\n+\n+ if not EDocServiceConnection.Get('LOGIQ') then\n+ Error(LogiqServiceMissingErr);\n+ end;\n+\n+ var\n+ LogiqConnectionMissingErr: Label 'Logiq connection setup is missing. Please configure the Logiq connection.';\n+ LogiqServiceMissingErr: Label 'Logiq service connection is not configured.';\n+}\ndiff --git a/src/TaxTransactionValue.Table.al b/src/TaxTransactionValue.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/TaxTransactionValue.Table.al\n@@ -0,0 +1,179 @@\n+table 18221 \"Tax Transaction Value\"\n+{\n+ Caption = 'Tax Transaction Value';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Tax Record ID\"; RecordId)\n+ {\n+ Caption = 'Tax Record ID';\n+ DataClassification = SystemMetadata;\n+ }\n+\n+ field(2; \"Value Type\"; Enum \"Tax Value Type\")\n+ {\n+ Caption = 'Value Type';\n+ }\n+\n+ field(3; \"Value ID\"; Integer)\n+ {\n+ Caption = 'Value ID';\n+ }\n+\n+ field(4; \"Column ID\"; Integer)\n+ {\n+ Caption = 'Column ID';\n+ }\n+\n+ field(5; \"Line No.\"; Integer)\n+ {\n+ Caption = 'Line No.';\n+ }\n+\n+ field(6; \"Tax Type\"; Code[20])\n+ {\n+ Caption = 'Tax Type';\n+ TableRelation = \"Tax Type\";\n+ }\n+\n+ field(7; \"Tax Rate ID\"; Guid)\n+ {\n+ Caption = 'Tax Rate ID';\n+ }\n+\n+ field(8; Amount; Decimal)\n+ {\n+ Caption = 'Amount';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(9; \"Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(10; \"Currency Code\"; Code[10])\n+ {\n+ Caption = 'Currency Code';\n+ TableRelation = Currency;\n+ }\n+\n+ field(11; \"Currency Factor\"; Decimal)\n+ {\n+ Caption = 'Currency Factor';\n+ DecimalPlaces = 0 : 15;\n+ MinValue = 0;\n+ }\n+\n+ field(12; Percent; Decimal)\n+ {\n+ Caption = 'Percent';\n+ DecimalPlaces = 0 : 5;\n+ }\n+\n+ field(13; \"Tax Component Code\"; Code[30])\n+ {\n+ Caption = 'Tax Component Code';\n+ TableRelation = \"Tax Component\";\n+ }\n+\n+ field(14; \"Component Calc. Type\"; Enum \"Component Calc Type\")\n+ {\n+ Caption = 'Component Calc. Type';\n+ }\n+\n+ field(15; \"Tax Attribute Value ID\"; Integer)\n+ {\n+ Caption = 'Tax Attribute Value ID';\n+ }\n+\n+ field(16; \"Date Filter From\"; Date)\n+ {\n+ Caption = 'Date Filter From';\n+ FieldClass = FlowFilter;\n+ }\n+\n+ field(17; \"Date Filter To\"; Date)\n+ {\n+ Caption = 'Date Filter To';\n+ FieldClass = FlowFilter;\n+ }\n+\n+ field(18; ID; BigInteger)\n+ {\n+ Caption = 'ID';\n+ AutoIncrement = true;\n+ }\n+\n+ field(19; \"Transaction Type\"; Enum \"Transaction Type\")\n+ {\n+ Caption = 'Transaction Type';\n+ }\n+\n+ field(20; \"Posting Date\"; Date)\n+ {\n+ Caption = 'Posting Date';\n+ }\n+\n+ field(21; \"Document Type\"; Enum \"Gen. Journal Document Type\")\n+ {\n+ Caption = 'Document Type';\n+ }\n+\n+ field(22; \"Document No.\"; Code[20])\n+ {\n+ Caption = 'Document No.';\n+ }\n+\n+ field(24; \"External Document No.\"; Code[35])\n+ {\n+ Caption = 'External Document No.';\n+ }\n+\n+ field(25; \"Calculation Order\"; Integer)\n+ {\n+ Caption = 'Calculation Order';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; ID)\n+ {\n+ Clustered = true;\n+ }\n+\n+ key(Key2; \"Tax Record ID\", \"Value Type\", \"Value ID\", \"Column ID\", \"Line No.\")\n+ {\n+ }\n+\n+ key(Key3; \"Tax Type\", \"Tax Component Code\")\n+ {\n+ }\n+\n+ key(Key4; \"Posting Date\", \"Document Type\", \"Document No.\")\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ if \"Posting Date\" = 0D then\n+ \"Posting Date\" := WorkDate();\n+ end;\n+\n+ procedure GetTaxAmount(): Decimal\n+ begin\n+ exit(Amount);\n+ end;\n+\n+ procedure CalculateTaxAmount(BaseAmount: Decimal): Decimal\n+ begin\n+ if Percent = 0 then\n+ exit(Amount);\n+\n+ exit(Round(BaseAmount * Percent / 100, 0.01));\n+ end;\n+}\ndiff --git a/src/PageroUpgrade.Codeunit.al b/src/PageroUpgrade.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/PageroUpgrade.Codeunit.al\n@@ -0,0 +1,63 @@\n+codeunit 6171 \"Pagero Upgrade\"\n+{\n+ Subtype = Upgrade;\n+\n+ trigger OnUpgradePerDatabase()\n+ var\n+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ PageroUpgradeTags: Codeunit \"Pagero Upgrade Tags\";\n+ begin\n+ if UpgradeTag.HasUpgradeTag(PageroUpgradeTags.GetPageroConnectionSetupUpgradeTag()) then\n+ exit;\n+\n+ SetupDefaultPageroConnection();\n+\n+ UpgradeTag.SetUpgradeTag(PageroUpgradeTags.GetPageroConnectionSetupUpgradeTag());\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerCompanyUpgradeTags', '', false, false)]\n+ local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])\n+ var\n+ PageroUpgradeTags: Codeunit \"Pagero Upgrade Tags\";\n+ begin\n+ PerCompanyUpgradeTags.Add(PageroUpgradeTags.GetPageroDocumentLayoutUpgradeTag());\n+ PerCompanyUpgradeTags.Add(PageroUpgradeTags.GetPageroServiceConnectionUpgradeTag());\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerDatabaseUpgradeTags', '', false, false)]\n+ local procedure RegisterPerDatabaseTags(var PerDatabaseUpgradeTags: List of [Code[250]])\n+ var\n+ PageroUpgradeTags: Codeunit \"Pagero Upgrade Tags\";\n+ begin\n+ PerDatabaseUpgradeTags.Add(PageroUpgradeTags.GetPageroConnectionSetupUpgradeTag());\n+ end;\n+\n+ local procedure SetupDefaultPageroConnection()\n+ var\n+ EDocServiceConnection: Record \"E-Document Service\";\n+ begin\n+ if not EDocServiceConnection.Get('PAGERO') then begin\n+ EDocServiceConnection.Init();\n+ EDocServiceConnection.Code := 'PAGERO';\n+ EDocServiceConnection.Description := 'Pagero E-Document Service';\n+ EDocServiceConnection.\"Service Integration\" := EDocServiceConnection.\"Service Integration\"::Pagero;\n+ EDocServiceConnection.Enabled := false;\n+ EDocServiceConnection.Insert();\n+ end;\n+ end;\n+\n+ procedure UpgradePageroDocumentLayouts()\n+ var\n+ PageroDocumentLayout: Record \"Pagero Document Layout\";\n+ begin\n+ if PageroDocumentLayout.Get(PageroDocumentLayout.\"Document Type\"::Invoice, 'PEPPOL_BIS3') then\n+ exit;\n+\n+ PageroDocumentLayout.Init();\n+ PageroDocumentLayout.\"Document Type\" := PageroDocumentLayout.\"Document Type\"::Invoice;\n+ PageroDocumentLayout.\"Layout Code\" := 'PEPPOL_BIS3';\n+ PageroDocumentLayout.Description := 'PEPPOL BIS3 Format';\n+ PageroDocumentLayout.Enabled := true;\n+ PageroDocumentLayout.Insert();\n+ end;\n+}\ndiff --git a/src/UpgradeExpenseAgentSetup.Codeunit.al b/src/UpgradeExpenseAgentSetup.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/UpgradeExpenseAgentSetup.Codeunit.al\n@@ -0,0 +1,54 @@\n+codeunit 69135 \"Upgrade Expense Agent Setup\"\n+{\n+ Subtype = Upgrade;\n+\n+ trigger OnUpgradePerDatabase()\n+ var\n+ InstallExpenseAgentSetup: Codeunit \"Install Expense Agent Setup\";\n+ begin\n+ InstallExpenseAgentSetup.RegisterCapability();\n+ end;\n+\n+ trigger OnUpgradePerCompany()\n+ var\n+ ExpenseAgentSetup: Record \"Expense Agent Setup\";\n+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ ExpenseAgentUpgradeTags: Codeunit \"Expense Agent Upgrade Tags\";\n+ begin\n+ if UpgradeTag.HasUpgradeTag(ExpenseAgentUpgradeTags.GetExpenseAgentSetupUpgradeTag()) then\n+ exit;\n+\n+ if not ExpenseAgentSetup.Get() then begin\n+ ExpenseAgentSetup.Init();\n+ ExpenseAgentSetup.Insert();\n+ end;\n+\n+ ExpenseAgentSetup.\"Enable AI Processing\" := true;\n+ ExpenseAgentSetup.\"Max File Size (MB)\" := 10;\n+ ExpenseAgentSetup.\"Supported File Types\" := 'PDF,JPG,PNG,JPEG';\n+ ExpenseAgentSetup.Modify();\n+\n+ UpgradeTag.SetUpgradeTag(ExpenseAgentUpgradeTags.GetExpenseAgentSetupUpgradeTag());\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerCompanyUpgradeTags', '', false, false)]\n+ local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])\n+ var\n+ ExpenseAgentUpgradeTags: Codeunit \"Expense Agent Upgrade Tags\";\n+ begin\n+ PerCompanyUpgradeTags.Add(ExpenseAgentUpgradeTags.GetExpenseAgentSetupUpgradeTag());\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerDatabaseUpgradeTags', '', false, false)]\n+ local procedure RegisterPerDatabaseTags(var PerDatabaseUpgradeTags: List of [Code[250]])\n+ begin\n+ end;\n+\n+ procedure UpgradeExpenseCategories()\n+ var\n+ ExpenseCategory: Record \"Expense Category\";\n+ begin\n+ ExpenseCategory.SetRange(\"G/L Account No.\", '');\n+ ExpenseCategory.ModifyAll(\"G/L Account No.\", '6110');\n+ end;\n+}\n", "expected_comments": [{"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 9, "line_end": 9, "body": "OnUpgradePerDatabase calls RegisterCapability without upgrade tag guard — See agent comment for details.", "severity": "critical", "domain": "upgrade"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 43, "line_end": 43, "body": "RegisterPerDatabaseTags subscriber body is empty so per-database upgrade tag is never registered — See agent comment for details.", "severity": "high", "domain": "upgrade"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 12, "line_end": 12, "body": "OnUpgradePerCompany contains direct implementation instead of delegating to a named local procedure — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/LogiqUpgrade.Codeunit.al", "line_start": 5, "line_end": 5, "body": "OnUpgradePerDatabase contains direct implementation instead of delegating to a single local procedure — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/TaxTransactionValue.Table.al", "line_start": 104, "line_end": 104, "body": "Field type change from Integer to BigInteger requires upgrade code — See agent comment for details.", "severity": "critical", "domain": "upgrade"}, {"file": "src/PageroUpgrade.Codeunit.al", "line_start": 5, "line_end": 5, "body": "OnUpgradePerDatabase contains direct implementation instead of delegating to a single local procedure — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/LogiqUpgrade.Codeunit.al", "line_start": 49, "line_end": 49, "body": "Public upgrade procedure UpgradeLogiqDocumentMappings without upgrade tag guard — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/PageroUpgrade.Codeunit.al", "line_start": 49, "line_end": 49, "body": "Public upgrade procedure UpgradePageroDocumentLayouts without upgrade tag guard — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 47, "line_end": 47, "body": "Public upgrade procedure UpgradeExpenseCategories without upgrade tag guard — See agent comment for details.", "severity": "medium", "domain": "upgrade"}], "match_line_tolerance": 2, "domain": "upgrade", "category": "code-review", "description": "True positive upgrade findings: data_upgrade (trimmed to representative upgrade-trigger and tag-guard issues)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__upgrade-007", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "diff --git a/src/ColumnHeaderDateType.Enum.al b/src/ColumnHeaderDateType.Enum.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ColumnHeaderDateType.Enum.al\n@@ -0,0 +1,33 @@\n+/// \n+/// Column Header Date Type Enum (764)\n+/// Defines the date type for column headers in financial reports\n+/// \n+enum 764 \"Column Header Date Type\"\n+{\n+ Extensible = true;\n+\n+ value(0; \"Starting Date\")\n+ {\n+ Caption = 'Starting Date';\n+ }\n+\n+ value(1; \"Ending Date\")\n+ {\n+ Caption = 'Ending Date';\n+ }\n+\n+ value(2; \"Date Range\")\n+ {\n+ Caption = 'Date Range';\n+ }\n+\n+ value(3; \"Period\")\n+ {\n+ Caption = 'Period';\n+ }\n+\n+ value(4; \"Closing Date\")\n+ {\n+ Caption = 'Closing Date';\n+ }\n+}\ndiff --git a/src/ExpenseAgentSetup.Table.al b/src/ExpenseAgentSetup.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ExpenseAgentSetup.Table.al\n@@ -0,0 +1,64 @@\n+table 69130 \"Expense Agent Setup\"\n+{\n+ Caption = 'Expense Agent Setup';\n+ DataPerCompany = true;\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Primary Key\"; Code[10])\n+ {\n+ Caption = 'Primary Key';\n+ NotBlank = true;\n+ }\n+\n+ field(10; \"Enable AI Processing\"; Boolean)\n+ {\n+ Caption = 'Enable AI Processing';\n+ ToolTip = 'Specifies whether AI-based expense processing is enabled.';\n+ }\n+\n+ field(13; \"Max File Size (MB)\"; Integer)\n+ {\n+ Caption = 'Max File Size (MB)';\n+ ToolTip = 'Specifies the maximum allowed receipt file size in megabytes.';\n+ MinValue = 1;\n+ MaxValue = 100;\n+ }\n+\n+ field(350; \"Open Report Notification Frequency\"; Enum \"Notification Frequency\")\n+ {\n+ Caption = 'Open Report Notification Frequency';\n+ ToolTip = 'Specifies how often users are notified about open expense reports.';\n+ InitValue = Daily;\n+ }\n+\n+ field(351; \"Enable Email Notifications\"; Boolean)\n+ {\n+ Caption = 'Enable Email Notifications';\n+ ToolTip = 'Specifies whether email notifications are sent for expense reports.';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Primary Key\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ procedure GetNotificationFrequencyDays(): Integer\n+ begin\n+ case \"Open Report Notification Frequency\" of\n+ \"Open Report Notification Frequency\"::Daily:\n+ exit(1);\n+ \"Open Report Notification Frequency\"::Weekly:\n+ exit(7);\n+ \"Open Report Notification Frequency\"::Monthly:\n+ exit(30);\n+ else\n+ exit(0);\n+ end;\n+ end;\n+}\ndiff --git a/src/ShowCurrencyGenLedgSetup.TableExt.al b/src/ShowCurrencyGenLedgSetup.TableExt.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ShowCurrencyGenLedgSetup.TableExt.al\n@@ -0,0 +1,44 @@\n+tableextension 50200 \"Show Currency Gen Ledg Setup\" extends \"General Ledger Setup\"\n+{\n+ fields\n+ {\n+ field(50200; \"Show Currency Code\"; Boolean)\n+ {\n+ Caption = 'Show Currency Code';\n+ ToolTip = 'Specifies whether the currency code is shown in general ledger views.';\n+ }\n+\n+ field(50201; \"Currency Symbol Position\"; Enum \"Currency Symbol Position\")\n+ {\n+ Caption = 'Currency Symbol Position';\n+ ToolTip = 'Specifies where the currency symbol is positioned relative to amounts.';\n+ InitValue = \"Before Amount\";\n+ }\n+\n+ field(50202; \"Show Currency Symbol\"; Boolean)\n+ {\n+ Caption = 'Show Currency Symbol';\n+ ToolTip = 'Specifies whether the currency symbol is shown next to amounts.';\n+ }\n+\n+ field(50203; \"Currency Decimal Places\"; Integer)\n+ {\n+ Caption = 'Currency Decimal Places';\n+ ToolTip = 'Specifies the number of decimal places used when displaying currency amounts.';\n+ InitValue = 2;\n+ MinValue = 0;\n+ MaxValue = 5;\n+ }\n+ }\n+\n+ procedure SetDefaultCurrencySymbolPosition()\n+ var\n+ GeneralLedgerSetup: Record \"General Ledger Setup\";\n+ begin\n+ GeneralLedgerSetup.Get();\n+ if GeneralLedgerSetup.\"Currency Symbol Position\" = GeneralLedgerSetup.\"Currency Symbol Position\"::\"Default\" then begin\n+ GeneralLedgerSetup.\"Currency Symbol Position\" := GeneralLedgerSetup.\"Currency Symbol Position\"::\"Before Amount\";\n+ GeneralLedgerSetup.Modify();\n+ end;\n+ end;\n+}\n", "expected_comments": [{"file": "src/ColumnHeaderDateType.Enum.al", "line_start": 5, "line_end": 5, "body": "Enum ID changed from 5002000 to 764 — See agent comment for details.", "severity": "critical", "domain": "upgrade"}, {"file": "src/ExpenseAgentSetup.Table.al", "line_start": 33, "line_end": 33, "body": "InitValue = Daily added without upgrade code — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/ShowCurrencyGenLedgSetup.TableExt.al", "line_start": 15, "line_end": 15, "body": "InitValue with enum re-numbering issue — See agent comment for details.", "severity": "high", "domain": "upgrade"}, {"file": "src/ShowCurrencyGenLedgSetup.TableExt.al", "line_start": 28, "line_end": 28, "body": "InitValue = 2 on Currency Decimal Places without upgrade code for existing record — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/ShowCurrencyGenLedgSetup.TableExt.al", "line_start": 34, "line_end": 34, "body": "SetShowCurrencySymbolPosition not in upgrade codeunit, no tag guard, unprotected Get() — See agent comment for details.", "severity": "high", "domain": "upgrade"}], "match_line_tolerance": 2, "domain": "upgrade", "category": "code-review", "description": "True positive upgrade findings: enum_conversion (trimmed to reliably detected enum/initvalue risks)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__upgrade-008", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "diff --git a/src/OIOUBLInitialize.Codeunit.al b/src/OIOUBLInitialize.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/OIOUBLInitialize.Codeunit.al\n@@ -0,0 +1,78 @@\n+codeunit 13631 \"OIOUBL-Initialize\"\n+{\n+ Subtype = Install;\n+\n+ trigger OnInstallAppPerCompany()\n+ var\n+ AppInfo: ModuleInfo;\n+ begin\n+ NavApp.GetCurrentModuleInfo(AppInfo);\n+\n+ if AppInfo.DataVersion = Version.Create(0, 0, 0, 0) then\n+ SetupOIOUBLDefaults()\n+ else\n+ HandleOIOUBLUpgrade(AppInfo.DataVersion);\n+ end;\n+\n+ trigger OnInstallAppPerDatabase()\n+ begin\n+ SetupOIOUBLReportSelections();\n+ end;\n+\n+ local procedure HandleOIOUBLUpgrade(AppVersion: Version)\n+ begin\n+ if AppVersion < Version.Create(25, 0, 0, 0) then\n+ UpgradeToV25();\n+ end;\n+\n+ local procedure SetupOIOUBLDefaults()\n+ var\n+ CompanyInformation: Record \"Company Information\";\n+ OIOUBLProfile: Record \"OIOUBL-Profile\";\n+ begin\n+ if not OIOUBLProfile.Get() then begin\n+ OIOUBLProfile.Init();\n+ OIOUBLProfile.\"OIOUBL Code\" := 'DEFAULT';\n+ OIOUBLProfile.\"OIOUBL Path\" := 'OIOUBL';\n+ OIOUBLProfile.\"Check Company\" := true;\n+ OIOUBLProfile.\"Check Customer\" := true;\n+ OIOUBLProfile.\"Check Item\" := true;\n+ OIOUBLProfile.Insert();\n+ end;\n+\n+ if CompanyInformation.Get() then\n+ if CompanyInformation.\"Country/Region Code\" = 'DK' then begin\n+ CompanyInformation.\"OIOUBL-Profile Code\" := 'DEFAULT';\n+ CompanyInformation.Modify();\n+ end;\n+ end;\n+\n+ local procedure SetupOIOUBLReportSelections()\n+ var\n+ ReportSelections: Record \"Report Selections\";\n+ OIOUBLManagement: Codeunit \"OIOUBL-Management\";\n+ begin\n+ OIOUBLManagement.InsertOIOUBLReportSelections(ReportSelections.Usage::\"S.Invoice\", Report::\"OIOUBL-Sales Invoice\");\n+ OIOUBLManagement.InsertOIOUBLReportSelections(ReportSelections.Usage::\"S.Cr.Memo\", Report::\"OIOUBL-Sales Cr. Memo\");\n+ OIOUBLManagement.InsertOIOUBLReportSelections(ReportSelections.Usage::\"Reminder\", Report::\"OIOUBL-Reminder\");\n+ OIOUBLManagement.InsertOIOUBLReportSelections(ReportSelections.Usage::\"Fin.Charge\", Report::\"OIOUBL-Fin. Charge Memo\");\n+ end;\n+\n+ local procedure UpgradeToV25()\n+ var\n+ OIOUBLProfile: Record \"OIOUBL-Profile\";\n+ GLSetup: Record \"General Ledger Setup\";\n+ begin\n+ if OIOUBLProfile.Get() then begin\n+ OIOUBLProfile.\"Check Item Reference\" := true;\n+ OIOUBLProfile.\"Validate Line Discount\" := true;\n+ OIOUBLProfile.Modify();\n+ end;\n+\n+ if GLSetup.Get() then\n+ if GLSetup.\"Country/Region Code\" = 'DK' then begin\n+ GLSetup.\"OIOUBL Enabled\" := true;\n+ GLSetup.Modify();\n+ end;\n+ end;\n+}\ndiff --git a/src/WHTPurchTaxCrMemoHdr.Table.al b/src/WHTPurchTaxCrMemoHdr.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/WHTPurchTaxCrMemoHdr.Table.al\n@@ -0,0 +1,195 @@\n+table 28047 \"WHT Purch. Tax Cr. Memo Hdr.\"\n+{\n+ Caption = 'WHT Purch. Tax Cr. Memo Hdr.';\n+ DataClassification = CustomerContent;\n+ ObsoleteState = Removed;\n+ ObsoleteReason = 'Replaced with standard Purchase Credit Memo with WHT extensions';\n+ ObsoleteTag = '26.0';\n+\n+ fields\n+ {\n+ field(1; \"No.\"; Code[20])\n+ {\n+ Caption = 'No.';\n+ }\n+\n+ field(2; \"Buy-from Vendor No.\"; Code[20])\n+ {\n+ Caption = 'Buy-from Vendor No.';\n+ TableRelation = Vendor;\n+ }\n+\n+ field(3; \"Buy-from Vendor Name\"; Text[100])\n+ {\n+ Caption = 'Buy-from Vendor Name';\n+ }\n+\n+ field(4; \"Buy-from Address\"; Text[100])\n+ {\n+ Caption = 'Buy-from Address';\n+ }\n+\n+ field(5; \"Buy-from City\"; Text[30])\n+ {\n+ Caption = 'Buy-from City';\n+ }\n+\n+ field(6; \"Buy-from Contact\"; Text[100])\n+ {\n+ Caption = 'Buy-from Contact';\n+ }\n+\n+ field(7; \"Posting Date\"; Date)\n+ {\n+ Caption = 'Posting Date';\n+ }\n+\n+ field(8; \"Document Date\"; Date)\n+ {\n+ Caption = 'Document Date';\n+ }\n+\n+ field(9; \"Due Date\"; Date)\n+ {\n+ Caption = 'Due Date';\n+ }\n+\n+ field(10; \"Payment Discount %\"; Decimal)\n+ {\n+ Caption = 'Payment Discount %';\n+ DecimalPlaces = 0 : 5;\n+ }\n+\n+ field(11; \"Payment Terms Code\"; Code[10])\n+ {\n+ Caption = 'Payment Terms Code';\n+ TableRelation = \"Payment Terms\";\n+ }\n+\n+ field(12; \"Currency Code\"; Code[10])\n+ {\n+ Caption = 'Currency Code';\n+ TableRelation = Currency;\n+ }\n+\n+ field(13; \"Currency Factor\"; Decimal)\n+ {\n+ Caption = 'Currency Factor';\n+ DecimalPlaces = 0 : 15;\n+ }\n+\n+ field(14; Amount; Decimal)\n+ {\n+ Caption = 'Amount';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(15; \"Amount Including VAT\"; Decimal)\n+ {\n+ Caption = 'Amount Including VAT';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(16; \"WHT Business Posting Group\"; Code[20])\n+ {\n+ Caption = 'WHT Business Posting Group';\n+ TableRelation = \"WHT Business Posting Group\";\n+ }\n+\n+ field(17; \"WHT Product Posting Group\"; Code[20])\n+ {\n+ Caption = 'WHT Product Posting Group';\n+ TableRelation = \"WHT Product Posting Group\";\n+ }\n+\n+ field(18; \"WHT Amount\"; Decimal)\n+ {\n+ Caption = 'WHT Amount';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(19; \"WHT Amount (LCY)\"; Decimal)\n+ {\n+ Caption = 'WHT Amount (LCY)';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(20; \"WHT %\"; Decimal)\n+ {\n+ Caption = 'WHT %';\n+ DecimalPlaces = 0 : 5;\n+ }\n+\n+ field(21; \"WHT Certificate No.\"; Code[20])\n+ {\n+ Caption = 'WHT Certificate No.';\n+ }\n+\n+ field(22; \"Vendor Cr. Memo No.\"; Code[35])\n+ {\n+ Caption = 'Vendor Cr. Memo No.';\n+ }\n+\n+ field(23; \"Gen. Bus. Posting Group\"; Code[20])\n+ {\n+ Caption = 'Gen. Bus. Posting Group';\n+ TableRelation = \"Gen. Business Posting Group\";\n+ }\n+\n+ field(24; \"VAT Bus. Posting Group\"; Code[20])\n+ {\n+ Caption = 'VAT Bus. Posting Group';\n+ TableRelation = \"VAT Business Posting Group\";\n+ }\n+\n+ field(25; \"Reason Code\"; Code[10])\n+ {\n+ Caption = 'Reason Code';\n+ TableRelation = \"Reason Code\";\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"No.\")\n+ {\n+ Clustered = true;\n+ }\n+\n+ key(Key2; \"Buy-from Vendor No.\", \"Posting Date\")\n+ {\n+ }\n+\n+ key(Key3; \"WHT Business Posting Group\", \"WHT Product Posting Group\")\n+ {\n+ }\n+ }\n+\n+ trigger OnDelete()\n+ var\n+ WHTEntry: Record \"WHT Entry\";\n+ WHTCertificate: Record \"WHT Certificate\";\n+ begin\n+ WHTEntry.SetRange(\"Document No.\", \"No.\");\n+ WHTEntry.DeleteAll();\n+\n+ if \"WHT Certificate No.\" <> '' then begin\n+ WHTCertificate.SetRange(\"Certificate No.\", \"WHT Certificate No.\");\n+ WHTCertificate.DeleteAll();\n+ end;\n+ end;\n+\n+ procedure CalcWHTAmount()\n+ begin\n+ if \"WHT %\" <> 0 then begin\n+ \"WHT Amount\" := Round(Amount * \"WHT %\" / 100, 0.01);\n+ if \"Currency Factor\" <> 0 then\n+ \"WHT Amount (LCY)\" := Round(\"WHT Amount\" / \"Currency Factor\", 0.01)\n+ else\n+ \"WHT Amount (LCY)\" := \"WHT Amount\";\n+ end else begin\n+ \"WHT Amount\" := 0;\n+ \"WHT Amount (LCY)\" := 0;\n+ end;\n+ end;\n+}\n", "expected_comments": [{"file": "src/OIOUBLInitialize.Codeunit.al", "line_start": 24, "line_end": 24, "body": "Version check pattern instead of upgrade tags - not idempotent — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/OIOUBLInitialize.Codeunit.al", "line_start": 61, "line_end": 61, "body": "UpgradeToV25 procedure lacks upgrade tag to prevent re-execution — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/WHTPurchTaxCrMemoHdr.Table.al", "line_start": 5, "line_end": 5, "body": "Table marked ObsoleteState = Removed without corresponding upgrade code for data migration — See agent comment for details.", "severity": "high", "domain": "upgrade"}, {"file": "src/WHTPurchTaxCrMemoHdr.Table.al", "line_start": 168, "line_end": 168, "body": "OnDelete trigger on removed table can cascade-delete related WHT records during cleanup or migration — See agent comment for details.", "severity": "medium", "domain": "upgrade"}], "match_line_tolerance": 2, "domain": "upgrade", "category": "code-review", "description": "True positive upgrade findings: obsolete_usage (trimmed to reliably detected findings)", "expect_findings": true, "source": "vsoadmin"} +{"repo": "microsoft/BCApps", "instance_id": "synthetic__upgrade-009", "base_commit": "70fd0246a0a4dbc72cb183ca719106722c03be4d", "created_at": "2026-05-15T00:00:00Z", "environment_setup_version": "27.0", "project_paths": [], "metadata": {"area": "upgrade"}, "patch": "diff --git a/src/ContactSyncFolder.Table.al b/src/ContactSyncFolder.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/ContactSyncFolder.Table.al\n@@ -0,0 +1,234 @@\n+/// \n+/// Contact Sync Folder Table (5368)\n+/// Stores folder information for contact synchronization with external systems\n+/// \n+table 5368 \"Contact Sync Folder\"\n+{\n+ Caption = 'Contact Sync Folder';\n+ DataClassification = CustomerContent;\n+\n+ fields\n+ {\n+ field(1; \"Folder ID\"; Text[250])\n+ {\n+ Caption = 'Folder ID';\n+ NotBlank = true;\n+ }\n+\n+ field(2; Name; Text[100])\n+ {\n+ Caption = 'Name';\n+ }\n+\n+ field(3; \"Last Sync DateTime\"; DateTime)\n+ {\n+ Caption = 'Last Sync DateTime';\n+ Editable = false;\n+ }\n+\n+ field(4; \"Parent Id\"; Text[250])\n+ {\n+ Caption = 'Parent Id';\n+ }\n+\n+ field(5; \"Folder Type\"; Option)\n+ {\n+ Caption = 'Folder Type';\n+ OptionCaption = 'Root,Contacts,Companies,People,Distribution Lists';\n+ OptionMembers = Root,Contacts,Companies,People,\"Distribution Lists\";\n+ }\n+\n+ field(6; \"Sync Enabled\"; Boolean)\n+ {\n+ Caption = 'Sync Enabled';\n+ InitValue = true;\n+ }\n+\n+ field(7; \"Sync Direction\"; Option)\n+ {\n+ Caption = 'Sync Direction';\n+ OptionCaption = 'Bidirectional,To Exchange,From Exchange';\n+ OptionMembers = Bidirectional,\"To Exchange\",\"From Exchange\";\n+ InitValue = Bidirectional;\n+ }\n+\n+ field(8; \"Exchange Service\"; Code[20])\n+ {\n+ Caption = 'Exchange Service';\n+ TableRelation = \"Exchange Service Connection\";\n+ }\n+\n+ field(9; \"Total Items\"; Integer)\n+ {\n+ Caption = 'Total Items';\n+ Editable = false;\n+ }\n+\n+ field(10; \"Synced Items\"; Integer)\n+ {\n+ Caption = 'Synced Items';\n+ Editable = false;\n+ }\n+\n+ field(11; \"Pending Items\"; Integer)\n+ {\n+ Caption = 'Pending Items';\n+ Editable = false;\n+ CalcFormula = Count(\"Contact Sync Entry\" WHERE(\"Folder ID\" = FIELD(\"Folder ID\"), \"Sync Status\" = CONST(Pending)));\n+ FieldClass = FlowField;\n+ }\n+\n+ field(12; \"Error Items\"; Integer)\n+ {\n+ Caption = 'Error Items';\n+ Editable = false;\n+ CalcFormula = Count(\"Contact Sync Entry\" WHERE(\"Folder ID\" = FIELD(\"Folder ID\"), \"Sync Status\" = CONST(Error)));\n+ FieldClass = FlowField;\n+ }\n+\n+ field(13; \"Auto Sync Interval\"; Duration)\n+ {\n+ Caption = 'Auto Sync Interval';\n+ }\n+\n+ field(14; \"Next Sync DateTime\"; DateTime)\n+ {\n+ Caption = 'Next Sync DateTime';\n+ }\n+\n+ field(15; \"Conflict Resolution\"; Option)\n+ {\n+ Caption = 'Conflict Resolution';\n+ OptionCaption = 'Exchange Wins,Business Central Wins,Manual Resolution';\n+ OptionMembers = \"Exchange Wins\",\"Business Central Wins\",\"Manual Resolution\";\n+ InitValue = \"Manual Resolution\";\n+ }\n+\n+ field(16; \"Created By\"; Code[50])\n+ {\n+ Caption = 'Created By';\n+ Editable = false;\n+ TableRelation = User.\"User Name\";\n+ }\n+\n+ field(17; \"Created DateTime\"; DateTime)\n+ {\n+ Caption = 'Created DateTime';\n+ Editable = false;\n+ }\n+\n+ field(18; \"Modified By\"; Code[50])\n+ {\n+ Caption = 'Modified By';\n+ Editable = false;\n+ TableRelation = User.\"User Name\";\n+ }\n+\n+ field(19; \"Modified DateTime\"; DateTime)\n+ {\n+ Caption = 'Modified DateTime';\n+ Editable = false;\n+ }\n+\n+ field(20; Blocked; Boolean)\n+ {\n+ Caption = 'Blocked';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Folder ID\")\n+ {\n+ Clustered = true;\n+ }\n+\n+ key(Key2; \"Exchange Service\", \"Sync Enabled\")\n+ {\n+ }\n+\n+ key(Key3; \"Parent Id\", \"Folder Type\")\n+ {\n+ }\n+\n+ key(Key4; \"Next Sync DateTime\")\n+ {\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ fieldgroup(DropDown; \"Folder ID\", Name, \"Folder Type\", \"Sync Enabled\")\n+ {\n+ }\n+\n+ fieldgroup(Brick; \"Folder ID\", Name, \"Total Items\", \"Last Sync DateTime\")\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ \"Created By\" := UserId;\n+ \"Created DateTime\" := CurrentDateTime;\n+ \"Modified By\" := UserId;\n+ \"Modified DateTime\" := CurrentDateTime;\n+ end;\n+\n+ trigger OnModify()\n+ begin\n+ \"Modified By\" := UserId;\n+ \"Modified DateTime\" := CurrentDateTime;\n+ end;\n+\n+ trigger OnDelete()\n+ var\n+ ContactSyncEntry: Record \"Contact Sync Entry\";\n+ begin\n+ ContactSyncEntry.SetRange(\"Folder ID\", \"Folder ID\");\n+ ContactSyncEntry.DeleteAll();\n+ end;\n+\n+ procedure SyncContacts()\n+ var\n+ ContactSyncMgt: Codeunit \"Contact Sync. Management\";\n+ begin\n+ TestField(\"Sync Enabled\", true);\n+ TestField(Blocked, false);\n+\n+ ContactSyncMgt.SyncFolder(Rec);\n+ end;\n+\n+ procedure ScheduleNextSync()\n+ begin\n+ if \"Auto Sync Interval\" > 0 then\n+ \"Next Sync DateTime\" := CurrentDateTime + \"Auto Sync Interval\";\n+ end;\n+\n+ procedure UpdateSyncStatistics()\n+ var\n+ ContactSyncEntry: Record \"Contact Sync Entry\";\n+ begin\n+ ContactSyncEntry.SetRange(\"Folder ID\", \"Folder ID\");\n+ \"Total Items\" := ContactSyncEntry.Count();\n+\n+ ContactSyncEntry.SetRange(\"Sync Status\", ContactSyncEntry.\"Sync Status\"::Synced);\n+ \"Synced Items\" := ContactSyncEntry.Count();\n+\n+ \"Last Sync DateTime\" := CurrentDateTime;\n+ Modify();\n+ end;\n+\n+ procedure GetChildFolders(var ChildFolders: Record \"Contact Sync Folder\")\n+ begin\n+ ChildFolders.SetRange(\"Parent Id\", \"Folder ID\");\n+ ChildFolders.SetRange(\"Sync Enabled\", true);\n+ ChildFolders.SetRange(Blocked, false);\n+ end;\n+\n+ procedure HasPendingSync(): Boolean\n+ begin\n+ CalcFields(\"Pending Items\");\n+ exit(\"Pending Items\" > 0);\n+ end;\n+}\ndiff --git a/src/Currency.Table.al b/src/Currency.Table.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/Currency.Table.al\n@@ -0,0 +1,255 @@\n+/// \n+/// Currency Table (4)\n+/// Master table for currency codes and settings\n+/// \n+table 4 Currency\n+{\n+ Caption = 'Currency';\n+ DataCaptionFields = \"Code\", Description;\n+ DataClassification = CustomerContent;\n+ LookupPageID = Currencies;\n+\n+ fields\n+ {\n+ field(1; \"Code\"; Code[10])\n+ {\n+ Caption = 'Code';\n+ NotBlank = true;\n+ }\n+\n+ field(2; \"Last Date Modified\"; Date)\n+ {\n+ Caption = 'Last Date Modified';\n+ Editable = false;\n+ }\n+\n+ field(3; \"Last Date Adjusted\"; Date)\n+ {\n+ Caption = 'Last Date Adjusted';\n+ Editable = false;\n+ }\n+\n+ field(4; \"Exchange Rate Amount\"; Decimal)\n+ {\n+ Caption = 'Exchange Rate Amount';\n+ DecimalPlaces = 1 : 6;\n+ InitValue = 1;\n+ MinValue = 0;\n+ NotBlank = true;\n+ }\n+\n+ field(5; \"Relational Exch. Rate Amount\"; Decimal)\n+ {\n+ Caption = 'Relational Exch. Rate Amount';\n+ DecimalPlaces = 1 : 6;\n+ InitValue = 1;\n+ MinValue = 0;\n+ }\n+\n+ field(6; \"Unrealized Gains Acc.\"; Code[20])\n+ {\n+ Caption = 'Unrealized Gains Acc.';\n+ TableRelation = \"G/L Account\";\n+ }\n+\n+ field(7; \"Unrealized Losses Acc.\"; Code[20])\n+ {\n+ Caption = 'Unrealized Losses Acc.';\n+ TableRelation = \"G/L Account\";\n+ }\n+\n+ field(8; \"Realized Gains Acc.\"; Code[20])\n+ {\n+ Caption = 'Realized Gains Acc.';\n+ TableRelation = \"G/L Account\";\n+ }\n+\n+ field(9; \"Realized Losses Acc.\"; Code[20])\n+ {\n+ Caption = 'Realized Losses Acc.';\n+ TableRelation = \"G/L Account\";\n+ }\n+\n+ field(10; \"Amount Rounding Precision\"; Decimal)\n+ {\n+ Caption = 'Amount Rounding Precision';\n+ DecimalPlaces = 0 : 5;\n+ InitValue = 0.01;\n+ }\n+\n+ field(11; \"Unit-Amount Rounding Precision\"; Decimal)\n+ {\n+ Caption = 'Unit-Amount Rounding Precision';\n+ DecimalPlaces = 0 : 5;\n+ InitValue = 0.00001;\n+ }\n+\n+ field(12; Description; Text[60])\n+ {\n+ Caption = 'Description';\n+ }\n+\n+ field(13; \"Invoice Rounding Precision\"; Decimal)\n+ {\n+ Caption = 'Invoice Rounding Precision';\n+ DecimalPlaces = 0 : 5;\n+ InitValue = 1;\n+ }\n+\n+ field(14; \"Invoice Rounding Type\"; Option)\n+ {\n+ Caption = 'Invoice Rounding Type';\n+ OptionCaption = 'Nearest,Up,Down';\n+ OptionMembers = Nearest,Up,Down;\n+ }\n+\n+ field(15; \"Amount Decimal Places\"; Text[5])\n+ {\n+ Caption = 'Amount Decimal Places';\n+ }\n+\n+ field(16; \"Unit-Amount Decimal Places\"; Text[5])\n+ {\n+ Caption = 'Unit-Amount Decimal Places';\n+ }\n+\n+ field(17; \"Appln. Rounding Precision\"; Decimal)\n+ {\n+ Caption = 'Appln. Rounding Precision';\n+ DecimalPlaces = 0 : 5;\n+ InitValue = 0.01;\n+ }\n+\n+ field(18; \"Conv. LCY Rndg. Debit Acc.\"; Code[20])\n+ {\n+ Caption = 'Conv. LCY Rndg. Debit Acc.';\n+ TableRelation = \"G/L Account\";\n+ }\n+\n+ field(19; \"Conv. LCY Rndg. Credit Acc.\"; Code[20])\n+ {\n+ Caption = 'Conv. LCY Rndg. Credit Acc.';\n+ TableRelation = \"G/L Account\";\n+ }\n+\n+ field(20; \"Max. VAT Difference Allowed\"; Decimal)\n+ {\n+ Caption = 'Max. VAT Difference Allowed';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(21; \"VAT Rounding Type\"; Option)\n+ {\n+ Caption = 'VAT Rounding Type';\n+ OptionCaption = 'Nearest,Up,Down';\n+ OptionMembers = Nearest,Up,Down;\n+ }\n+\n+ field(22; \"Payment Tolerance %\"; Decimal)\n+ {\n+ Caption = 'Payment Tolerance %';\n+ DecimalPlaces = 0 : 5;\n+ }\n+\n+ field(23; \"Max. Payment Tolerance Amount\"; Decimal)\n+ {\n+ Caption = 'Max. Payment Tolerance Amount';\n+ DecimalPlaces = 2 : 5;\n+ }\n+\n+ field(24; Symbol; Text[10])\n+ {\n+ Caption = 'Symbol';\n+ }\n+\n+ field(746; \"Currency Symbol Position\"; Enum \"Currency Symbol Position\")\n+ {\n+ Caption = 'Currency Symbol Position';\n+ InitValue = \"Default\";\n+ }\n+\n+ field(747; \"ISO Code\"; Code[3])\n+ {\n+ Caption = 'ISO Code';\n+ }\n+\n+ field(748; \"ISO Numeric Code\"; Code[3])\n+ {\n+ Caption = 'ISO Numeric Code';\n+ }\n+\n+ field(749; \"Digital Currency\"; Boolean)\n+ {\n+ Caption = 'Digital Currency';\n+ }\n+ }\n+\n+ keys\n+ {\n+ key(Key1; \"Code\")\n+ {\n+ Clustered = true;\n+ }\n+ }\n+\n+ fieldgroups\n+ {\n+ fieldgroup(DropDown; \"Code\", Description, Symbol, \"ISO Code\")\n+ {\n+ }\n+\n+ fieldgroup(Brick; \"Code\", Description, Symbol)\n+ {\n+ }\n+ }\n+\n+ trigger OnInsert()\n+ begin\n+ \"Last Date Modified\" := Today;\n+ SetDefaultValues();\n+ end;\n+\n+ trigger OnModify()\n+ begin\n+ \"Last Date Modified\" := Today;\n+ end;\n+\n+ local procedure SetDefaultValues()\n+ begin\n+ if \"Exchange Rate Amount\" = 0 then\n+ \"Exchange Rate Amount\" := 1;\n+\n+ if \"Relational Exch. Rate Amount\" = 0 then\n+ \"Relational Exch. Rate Amount\" := 1;\n+\n+ if \"Amount Rounding Precision\" = 0 then\n+ \"Amount Rounding Precision\" := 0.01;\n+\n+ if \"Unit-Amount Rounding Precision\" = 0 then\n+ \"Unit-Amount Rounding Precision\" := 0.00001;\n+\n+ if \"Invoice Rounding Precision\" = 0 then\n+ \"Invoice Rounding Precision\" := 1;\n+\n+ if \"Appln. Rounding Precision\" = 0 then\n+ \"Appln. Rounding Precision\" := 0.01;\n+ end;\n+\n+ procedure InitRoundingPrecision()\n+ begin\n+ \"Amount Rounding Precision\" := 0.01;\n+ \"Unit-Amount Rounding Precision\" := 0.00001;\n+ \"Appln. Rounding Precision\" := 0.01;\n+ \"Invoice Rounding Precision\" := 1;\n+ \"Invoice Rounding Type\" := \"Invoice Rounding Type\"::Nearest;\n+ \"VAT Rounding Type\" := \"VAT Rounding Type\"::Nearest;\n+ end;\n+\n+ procedure GetCurrencySymbol(): Text[10]\n+ begin\n+ if Symbol <> '' then\n+ exit(Symbol);\n+\n+ exit(Code);\n+ end;\n+}\ndiff --git a/src/UpgradeExpenseAgentSetup.Codeunit.al b/src/UpgradeExpenseAgentSetup.Codeunit.al\nnew file mode 100644\n--- /dev/null\n+++ b/src/UpgradeExpenseAgentSetup.Codeunit.al\n@@ -0,0 +1,119 @@\n+/// \n+/// Upgrade Expense Agent Setup Codeunit (69135)\n+/// Handles upgrade procedures for Expense Agent setup with direct implementation\n+/// \n+codeunit 69135 \"Upgrade Expense Agent Setup\"\n+{\n+ Subtype = Upgrade;\n+\n+ trigger OnUpgradePerDatabase()\n+ var\n+ InstallExpenseAgentSetup: Codeunit \"Install Expense Agent Setup\";\n+ begin\n+ InstallExpenseAgentSetup.RegisterCapability();\n+ end;\n+\n+ trigger OnUpgradePerCompany()\n+ var\n+ ExpenseAgentSetup: Record \"Expense Agent Setup\";\n+ UpgradeTag: Codeunit \"Upgrade Tag\";\n+ ExpenseAgentUpgradeTags: Codeunit \"Expense Agent Upgrade Tags\";\n+ begin\n+ if UpgradeTag.HasUpgradeTag(ExpenseAgentUpgradeTags.GetExpenseAgentSetupUpgradeTag()) then\n+ exit;\n+\n+ UpgradeExpenseAgentSetup();\n+\n+ UpgradeTag.SetUpgradeTag(ExpenseAgentUpgradeTags.GetExpenseAgentSetupUpgradeTag());\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerCompanyUpgradeTags', '', false, false)]\n+ local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])\n+ var\n+ ExpenseAgentUpgradeTags: Codeunit \"Expense Agent Upgrade Tags\";\n+ begin\n+ PerCompanyUpgradeTags.Add(ExpenseAgentUpgradeTags.GetExpenseAgentSetupUpgradeTag());\n+ end;\n+\n+ [EventSubscriber(ObjectType::Codeunit, Codeunit::\"Upgrade Tag\", 'OnGetPerDatabaseUpgradeTags', '', false, false)]\n+ local procedure RegisterPerDatabaseTags(var PerDatabaseUpgradeTags: List of [Code[250]])\n+ var\n+ ExpenseAgentUpgradeTags: Codeunit \"Expense Agent Upgrade Tags\";\n+ begin\n+ PerDatabaseUpgradeTags.Add(ExpenseAgentUpgradeTags.GetExpenseCapabilityUpgradeTag());\n+ end;\n+\n+ local procedure UpgradeExpenseAgentSetup()\n+ var\n+ ExpenseAgentSetup: Record \"Expense Agent Setup\";\n+ begin\n+ if not ExpenseAgentSetup.Get() then begin\n+ ExpenseAgentSetup.Init();\n+ ExpenseAgentSetup.Insert();\n+ end;\n+\n+ // Set default values for new fields\n+ ExpenseAgentSetup.\"Enable AI Processing\" := true;\n+ ExpenseAgentSetup.\"Max File Size (MB)\" := 10;\n+ ExpenseAgentSetup.\"Supported File Types\" := 'PDF,JPG,PNG,JPEG';\n+ ExpenseAgentSetup.\"Auto-Submit Threshold\" := 100;\n+ ExpenseAgentSetup.\"Receipt Required for Amount\" := 50;\n+ ExpenseAgentSetup.\"Mileage Rate per KM\" := 0.45;\n+ ExpenseAgentSetup.\"Supervisor Notification Days\" := 7;\n+ ExpenseAgentSetup.\"Expense Approval Timeout (Days)\" := 14;\n+ ExpenseAgentSetup.Modify();\n+ end;\n+\n+ procedure UpgradeExpenseCategories()\n+ var\n+ ExpenseCategory: Record \"Expense Category\";\n+ GLAccount: Record \"G/L Account\";\n+ begin\n+ ExpenseCategory.SetRange(\"G/L Account No.\", '');\n+ if ExpenseCategory.FindSet() then\n+ repeat\n+ // Set default G/L accounts for expense categories without them\n+ case ExpenseCategory.Type of\n+ ExpenseCategory.Type::Travel:\n+ if GLAccount.Get('6110') then\n+ ExpenseCategory.\"G/L Account No.\" := GLAccount.\"No.\";\n+ ExpenseCategory.Type::Meals:\n+ if GLAccount.Get('6120') then\n+ ExpenseCategory.\"G/L Account No.\" := GLAccount.\"No.\";\n+ ExpenseCategory.Type::Accommodation:\n+ if GLAccount.Get('6130') then\n+ ExpenseCategory.\"G/L Account No.\" := GLAccount.\"No.\";\n+ ExpenseCategory.Type::Transportation:\n+ if GLAccount.Get('6140') then\n+ ExpenseCategory.\"G/L Account No.\" := GLAccount.\"No.\";\n+ ExpenseCategory.Type::Entertainment:\n+ if GLAccount.Get('6150') then\n+ ExpenseCategory.\"G/L Account No.\" := GLAccount.\"No.\";\n+ end;\n+ ExpenseCategory.Modify();\n+ until ExpenseCategory.Next() = 0;\n+ end;\n+\n+ procedure UpgradeExpensePaymentMethods()\n+ var\n+ PaymentMethod: Record \"Payment Method\";\n+ ExpensePaymentMethod: Record \"Expense Payment Method\";\n+ begin\n+ // Migrate from Payment Method to Expense Payment Method\n+ PaymentMethod.SetRange(\"Expense Report Type\", true);\n+ if PaymentMethod.FindSet() then\n+ repeat\n+ if not ExpensePaymentMethod.Get(PaymentMethod.Code) then begin\n+ ExpensePaymentMethod.Init();\n+ ExpensePaymentMethod.Code := PaymentMethod.Code;\n+ ExpensePaymentMethod.Description := PaymentMethod.Description;\n+ ExpensePaymentMethod.\"Reimbursable\" := not PaymentMethod.\"Corporate Card\";\n+ ExpensePaymentMethod.\"Corporate Card\" := PaymentMethod.\"Corporate Card\";\n+ ExpensePaymentMethod.\"Requires Receipt\" := PaymentMethod.\"Receipt Required\";\n+ ExpensePaymentMethod.\"Balancing Account Type\" := PaymentMethod.\"Bal. Account Type\";\n+ ExpensePaymentMethod.\"Balancing Account No.\" := PaymentMethod.\"Bal. Account No.\";\n+ ExpensePaymentMethod.Insert();\n+ end;\n+ until PaymentMethod.Next() = 0;\n+ end;\n+}\n", "expected_comments": [{"file": "src/Currency.Table.al", "line_start": 168, "line_end": 168, "body": "New field with InitValue = Default without upgrade code — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 9, "line_end": 9, "body": "OnUpgradePerDatabase trigger lacks upgrade tag protection and runs per-database logic on every upgrade — See agent comment for details.", "severity": "high", "domain": "upgrade"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 16, "line_end": 16, "body": "OnUpgradePerCompany trigger contains inline implementation instead of delegating to a named procedure — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 56, "line_end": 56, "body": "UpgradeExpenseAgentSetup() unconditionally overwrites existing setup values during upgrade — See agent comment for details.", "severity": "high", "domain": "upgrade"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 67, "line_end": 67, "body": "Public upgrade procedure UpgradeExpenseCategories without upgrade tag guard — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 97, "line_end": 97, "body": "Public upgrade procedure UpgradeExpensePaymentMethods without upgrade tag guard — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/ContactSyncFolder.Table.al", "line_start": 44, "line_end": 44, "body": "InitValue = true on Sync Enabled without upgrade code for existing records — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/ContactSyncFolder.Table.al", "line_start": 104, "line_end": 104, "body": "Conflict Resolution InitValue on existing table without upgrade code for existing records — See agent comment for details.", "severity": "medium", "domain": "upgrade"}, {"file": "src/UpgradeExpenseAgentSetup.Codeunit.al", "line_start": 93, "line_end": 93, "body": "Modify() called unconditionally even when no case branch matched — See agent comment for details.", "severity": "low", "domain": "upgrade"}], "match_line_tolerance": 2, "domain": "upgrade", "category": "code-review", "description": "True positive upgrade findings: other_upgrade (trimmed to reliably detected setup and InitValue upgrade risks)", "expect_findings": true, "source": "vsoadmin"} diff --git a/docs/_data/code-review.json b/docs/_data/code-review.json new file mode 100644 index 000000000..f744d8bdb --- /dev/null +++ b/docs/_data/code-review.json @@ -0,0 +1,4 @@ +{ + "runs": [], + "aggregate": [] +} diff --git a/evaluator/scores.py b/evaluator/scores.py index f376ce206..d9c64028a 100644 --- a/evaluator/scores.py +++ b/evaluator/scores.py @@ -19,3 +19,23 @@ def __call__(self, *, metadata: dict, **kwargs: object) -> bool: class PostPatchPassedRate: def __call__(self, *, metadata: dict, **kwargs: object) -> bool: return metadata.get("post_patch_passed", False) + + +class PrecisionScore: + def __call__(self, *, metadata: dict, **kwargs: object) -> float: + return float(metadata.get("precision", 0.0)) + + +class RecallScore: + def __call__(self, *, metadata: dict, **kwargs: object) -> float: + return float(metadata.get("recall", 0.0)) + + +class F1Score: + def __call__(self, *, metadata: dict, **kwargs: object) -> float: + return float(metadata.get("f1", 0.0)) + + +class ValidReviewOutput: + def __call__(self, *, metadata: dict, **kwargs: object) -> bool: + return bool(metadata.get("valid_review_output", False)) diff --git a/scripts/BCBenchUtils.psm1 b/scripts/BCBenchUtils.psm1 index f9d4200c3..d435fd6da 100644 --- a/scripts/BCBenchUtils.psm1 +++ b/scripts/BCBenchUtils.psm1 @@ -490,13 +490,14 @@ function Get-BCBenchDatasetPath { param( [Parameter(Mandatory = $true)] # Category validation lives only here: every caller resolves the dataset path through this function, so there's no need to duplicate ValidateSet on each caller. - [ValidateSet("bug-fix", "test-generation")] + [ValidateSet("bug-fix", "test-generation", "code-review")] [string] $Category ) switch ($Category) { "bug-fix" { $DatasetName = "bcbench.jsonl" } "test-generation" { $DatasetName = "bcbench.jsonl" } + "code-review" { $DatasetName = "codereview.jsonl" } } [string] $projectRoot = Split-Path $PSScriptRoot -Parent diff --git a/src/bcbench/agent/copilot/metrics.py b/src/bcbench/agent/copilot/metrics.py index 0d5e3755d..f0ef554f6 100644 --- a/src/bcbench/agent/copilot/metrics.py +++ b/src/bcbench/agent/copilot/metrics.py @@ -34,7 +34,12 @@ def parse_metrics(output_lines: Sequence[str], session_log_path: Path | None = N output_lines: Lines from Copilot CLI stderr output session_log_path: Optional path to session log file for tool usage parsing - Expected output format (new, v1.0.2+): + Expected output format (newest, v1.0.61+): + Changes +23 -0 + AI Credits 58.4 (1m 14s) + Tokens ↑ 413.9k (368.1k cached) • ↓ 4.5k (500 reasoning) + + Previous output format (v1.0.2..v1.0.60): Changes +17 -0 Requests 0.33 Premium (1m 45s) Tokens ↑ 317.5k • ↓ 4.3k • 255.0k (cached) @@ -83,7 +88,7 @@ def parse_metrics(output_lines: Sequence[str], session_log_path: Path | None = N seconds = float(duration_match.group(2)) execution_time = minutes * 60 + seconds - # New format: "Requests 0.33 Premium (1m 45s)" — extract session time from parenthesized duration + # New format (v1.0.2+): "Requests 0.33 Premium (1m 45s)" — extract session time from parenthesized duration if execution_time is None: requests_match = re.search(r"Requests\s+[\d.]+\s+Premium\s+\((?:(\d+)m\s*)?(\d+(?:\.\d+)?)s\)", output_text) if requests_match: @@ -91,18 +96,33 @@ def parse_metrics(output_lines: Sequence[str], session_log_path: Path | None = N seconds = float(requests_match.group(2)) execution_time = minutes * 60 + seconds + # Newest format (v1.0.61+): "AI Credits 58.4 (1m 14s)" — "Requests N Premium" was renamed to "AI Credits N" + if execution_time is None: + credits_match = re.search(r"AI Credits\s+[\d.]+\s+\((?:(\d+)m\s*)?(\d+(?:\.\d+)?)s\)", output_text) + if credits_match: + minutes = int(credits_match.group(1)) if credits_match.group(1) else 0 + seconds = float(credits_match.group(2)) + execution_time = minutes * 60 + seconds + # Token usage — legacy format: "1.3m in, 11.6k out" usage_match = re.search(r"(\d+(?:\.\d+)?[km]?)\s+in,\s*(\d+(?:\.\d+)?[km]?)\s+out", output_text) if usage_match: prompt_tokens = _parse_token_count(usage_match.group(1)) completion_tokens = _parse_token_count(usage_match.group(2)) - # New format: "Tokens ↑ 317.5k • ↓ 4.3k • 255.0k (cached)" + # New format (v1.0.2+): "Tokens ↑ 317.5k • ↓ 4.3k • 255.0k (cached)" + # Newest format (v1.0.61+): "Tokens ↑ 413.9k (368.1k cached) • ↓ 4.5k (500 reasoning)" + # Use separate ↑ / ↓ lookups to tolerate inline "(N cached)" / "(N reasoning)" annotations + # between the two values. if prompt_tokens is None: - tokens_match = re.search(r"Tokens\s+[^\d]*(\d+(?:\.\d+)?[km]?)\s*[•·]\s*[^\d]*(\d+(?:\.\d+)?[km]?)", output_text) - if tokens_match: - prompt_tokens = _parse_token_count(tokens_match.group(1)) - completion_tokens = _parse_token_count(tokens_match.group(2)) + tokens_line_match = re.search(r"Tokens\s+([^\n]+)", output_text) + if tokens_line_match: + tokens_line = tokens_line_match.group(1) + up_match = re.search(r"\u2191\s*(\d+(?:\.\d+)?[km]?)", tokens_line) + down_match = re.search(r"\u2193\s*(\d+(?:\.\d+)?[km]?)", tokens_line) + if up_match and down_match: + prompt_tokens = _parse_token_count(up_match.group(1)) + completion_tokens = _parse_token_count(down_match.group(1)) if execution_time is not None or llm_duration is not None or prompt_tokens is not None or completion_tokens is not None or turn_count is not None: return AgentMetrics( diff --git a/src/bcbench/agent/shared/config.yaml b/src/bcbench/agent/shared/config.yaml index 32e31a833..522b643b2 100644 --- a/src/bcbench/agent/shared/config.yaml +++ b/src/bcbench/agent/shared/config.yaml @@ -50,6 +50,21 @@ prompt: {{task}} {% endif %} + code-review-template: | + /al-code-review + + Review ONLY the current working-tree AL file changes for this evaluation entry. + Use the working tree diff only (git diff HEAD), and focus on changed *.al files. + Do NOT review committed history or the HEAD commit, and do NOT compare commits (for example, do NOT use HEAD~1..HEAD or origin/main comparisons). + + Save findings to a file named "review.json" in the repository root. + The file must contain valid JSON with a top-level object named findings. + Each finding must include: filePath, lineNumber, severity, issue, recommendation, domain, suggestedCode. + Map the skill's findings into this schema as described in the skill's output-mapping section + (blocker->critical, major->high, minor->medium, info->low; from-sub-skill->domain). + Allowed severity values are: critical, high, medium, low. + If there are no findings, write an empty findings list. + # controls: # 1. whether to copy custom instructions from `src/bcbench/agent/shared/instructions//` # - Copilot: copies to repo/.github/ and renames AGENTS.md to copilot-instructions.md @@ -66,7 +81,7 @@ instructions: # - Copilot: copies to repo/.github/skills/ # - Claude: copies to repo/.claude/skills/ skills: - enabled: false + enabled: true # controls: # 1. whether to copy custom agents from `src/bcbench/agent/shared/instructions//agents/` diff --git a/src/bcbench/agent/shared/hooks/log_tool_usage.py b/src/bcbench/agent/shared/hooks/log_tool_usage.py new file mode 100644 index 000000000..1af8f6d32 --- /dev/null +++ b/src/bcbench/agent/shared/hooks/log_tool_usage.py @@ -0,0 +1,51 @@ +"""Copilot/Claude PreToolUse hook: log tool invocations to a JSONL file. + +Reads the hook payload from stdin and appends one JSON line per call to the +path in BCBENCH_TOOL_LOG. Used by both Copilot CLI (Linux runners) and Claude +hooks via the `bash` field of the hook command spec; the legacy .ps1 in this +directory mirrors the same behavior for the Windows `powershell` field. +""" + +import contextlib +import json +import os +import sys + + +def _extract_tool_name(payload: dict) -> str | None: + name = payload.get("tool_name") or payload.get("toolName") + if name != "lsp": + return name + + args = payload.get("toolArgs") or payload.get("tool_input") + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = None + if isinstance(args, dict) and (op := args.get("operation")): + return f"lsp:{op}" + return name + + +def main() -> None: + try: + payload = json.loads(sys.stdin.read() or "{}") + except json.JSONDecodeError: + return + + name = _extract_tool_name(payload) + log_path = os.environ.get("BCBENCH_TOOL_LOG") + if not name or not log_path: + return + + entry = {"tool_name": name, "timestamp": payload.get("timestamp", "")} + with open(log_path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +if __name__ == "__main__": + with contextlib.suppress(Exception): + # Never block tool execution — silently fail. + main() + sys.exit(0) diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/SKILL.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/SKILL.md new file mode 100644 index 000000000..83694fb57 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/SKILL.md @@ -0,0 +1,131 @@ +--- +name: al-code-review +description: 'Review AL code for Dynamics 365 Business Central by composing specialized review sub-skills (performance, security, privacy, upgrade, style), each backed by curated BCQuality knowledge files. Use when reviewing AL code changes or pull requests and producing structured findings.' +allowed-tools: Read, Glob, Grep, LSP +argument-hint: 'leave empty to run the full composed review across all domains' +--- + +# AL Code Review (composed super-skill / sub-skill review) + +Reviews AL code for Dynamics 365 Business Central using a **composition** pattern: a single +super-skill invokes five domain leaf sub-skills one at a time, each evaluating the diff against +its own curated **knowledge files**, then performs a cross-cutting self-review pass. The result +is mapped into this repository's `review.json` schema. + +## When to Use + +- Reviewing AL code changes or pull requests +- User asks for "code review", "review this AL code", or domain-specific analysis + +## Vendored layout — all paths are relative to THIS skill folder + +This skill is self-contained. The evaluator copies it to `.github/skills/al-code-review/`. +Every path referenced by the vendored framework files below resolves **relative to this skill +folder** (the directory containing this `SKILL.md`), not the repository root: + +``` +.github/skills/al-code-review/ + SKILL.md <- this entry point (you are here) + skills/ + read.md <- READ contract: how to read a knowledge file + do.md <- DO contract: the action-skill template + output schema + microsoft/ + skills/review/ + al-code-review.md <- the super-skill (composition orchestrator) + al-performance-review.md <- leaf sub-skill + al-privacy-review.md <- leaf sub-skill + al-security-review.md <- leaf sub-skill + al-style-review.md <- leaf sub-skill + al-upgrade-review.md <- leaf sub-skill + knowledge/ + performance/ privacy/ security/ style/ upgrade/ <- knowledge files (*.md) + samples (*.al) + knowledge-index.json <- discovery metadata for the knowledge corpus +``` + +This `SKILL.md` is the entry point. The BCQuality framework's own `entry.md` routing/dispatch +step is **not** vendored — its job is fulfilled here: you always dispatch the single super-skill +`microsoft/skills/review/al-code-review.md`. Ignore any reference to `entry.md` inside the +vendored contracts; wherever a contract says "the knowledge index BCQuality builds at the root of +the knowledge checkout", read the already-built `knowledge-index.json` in this skill folder. + +## Review Process + +1. **Read the contracts first.** Read `skills/read.md` (knowledge-file schema + frontmatter + matching) and `skills/do.md` (action-skill template, severity taxonomy, output contract, + agent-finding precision bar). +2. **Invoke the super-skill.** Read `microsoft/skills/review/al-code-review.md` and execute it. + It composes the five leaf sub-skills listed in its frontmatter `sub-skills`. +3. **Run each leaf one at a time (mandatory execution discipline).** For each of + `al-performance-review`, `al-security-review`, `al-privacy-review`, `al-upgrade-review`, + `al-style-review`: read the leaf file, read `knowledge-index.json`, select the candidate + articles for that leaf's `domain`, open only the worklisted knowledge files in full, and + evaluate the diff against their `## Best Practice` / `## Anti Pattern` sections. Do NOT + collapse multiple leaves into one shared scan — each leaf re-walks the diff independently. +4. **Cross-cutting self-review pass.** After all five leaves finish, perform the super-skill's + own self-review for defects that span domain boundaries. Hold agent findings to the precision + bar in `skills/do.md` (concrete, demonstrable, material; steelman first; when in doubt, omit). +5. **Map and write output.** Convert the rolled-up findings into this repository's `review.json` + schema (below) and save to a file named `review.json` in the repository root. + +## Scope of the diff + +Review ONLY the current working-tree AL file changes for this evaluation entry. Do NOT compare +commits (do NOT use `HEAD~1..HEAD` or `origin/main`). Use working-tree diff only (`git diff HEAD`) +and focus on changed `*.al` files. + +## Strict domain discipline + +Each leaf sub-skill owns exactly one domain and emits findings only within that domain. When a +leaf is active, judge every candidate by its **root cause**, not by surrounding names: a +non-translatable string in a method called `GenerateComplianceReport` is a `style` issue, not +`privacy`. If a candidate's root cause is outside the active leaf's domain, the active leaf stays +silent — the owning leaf (or the cross-cutting pass) will surface it. When in doubt, drop it. + +## Output mapping — BCQuality findings-report -> review.json + +The composed run produces a BCQuality findings-report (see `skills/do.md`). Map it into the +`review.json` schema this repository expects. The output file MUST contain valid JSON with a +top-level object named `findings`; each finding is an object with exactly these fields: + +| review.json field | Source in the BCQuality finding | Notes | +|-------------------|---------------------------------|-------| +| `filePath` | `location.file` | Repo-relative path of the changed `*.al` file. | +| `lineNumber` | `location.line` | 1-based line in the changed file. | +| `severity` | `severity` | Map: `blocker`->`critical`, `major`->`high`, `minor`->`medium`, `info`->`low`. | +| `issue` | `message` | Describe the concern. | +| `recommendation` | `message` / knowledge guidance | The concrete fix; draw from the knowledge file's `## Best Practice` when present. | +| `domain` | `from-sub-skill` | Map leaf id to domain (below). | +| `suggestedCode` | `suggested-code` | Literal replacement for the located lines; empty string if none. | + +Domain mapping for `from-sub-skill`: + +- `al-performance-review` -> `performance` +- `al-security-review` -> `security` +- `al-privacy-review` -> `privacy` +- `al-upgrade-review` -> `upgrade` +- `al-style-review` -> `style` +- `agent` (cross-cutting self-review finding) or a leaf's own agent finding -> map to the single + closest of the five domains above by the finding's root cause. + +Allowed `severity` values in `review.json` are exactly: `critical`, `high`, `medium`, `low`. +Drop the BCQuality-only fields (`id`, `references`, `confidence`, `from-sub-skill`, `sub-results`, +etc.) — they are not part of `review.json`. If there are no findings, write an empty `findings` +list. + +Example `review.json`: + +```json +{ + "findings": [ + { + "filePath": "src/Sales/PostingRoutines.Codeunit.al", + "lineNumber": 140, + "severity": "high", + "issue": "FindSet is called without a prior SetRange/SetFilter, forcing a full-table scan.", + "recommendation": "Apply SetRange/SetFilter to narrow the record set before FindSet, per the filter-before-find guidance.", + "domain": "performance", + "suggestedCode": "" + } + ] +} +``` diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/knowledge-index.json b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/knowledge-index.json new file mode 100644 index 000000000..8496b917c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/knowledge-index.json @@ -0,0 +1,3308 @@ +{ + "articles": [ + { + "path": "microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "report", + "addloadfields", + "onpredataitem", + "dataitem", + "partial-record", + "layout" + ], + "title": "In reports, declare the fields the layout needs with AddLoadFields", + "description": "Reports iterate dataitems on potentially large source tables and pipe rows into a layout." + }, + { + "path": "microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "admin-page", + "migration", + "wizard", + "hybrid", + "permissions", + "lower-severity" + ], + "title": "Admin and migration pages tolerate lower performance discipline", + "description": "Some pages run rarely and against small datasets, and the upstream guidance explicitly calls for treating them as lower severity." + }, + { + "path": "microsoft/knowledge/performance/apply-filters-before-iterating.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "setrange", + "setfilter", + "filter", + "loop", + "early", + "dataset" + ], + "title": "Apply SetRange/SetFilter before iterating, not as an if-test inside the loop", + "description": "A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at the database." + }, + { + "path": "microsoft/knowledge/performance/apply-guards-before-get.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "get", + "guard", + "early-exit", + "conditional", + "lookup", + "wasted-query" + ], + "title": "Apply early-exit guards before calling Get", + "description": "A `Get` (or any other database call) executed before a guard that may exit the procedure does a round-trip the procedure never uses." + }, + { + "path": "microsoft/knowledge/performance/avoid-commit-inside-loops.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "commit", + "loop", + "transaction", + "lock", + "checkpoint", + "codeunit-run" + ], + "title": "Do not Commit inside loops", + "description": "Commit ends the current write transaction." + }, + { + "path": "microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "n-plus-one", + "get", + "findfirst", + "loop", + "inner-lookup", + "large-table" + ], + "title": "Avoid Get / FindFirst inside a loop on a large inner table", + "description": "A `Get` or `FindFirst` against a different record inside a loop body produces one database round-trip per iteration \u2014 the classic N+1 pattern." + }, + { + "path": "microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "recordref", + "fieldref", + "hot-loop", + "typed-record", + "metadata" + ], + "title": "Avoid RecordRef / FieldRef in hot loops when a typed record fits", + "description": "`RecordRef` and `FieldRef` are slower than direct typed record access \u2014 the platform resolves the table and field at runtime instead of at compile time." + }, + { + "path": "microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "get", + "onaftergetrecord", + "redundant", + "page-trigger", + "rec", + "already-loaded" + ], + "title": "Do not Get the record the page already loaded", + "description": "A list or card page's `OnAfterGetRecord` trigger fires *because* the platform has already fetched a row into `Rec`." + }, + { + "path": "microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "confirm", + "strmenu", + "dialog", + "transaction", + "lock", + "user-interaction" + ], + "title": "Do not hold locks while waiting for the user", + "description": "A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a write transaction stalls the transaction \u2014 and therefore every lock it holds \u2014 until the user responds." + }, + { + "path": "microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "calcfields", + "calcsums", + "loop", + "flowfield", + "n-plus-one", + "aggregation" + ], + "title": "Use CalcSums to aggregate, not CalcFields inside a loop", + "description": "`CalcFields` materializes FlowField values for one record." + }, + { + "path": "microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "codeunit-run", + "atomic", + "rollback", + "transaction", + "sub-transaction", + "try-pattern", + "implicit-commit" + ], + "title": "Use Codeunit.Run to bound an atomic sub-operation", + "description": "`Codeunit.Run(ID)` is the AL-idiomatic way to run a unit of work as an atomic sub-operation with its own transactional boundary." + }, + { + "path": "microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "codeunit-run", + "commit", + "write-transaction", + "nesting", + "loop", + "runtime-error" + ], + "title": "Commit before Codeunit.Run when the caller already holds a write transaction", + "description": "`Codeunit.Run` cannot nest inside an open write transaction." + }, + { + "path": "microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "locktable", + "read-only", + "helper", + "contention", + "transaction" + ], + "title": "Do not LockTable in a read-only procedure", + "description": "`LockTable` is a transaction-wide signal: from the call onward, every read against that table in the same transaction acquires `UPDLOCK`." + }, + { + "path": "microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "page-trigger", + "onaftergetrecord", + "modify", + "display", + "scroll", + "db-write" + ], + "title": "Do not Modify inside OnAfterGetRecord", + "description": "A list page's `OnAfterGetRecord` fires once per visible row, every time the user scrolls, sorts, or refreshes." + }, + { + "path": "microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "sourcetabletemporary", + "api-page", + "temporary", + "persistent", + "in-memory" + ], + "title": "Removing SourceTableTemporary on an API page switches it from in-memory to persistent", + "description": "`SourceTableTemporary = true` on a page makes the page's record buffer in-memory only \u2014 reads and writes do not touch SQL." + }, + { + "path": "microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "findset", + "updlock", + "readisolation", + "locking", + "modify", + "obsolete-syntax" + ], + "title": "FindSet(true) applies UpdLock on the read; the two-parameter form is obsolete", + "description": "`FindSet()` and `FindSet(false)` are read-only \u2014 no locking." + }, + { + "path": "microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "flowfield", + "sumindexfields", + "sift", + "key", + "calcformula", + "aa0232" + ], + "title": "A FlowField needs a source-table key that covers its CalcFormula", + "description": "A FlowField is computed by SQL on demand." + }, + { + "path": "microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "event-subscriber", + "guard", + "db-call", + "frequently-fired", + "validate" + ], + "title": "Guard event subscribers with cheap checks before any database call", + "description": "Event subscribers fire on every event matching their signature \u2014 for `OnAfterValidateEvent` on a hot field like `Sales Line.Quantity`, that is every quantity edit by every user." + }, + { + "path": "microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "maintainsqlindex", + "key", + "sift", + "flowfield", + "sum", + "count", + "table-scan" + ], + "title": "MaintainSQLIndex = false on a key disables SIFT for FlowFields that depend on it", + "description": "`MaintainSQLIndex = false` on a key tells the platform not to materialize that key as a SQL index." + }, + { + "path": "microsoft/knowledge/performance/pair-findset-with-next-loop.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "findset", + "findfirst", + "findlast", + "get", + "next", + "repeat-until", + "aa0181", + "aa0233" + ], + "title": "Use FindSet with repeat..Next; do not pair FindFirst/FindLast/Get with Next", + "description": "Two CodeCop rules carve out the loop pattern." + }, + { + "path": "microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "insert", + "modify", + "delete", + "trigger", + "run-trigger", + "write-parameters" + ], + "title": "Pass false to Insert/Modify/Delete when the table triggers do not need to fire", + "description": "`Insert(true)`, `Modify(true)`, and `Delete(true)` run the table's `OnInsert`/`OnModify`/`OnDelete` trigger; the `(false)` form skips it." + }, + { + "path": "microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "dictionary", + "temporary-table", + "lookup", + "o-of-1", + "key-lookup" + ], + "title": "Prefer a Dictionary over a temporary table for pure lookups", + "description": "A temporary table supports a full record API \u2014 filters, iteration, multi-field keys \u2014 but a pure key\u2192value lookup pays for plumbing it does not use." + }, + { + "path": "microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "modifyall", + "deleteall", + "bulk", + "loop", + "modify", + "set-based" + ], + "title": "Use ModifyAll / DeleteAll instead of per-row Modify / Delete in a loop", + "description": "`ModifyAll` and `DeleteAll` are the bulk APIs." + }, + { + "path": "microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "readisolation", + "locktable", + "updlock", + "read-only", + "transaction-scope", + "isolation-level" + ], + "title": "Prefer ReadIsolation over LockTable for read-only scenarios", + "description": "`LockTable` and `ReadIsolation` solve different problems with different blast radii." + }, + { + "path": "microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "table-size", + "hot-table", + "ledger-entry", + "item", + "customer", + "sales-line", + "scale" + ], + "title": "Production-scale tables warrant concrete performance analysis", + "description": "Some Business Central tables routinely reach sizes where access patterns matter much more than they do on a generic table." + }, + { + "path": "microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "setcurrentkey", + "key", + "index", + "filter", + "sort" + ], + "title": "Pick a key whose fields cover the filter and sort with SetCurrentKey", + "description": "The platform chooses a key for each record access." + }, + { + "path": "microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "singleton", + "setup-table", + "sales-receivables-setup", + "general-ledger-setup", + "setloadfields", + "bounded" + ], + "title": "Singleton setup tables hold one row; access-pattern optimization is wasted", + "description": "Business Central setup tables \u2014 `Sales & Receivables Setup`, `General Ledger Setup`, `FA Setup`, `Purchases & Payables Setup`, and the broader pattern of any `*Setup` table \u2014 hold at most one record per company." + }, + { + "path": "microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "temporary-table", + "in-memory", + "findset", + "findfirst", + "get", + "no-db-cost" + ], + "title": "Temporary tables are in-memory; access-pattern rules do not apply", + "description": "A record declared `Temporary` (or a page with `SourceTableTemporary = true`) lives entirely in memory; reads and writes never reach SQL." + }, + { + "path": "microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "modifyall", + "deleteall", + "regression", + "triggers", + "media", + "getglobaltabletriggermask", + "subscriber" + ], + "title": "Triggers, subscribers, and media fields can silently regress ModifyAll / DeleteAll", + "description": "`ModifyAll` and `DeleteAll` usually execute as single SQL statements, but the platform falls back to a fetch-then-row-by-row loop under specific conditions." + }, + { + "path": "microsoft/knowledge/performance/understand-implicit-transaction-boundary.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "commit", + "transaction", + "implicit-commit", + "write-transaction", + "runtime", + "boundary" + ], + "title": "AL auto-commits when code execution completes", + "description": "In AL, write transactions are managed by the runtime, not by the developer." + }, + { + "path": "microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "get", + "findfirst", + "primary-key", + "setrange", + "lookup" + ], + "title": "Use Get when the full primary key is known; FindFirst is the wrong tool", + "description": "`Get(...)` is the direct primary-key lookup." + }, + { + "path": "microsoft/knowledge/performance/use-isempty-for-existence-check.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "isempty", + "count", + "findfirst", + "existence-check", + "exists" + ], + "title": "Use IsEmpty for existence checks, not Count() or FindFirst()", + "description": "When the caller only needs to know whether any row matches a filter, `IsEmpty()` is the API designed for the question." + }, + { + "path": "microsoft/knowledge/performance/use-setloadfields-for-partial-records.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "setloadfields", + "partial-record", + "normal-field", + "flowfield", + "get", + "findset" + ], + "title": "Use SetLoadFields to load only the fields the code reads", + "description": "`SetLoadFields(...)` declares the subset of normal fields the next read should materialize, \"reducing data read and transfer thereby improving performance significantly.\" Per the upstream guidance, \"the gains scale with the amount of rows read, so for loops that read many rows `SetLoadFields` is even more important.\" Primary-key fields, `SystemId`, and system audit fields are loaded automatically, \"and fields that are filtered on are also automatically included\" \u2014 those do not need to appear in the list." + }, + { + "path": "microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "textbuilder", + "string-concatenation", + "loop", + "append", + "immutable-text" + ], + "title": "Use TextBuilder for many string concatenations, especially inside loops", + "description": "AL `Text` is immutable: each `Result += Piece;` allocates a new buffer and copies the previous content into it." + }, + { + "path": "microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md", + "layer": "microsoft", + "domain": "performance", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "try-function", + "try-method", + "error-handling", + "rollback", + "atomic", + "exception", + "get-last-error", + "session-buffer" + ], + "title": "Use [TryFunction] for error catching, Codeunit.Run for atomic rollback", + "description": "`[TryFunction]` annotates a method so that errors raised inside it can be caught by the caller instead of propagating." + }, + { + "path": "microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "strsubstno", + "error", + "telemetry", + "pii", + "prebuild", + "text-variable" + ], + "title": "Do not pre-build an error string with `StrSubstNo` before calling `Error()`", + "description": "`StrSubstNo` returns a plain `Text` value with the substitutions already performed." + }, + { + "path": "microsoft/knowledge/privacy/data-classification-is-table-field-property.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "data-classification", + "page-field", + "table-field", + "api-page", + "card-page", + "list-page" + ], + "title": "DataClassification is a table-field property, not a page-field property", + "description": "`DataClassification` is defined on table fields." + }, + { + "path": "microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "data-classification", + "pii", + "gdpr", + "customer-content", + "table-field", + "under-classified" + ], + "title": "DataClassification is required on table fields containing sensitive data", + "description": "`DataClassification` is the AL property that tells the platform what kind of data a table field stores so that telemetry, GDPR data-subject requests, and the platform's audit surfaces can treat it correctly." + }, + { + "path": "microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "error", + "strsubstno", + "direct-substitution", + "telemetry", + "classification", + "label" + ], + "title": "`Error()` with direct substitution parameters is always safe for telemetry", + "description": "When `Error()` is called with a format string and direct substitution parameters (`%1`, `%2`, \u2026), the BC platform intercepts the call, inspects each parameter individually, and applies the `DataClassification` of the source field \u2014 stripping or masking sensitive data before writing the message to telemetry." + }, + { + "path": "microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "error", + "message", + "confirm", + "notification", + "telemetry", + "logging", + "ui-dialog" + ], + "title": "Only `Error()` is logged to telemetry \u2014 `Message`, `Confirm`, `Notification` are not", + "description": "The privacy concern with dialog APIs is not what the signed-in user sees on the screen \u2014 it is what the platform writes to telemetry." + }, + { + "path": "microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "feature-telemetry", + "customdimensions", + "logusage", + "loguptake", + "logerror", + "pii", + "euii", + "eupi" + ], + "title": "`FeatureTelemetry` `CustomDimensions` follow the same privacy rules as `Session.LogMessage`", + "description": "`Codeunit \"Feature Telemetry\"` is the second telemetry surface in AL." + }, + { + "path": "microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "flowfield", + "flowfilter", + "data-classification", + "systemmetadata", + "calculated" + ], + "title": "FlowFields and FlowFilters are classified `SystemMetadata` automatically", + "description": "`FlowField` and `FlowFilter` are not stored fields \u2014 a FlowField is computed from a CalcFormula at read time and a FlowFilter is a transient filter scoped to the record variable." + }, + { + "path": "microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "getlasterrortext", + "error", + "strsubstno", + "telemetry", + "customer-data", + "attachment" + ], + "title": "Treat `GetLastErrorText()` as potential customer content", + "description": "`GetLastErrorText()` returns the text of the last error that occurred in the context where it is called." + }, + { + "path": "microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "in-memory", + "dictionary", + "list", + "temporary-table", + "variable", + "memory-dump" + ], + "title": "In-memory variables, dictionaries, lists and temporary tables are not a privacy concern", + "description": "AL runs in a managed server environment." + }, + { + "path": "microsoft/knowledge/privacy/migration-destination-classification.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "data-migration", + "hybridsl", + "hybridgp", + "hybridbc", + "destination-classification", + "ssn", + "tin" + ], + "title": "In data migration code, classify the destination \u2014 not the migration itself", + "description": "Migration codeunits such as `HybridSL`, `HybridGP`, and `HybridBC` exist to copy sensitive data \u2014 TINs, Federal IDs, social security numbers, financial records \u2014 from a source system into Business Central." + }, + { + "path": "microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "telemetry", + "session-logmessage", + "strsubstno", + "pii", + "customer-data", + "employee-code", + "filename" + ], + "title": "Do not embed customer data in the telemetry message text", + "description": "`Session.LogMessage`'s message argument is a plain `Text`." + }, + { + "path": "microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "page", + "card", + "list", + "api", + "listpart", + "permission-system", + "display", + "ui-dialog" + ], + "title": "Displaying fields on a page (or in a UI dialog) is not a privacy concern", + "description": "Every page in Business Central \u2014 `Card`, `List`, `API`, `ListPart`, request pages \u2014 renders data to an authenticated user who has been granted permission to see it." + }, + { + "path": "microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "privacy-notice", + "consent", + "http-client", + "outgoing-request", + "external-service", + "getprivacynoticeapprovalstate" + ], + "title": "Outgoing requests to external services require a Privacy Notice consent check", + "description": "Business Central ships a built-in Privacy Notice framework that the admin uses to grant or withhold per-integration consent for sending data to external services." + }, + { + "path": "microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "privacy-notice-registrations", + "integration", + "register", + "exchange", + "onedrive", + "teams", + "notice-id" + ], + "title": "Register every new external integration with `Privacy Notice Registrations`", + "description": "`Codeunit \"Privacy Notice Registrations\"` is the registry of integrations whose consent state the platform tracks." + }, + { + "path": "microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "tobeclassified", + "data-classification", + "release", + "appsource", + "development" + ], + "title": "Resolve every `ToBeClassified` before release", + "description": "`DataClassification = ToBeClassified` is the sentinel value the AL compiler accepts while a developer has not yet decided what a new field actually stores." + }, + { + "path": "microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "session-logmessage", + "telemetry", + "data-classification", + "verbosity", + "telemetry-scope" + ], + "title": "Every `Session.LogMessage` call must specify `DataClassification`", + "description": "`Session.LogMessage` writes a record to the telemetry pipeline." + }, + { + "path": "microsoft/knowledge/privacy/table-level-data-classification-cascades.md", + "layer": "microsoft", + "domain": "privacy", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "data-classification", + "table-level", + "inheritance", + "override", + "cascading" + ], + "title": "Table-level DataClassification cascades to every field unless overridden", + "description": "`DataClassification` may be set at the table level." + }, + { + "path": "microsoft/knowledge/security/al-has-no-built-in-htmlencode.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "html", + "xss", + "encoding", + "htmlencode", + "injection", + "email" + ], + "title": "AL has no built-in HtmlEncode \u2014 encode HTML output by hand or avoid it", + "description": "AL does not ship a built-in `HtmlEncode` (or equivalent) function." + }, + { + "path": "microsoft/knowledge/security/commitbehavior-attribute-scopes-explicit-commits.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "commit-behavior", + "attribute", + "integration-event", + "subscriber", + "commit", + "atomic" + ], + "title": "Use [CommitBehavior] to protect an atomic operation from third-party commits", + "description": "`[CommitBehavior(CommitBehavior::Ignore)]` and `[CommitBehavior(CommitBehavior::Error)]` are method-level attributes that restrict what an explicit `Commit()` does inside the annotated method's scope: `Ignore` silently discards the call; `Error` raises a runtime error." + }, + { + "path": "microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "getlasterrortext", + "error-text", + "classification", + "privacy", + "review-scope" + ], + "title": "Storing GetLastErrorText() in table fields is a privacy finding, not a security finding", + "description": "It is tempting to flag any code that calls `GetLastErrorText()` and writes the result into a table field (or displays it to end users) as a security issue, on the assumption that the error text might leak credentials or system internals." + }, + { + "path": "microsoft/knowledge/security/indirect-permissions-for-elevated-access.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "permissionset", + "indirect-permissions", + "ri", + "ii", + "mi", + "di", + "code-mediated" + ], + "title": "Use indirect permissions when access must be code-mediated", + "description": "In a `permissionset`, uppercase letters (`R`, `I`, `M`, `D`) grant **direct** permissions: the assignee can read, insert, modify, or delete the table data through any UI or API surface." + }, + { + "path": "microsoft/knowledge/security/inherent-permissions-minimal-grant.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "inherentpermissions", + "inherententitlements", + "attribute", + "least-privilege", + "procedure" + ], + "title": "Grant the minimum InherentPermissions a procedure needs", + "description": "`[InherentPermissions(PermissionObjectType::..., ...)]` and `[InherentEntitlements(Entitlement::...)]` are method-level attributes that let a procedure perform an operation on the listed object even when the caller's permission set does not allow it." + }, + { + "path": "microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "integrationevent", + "eventsubscriber", + "secrets", + "credentials", + "publisher" + ], + "title": "Do not pass credentials or secrets through IntegrationEvent parameters", + "description": "`[IntegrationEvent]` publishes a hook that any extension can subscribe to." + }, + { + "path": "microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "integrationevent", + "var", + "guard", + "ishandled", + "bypass", + "security-check" + ], + "title": "Do not expose security guards as `var` parameters on IntegrationEvent", + "description": "A `var` parameter on an `[IntegrationEvent]` is a mutable hook: any subscriber can overwrite the value and the publisher will see the new value when control returns." + }, + { + "path": "microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "isolatedstorage", + "local", + "internal", + "public", + "getter", + "setter", + "encapsulation" + ], + "title": "Procedures that read or write IsolatedStorage must not be public", + "description": "`IsolatedStorage` partitions its data by extension: values written by one extension are unreadable to another." + }, + { + "path": "microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "isolatedstorage", + "datascope", + "module", + "company", + "user", + "scope" + ], + "title": "Pick the right IsolatedStorage DataScope for the secret's lifetime", + "description": "`IsolatedStorage` read and write methods take a `DataScope` parameter that decides which slice of storage the value belongs to." + }, + { + "path": "microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "isolatedstorage", + "setencrypted", + "encryption", + "secret", + "storage" + ], + "title": "Prefer IsolatedStorage.SetEncrypted over Set for sensitive values", + "description": "`IsolatedStorage` exposes two write entry points: `Set` stores the value as-is, and `SetEncrypted` stores it encrypted at rest." + }, + { + "path": "microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "nondebuggable", + "attribute", + "secrettext", + "unwrap", + "debugger" + ], + "title": "Mark procedures that call SecretText.Unwrap() as [NonDebuggable]", + "description": "`SecretText` transit \u2014 assignment, parameter passing, and return values \u2014 is auto-protected: the debugger sees a redacted placeholder, not the value." + }, + { + "path": "microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "permissionset", + "wildcard", + "rimd", + "tabledata", + "least-privilege" + ], + "title": "Avoid wildcard grants in permission sets", + "description": "A `permissionset` object can grant access object-by-object or with the `*` wildcard." + }, + { + "path": "microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "recordref", + "open", + "public", + "system-table", + "scope-onprem", + "confused-deputy", + "saas" + ], + "title": "Procedures that RecordRef.Open a caller-provided table must not be public", + "description": "When a codeunit holds permission to system tables \u2014 directly, via a permission set granted at install, or via `[InherentPermissions]` \u2014 and exposes a public procedure that accepts a table number (or a `RecordId`, from which the table number is derived) and calls `RecordRef.Open` on it, the procedure becomes a confused deputy." + }, + { + "path": "microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "secretstrsubstno", + "secrettext", + "strsubstno", + "format", + "compose" + ], + "title": "Use SecretStrSubstNo to compose strings that contain secrets", + "description": "`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`." + }, + { + "path": "microsoft/knowledge/security/secrettext-for-credentials.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "secrettext", + "credentials", + "api-key", + "token", + "debugger", + "unwrap" + ], + "title": "Use SecretText for credentials, API keys, and tokens", + "description": "`SecretText` is the AL data type for values that should never appear in a debugger session, in a log, or in a variable watch." + }, + { + "path": "microsoft/knowledge/security/secrettext-with-httpclient.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "secrettext", + "httpclient", + "setsecretrequesturi", + "containssecret", + "headers", + "http" + ], + "title": "Use the SecretText-aware HttpClient surface for secrets in requests", + "description": "`HttpClient` and its companion types expose a parallel surface that accepts `SecretText` instead of `Text`, so that secret URIs, secret headers, and secret request bodies never round-trip through plain text." + }, + { + "path": "microsoft/knowledge/security/validate-user-configurable-urls.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "ssrf", + "uri", + "url-validation", + "areurishavesamehost", + "isvaliduripattern", + "httpclient" + ], + "title": "Validate URLs that come from table fields before calling them", + "description": "A URL stored in a table field is user-configurable: anyone with write access to the row can change it." + }, + { + "path": "microsoft/knowledge/security/validatetablerelation-false-on-user-input.md", + "layer": "microsoft", + "domain": "security", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "validatetablerelation", + "tablerelation", + "field", + "validation", + "input" + ], + "title": "Do not set ValidateTableRelation = false on user-editable fields", + "description": "`TableRelation` on a field declares that the field's value must exist in another table; the platform validates the value on entry and on `Validate`." + }, + { + "path": "microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "abouttitle", + "abouttext", + "teaching-tip", + "onboarding", + "page" + ], + "title": "Use `AboutTitle` and `AboutText` to surface teaching tips on top-level pages", + "description": "The `AboutTitle` and `AboutText` properties on a page render a teaching tip \u2014 an onboarding callout that appears the first time a user opens the page." + }, + { + "path": "microsoft/knowledge/style/api-page-camelcase-properties.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "api-page", + "camelcase", + "apipublisher", + "apigroup", + "entityname", + "entitysetname" + ], + "title": "API pages use camelCase, alphanumeric-only values for API properties", + "description": "API pages \u2014 pages declared with `PageType = API` \u2014 surface as OData/JSON endpoints." + }, + { + "path": "microsoft/knowledge/style/api-page-delayedinsert-true.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "api-page", + "delayedinsert", + "insert-trigger", + "validation" + ], + "title": "Set `DelayedInsert = true` on API pages", + "description": "On a normal page, `DelayedInsert = false` is the default: the record is inserted into the table as soon as the user enters the first field, and subsequent fields are written via `Modify` triggers." + }, + { + "path": "microsoft/knowledge/style/api-page-entity-naming-singular-plural.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "api-page", + "entityname", + "entitysetname", + "singular", + "plural" + ], + "title": "`EntityName` is singular; `EntitySetName` is plural", + "description": "`EntityName` and `EntitySetName` on an API page are the two halves of the OData naming contract." + }, + { + "path": "microsoft/knowledge/style/api-page-version-format.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "api-page", + "apiversion", + "version", + "format", + "beta" + ], + "title": "`APIVersion` must follow the pattern `vX.Y` (or `beta`)", + "description": "The `APIVersion` property on an API page is part of the public URL path: `/api////`." + }, + { + "path": "microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "begin", + "end", + "compound-statement", + "aa0005", + "codecop", + "formatting" + ], + "title": "`begin` goes on the same line as `then`, `else`, or `do` (CodeCop AA0005)", + "description": "When a compound block follows `then`, `else`, or `do`, the `begin` keyword must sit on the same line as the preceding keyword, separated by exactly one space." + }, + { + "path": "microsoft/knowledge/style/block-keywords-start-new-line.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "block-keyword", + "end", + "if", + "repeat", + "until", + "for", + "while", + "case", + "aa0018" + ], + "title": "Block keywords (`end`, `if`, `repeat`, `until`, `for`, `while`, `case`) start a new line (CodeCop AA0018)", + "description": "CodeCop AA0018 requires that the block-introducing keywords `if`, `repeat`, `until`, `for`, `while`, `case`, and the block-terminating keyword `end` always start a new line." + }, + { + "path": "microsoft/knowledge/style/caption-required-on-page-fields.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "caption", + "page-field", + "aa0225", + "aa0226", + "codecop", + "captionclass" + ], + "title": "Every page field needs a `Caption` (CodeCop AA0225/AA0226)", + "description": "CodeCop AA0225 and AA0226 require every field control to expose a `Caption` property, separately from the field's source name." + }, + { + "path": "microsoft/knowledge/style/case-action-on-line-after-possibility.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "case", + "statement", + "formatting", + "possibility", + "action", + "line-break" + ], + "title": "`case` action goes on the line after the possibility", + "description": "In an AL `case` statement, the action for each label is written on the line that follows the label, not on the same line." + }, + { + "path": "microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "error", + "strsubstno", + "label", + "parameters", + "concatenation", + "aa0231" + ], + "title": "Pass parameters directly to `Error()`, do not wrap with `StrSubstNo`", + "description": "`Error()` accepts a format string and a variable number of arguments \u2014 `Error(SomeLabelErr, Arg1, Arg2)`." + }, + { + "path": "microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "event-subscriber", + "parameter-name", + "publisher", + "signature", + "eventsubscriber" + ], + "title": "Event subscriber parameter names must match the publisher signature", + "description": "In AL, an `[EventSubscriber]` procedure is bound to its publisher by event name and parameter list." + }, + { + "path": "microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "fieldcaption", + "fieldname", + "tablecaption", + "tablename", + "translation", + "message", + "error" + ], + "title": "Use FieldCaption/TableCaption (not FieldName/TableName) in user-facing text", + "description": "`FieldName` and `TableName` return the developer-facing identifier of a field or table \u2014 a fixed English string used in metadata and in code." + }, + { + "path": "microsoft/knowledge/style/file-name-object-type-pattern.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "file-name", + "object-type", + "suffix", + "naming-convention" + ], + "title": "Name AL source files `..al`", + "description": "Each AL source file holds a single object, and the file name is expected to be of the form `..al` \u2014 `CustomerCard.Page.al`, `PostSalesInvoice.Codeunit.al`, `NoSeriesTests.Codeunit.al`, `SalesHeader.TableExt.al`." + }, + { + "path": "microsoft/knowledge/style/function-call-parentheses-required.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "parentheses", + "function-call", + "method-call", + "aa0008", + "codecop" + ], + "title": "Always write parentheses on procedure calls (CodeCop AA0008)", + "description": "AL allows a parameterless procedure to be called without parentheses \u2014 `Customer.Init` instead of `Customer.Init()` \u2014 and the result is syntactically identical at runtime." + }, + { + "path": "microsoft/knowledge/style/label-comment-explains-placeholders.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "label", + "comment", + "placeholder", + "strsubstno", + "translation", + "aa0470" + ], + "title": "Document each Label placeholder with the Comment parameter", + "description": "`Label` and `TextConst` strings that contain placeholders (`%1`, `%2`, \u2026) need a `Comment` parameter that names what each placeholder is." + }, + { + "path": "microsoft/knowledge/style/label-locked-for-non-translatable.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "label", + "locked", + "translation", + "token", + "url", + "json", + "xml" + ], + "title": "Set `Locked = true` on Labels that must not be translated", + "description": "A `Label` is by default surfaced to translators and rewritten per locale." + }, + { + "path": "microsoft/knowledge/style/label-suffix-approved-list.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "label", + "textconst", + "suffix", + "aa0074", + "codecop", + "msg", + "err", + "qst", + "lbl", + "tok" + ], + "title": "Use approved suffixes on Label and TextConst names (CodeCop AA0074)", + "description": "CodeCop AA0074 flags `Label` and `TextConst` identifiers that do not end with an approved usage suffix." + }, + { + "path": "microsoft/knowledge/style/labels-declared-at-object-scope.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "label", + "scope", + "procedure", + "translation", + "localization", + "xliff" + ], + "title": "Declare Labels at object scope, not inside procedure `var` blocks", + "description": "`Label` is the AL declaration that participates in the translation pipeline: the build extracts every Label declared in an object into the `.xlf` file shipped to translators, and the runtime substitutes the localized value when the object is loaded." + }, + { + "path": "microsoft/knowledge/style/lowercase-reserved-keywords.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "reserved-keyword", + "lowercase", + "aa0241", + "codecop", + "if", + "then", + "begin" + ], + "title": "Reserved keywords are written in lowercase (CodeCop AA0241)", + "description": "CodeCop AA0241 requires reserved AL keywords \u2014 `if`, `then`, `else`, `begin`, `end`, `var`, `procedure`, `local`, `internal`, `for`, `while`, `repeat`, `until`, `case`, `of`, `do`, `not`, `and`, `or`, `exit`, `break`, `skip`, `quit`, and the rest \u2014 to be lowercase." + }, + { + "path": "microsoft/knowledge/style/named-invocations-not-object-ids.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "page", + "report", + "codeunit", + "runmodal", + "run", + "object-id", + "named-invocation" + ], + "title": "Call objects by name, not by numeric ID", + "description": "`Page.RunModal`, `Report.Run`, `Codeunit.Run`, and the `Page::`, `Report::`, `Codeunit::`, `Table::`, `XmlPort::` selectors accept either a numeric ID or a named alias." + }, + { + "path": "microsoft/knowledge/style/no-begin-end-around-single-statement.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "begin", + "end", + "single-statement", + "aa0013", + "codecop", + "compound" + ], + "title": "Do not wrap a single statement in `begin \u2026 end` (CodeCop AA0013)", + "description": "CodeCop AA0013 flags `begin \u2026 end` blocks that contain exactly one statement." + }, + { + "path": "microsoft/knowledge/style/no-else-after-terminating-statement.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "else", + "exit", + "break", + "skip", + "quit", + "error", + "terminating", + "control-flow" + ], + "title": "Omit `else` when the `then` branch ends with `exit`, `break`, `skip`, `quit`, or `error`", + "description": "When the `then` branch of an `if` ends in a terminating statement \u2014 `exit`, `break`, `skip`, `quit`, or `error` \u2014 the `else` branch becomes the natural fall-through." + }, + { + "path": "microsoft/knowledge/style/no-space-before-method-parenthesis.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "spacing", + "parenthesis", + "method-call", + "aa0002", + "codecop" + ], + "title": "No space between a method name and its opening parenthesis (CodeCop AA0002)", + "description": "CodeCop AA0002 forbids whitespace between a procedure/method name and its `(`." + }, + { + "path": "microsoft/knowledge/style/object-name-30-char-limit.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "object-name", + "length", + "prefix", + "affix", + "30-characters", + "appsource" + ], + "title": "Keep object names within the 30-character platform limit", + "description": "Business Central object names \u2014 for tables, pages, codeunits, reports, queries, XML ports, enums, and permission sets \u2014 are limited to 30 characters in total." + }, + { + "path": "microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "optioncaption", + "option", + "member-count", + "aa0221", + "aa0223", + "aa0224" + ], + "title": "Option fields need `OptionCaption`, and its element count must match `OptionMembers` (CodeCop AA0221/AA0223/AA0224)", + "description": "CodeCop AA0221 requires an `OptionCaption` on every option-type field that is not sourced from a table column (table-sourced option fields inherit the captions of the underlying field)." + }, + { + "path": "microsoft/knowledge/style/page-name-must-match-source-table.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "page-name", + "source-table", + "misleading", + "naming" + ], + "title": "A page or view name must describe the table it shows", + "description": "A page (or filtered page View) whose name references one entity but whose `SourceTable` is a different entity misleads every consumer of the object's metadata." + }, + { + "path": "microsoft/knowledge/style/single-space-after-not-operator.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "spacing", + "not", + "operator", + "aa0003", + "codecop" + ], + "title": "Exactly one space between `not` and its argument (CodeCop AA0003)", + "description": "CodeCop AA0003 requires exactly one space between the `not` operator and the expression it negates." + }, + { + "path": "microsoft/knowledge/style/single-space-around-binary-operators.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "spacing", + "binary-operator", + "aa0001", + "codecop", + "formatting" + ], + "title": "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." + }, + { + "path": "microsoft/knowledge/style/telemetry-event-id-stable-unique.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "telemetry", + "logmessage", + "event-id", + "sessionlogmessage", + "observability" + ], + "title": "Telemetry event IDs must be stable, unique, and non-placeholder", + "description": "The first parameter of `Session.LogMessage` is the **event ID**." + }, + { + "path": "microsoft/knowledge/style/temporary-variable-temp-prefix.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "temporary", + "temp", + "prefix", + "record-variable", + "naming" + ], + "title": "Prefix temporary record variables with `Temp`", + "description": "A `Record` variable declared with the `temporary` modifier behaves nothing like a normal record variable: it never touches the database, holds rows only for the lifetime of the variable, and is not visible to filters or queries on the underlying table." + }, + { + "path": "microsoft/knowledge/style/this-keyword-in-codeunits.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "this", + "codeunit", + "self-reference", + "aa0248", + "scope" + ], + "title": "Use the `this` keyword for self-reference inside codeunits (CodeCop AA0248)", + "description": "CodeCop AA0248 recommends prefixing self-references inside a codeunit with `this`." + }, + { + "path": "microsoft/knowledge/style/tooltip-required-on-page-fields.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "tooltip", + "page-field", + "aa0218", + "codecop", + "accessibility", + "specifies" + ], + "title": "Every page field needs a `ToolTip` (CodeCop AA0218)", + "description": "CodeCop AA0218 requires a non-empty `ToolTip` property on every field control on a page." + }, + { + "path": "microsoft/knowledge/style/variable-declaration-order-by-type.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "variable-declaration", + "order", + "var", + "complex-types", + "aa0021" + ], + "title": "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." + }, + { + "path": "microsoft/knowledge/style/variable-name-must-not-shadow.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "variable-name", + "shadow", + "conflict", + "aa0198", + "aa0202", + "aa0204", + "codecop" + ], + "title": "Local variable names must not shadow globals, fields, methods, or actions (CodeCop AA0198/AA0202/AA0204)", + "description": "Three CodeCop rules \u2014 AA0198, AA0202, AA0204 \u2014 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." + }, + { + "path": "microsoft/knowledge/style/xmldoc-for-public-library-procedures.md", + "layer": "microsoft", + "domain": "style", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "xmldoc", + "summary", + "param", + "returns", + "public-procedure", + "documentation" + ], + "title": "Add XML documentation to public procedures on library/API codeunits", + "description": "XML documentation comments (`/// \u2026`, `/// \u2026`, `/// \u2026`) are expected on procedures that form the public surface of a library \u2014 codeunits intended to be called from outside the current app: System App modules, AppSource library codeunits, `Access = Public` codeunits exposed for extension." + }, + { + "path": "microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "primary-key", + "field-type", + "breaking-change", + "integer-to-biginteger", + "existing-data" + ], + "title": "Primary-key and field-type changes are safe only on tables without existing data", + "description": "Primary-key changes and field-type changes (for example widening `Integer` to `BigInteger`) rewrite the on-disk layout of every row in the table." + }, + { + "path": "microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "datatransfer", + "large-dataset", + "bulk-update", + "modifyall", + "copyfields", + "new-field" + ], + "title": "Use `DataTransfer` for bulk updates on large tables", + "description": "Tables that can contain more than 300,000 records, and any newly added field on an existing table, should be initialized with `DataTransfer` rather than a `repeat ..." + }, + { + "path": "microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "datatransfer", + "validate-trigger", + "event-subscriber", + "side-effects", + "business-logic" + ], + "title": "`DataTransfer` does not fire validation triggers or event subscribers", + "description": "`DataTransfer` writes directly at the database layer." + }, + { + "path": "microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "error-handling", + "telemetry", + "session-logmessage", + "blocking", + "graceful" + ], + "title": "Log telemetry; do not raise errors that block the upgrade", + "description": "When upgrade code encounters unexpected data \u2014 a record it expected to find, a relationship it assumed to be intact \u2014 the response is to log telemetry and continue, not to raise an error." + }, + { + "path": "microsoft/knowledge/upgrade/enum-values-additive-at-end.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "enum", + "ordinal", + "additive", + "append", + "backward-compatible", + "breaking-change" + ], + "title": "Add new enum values only at the end", + "description": "An AL `enum` is a fixed list of ordinal-named values." + }, + { + "path": "microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "dataversion", + "first-install", + "on-install-app-per-company", + "moduleinfo", + "zero-version" + ], + "title": "Detect first install with `DataVersion() = Version.Create('0.0.0.0')`", + "description": "On the first install of an extension on a tenant the platform records a zero data version: `AppInfo.DataVersion()` returns `Version.Create('0.0.0.0')`." + }, + { + "path": "microsoft/knowledge/upgrade/guard-database-reads.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "get", + "findset", + "findlast", + "guard", + "if-then", + "runtime-error" + ], + "title": "Guard every database read in upgrade code with `if`", + "description": "Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgradePerCompany` / `OnUpgradePerDatabase`), an unguarded `Record.Get`, `Record.FindSet`, or `Record.FindLast` raises a runtime error when the row or set is missing." + }, + { + "path": "microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "hybrid-migration", + "hybrid-bc14", + "hybrid-sl", + "hybrid-gp", + "hybrid-base-deployment", + "one-time-migration" + ], + "title": "Hybrid migration codeunits are not standard upgrade codeunits", + "description": "Codeunits like `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` implement one-time migration paths from a specific source system into Business Central." + }, + { + "path": "microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "initvalue", + "new-field", + "existing-rows", + "default-value", + "table-extension" + ], + "title": "`InitValue` does not back-fill existing rows", + "description": "`InitValue` on a field defines the value the platform assigns when a *new* record is inserted." + }, + { + "path": "microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "on-validate-upgrade-per-company", + "performance-impact", + "skip-logic", + "justification", + "upgrade-tag" + ], + "title": "Performance-impacting upgrade triggers need justification and skip logic", + "description": "Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass." + }, + { + "path": "microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "httpclient", + "dotnet", + "external-service", + "network-call", + "blocking", + "upgrade-rollback" + ], + "title": "No external calls inside upgrade codeunits", + "description": "Upgrade code runs in a constrained execution window: the tenant is mid-upgrade, no users are signed in, and a failure aborts the entire transaction." + }, + { + "path": "microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "obsolete-state", + "pending", + "removed", + "lifecycle", + "clean-flag", + "upgrade-code-timing" + ], + "title": "Stage obsoletion `Pending \u2192 Removed`; write upgrade code on removal", + "description": "`ObsoleteState` has a deliberate two-step lifecycle." + }, + { + "path": "microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "obsolete-state", + "obsolete-reason", + "obsolete-tag", + "deprecation", + "metadata" + ], + "title": "Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag`", + "description": "When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation:" + }, + { + "path": "microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "upgrade-tag", + "event-subscriber", + "on-get-per-company-upgrade-tags", + "on-get-per-database-upgrade-tags", + "registration" + ], + "title": "Register every upgrade tag with the platform via an event subscriber", + "description": "The `Upgrade Tag` codeunit only recognizes a tag if the tag was published to the platform through one of two events on that codeunit: `OnGetPerCompanyUpgradeTags` for tags set inside `OnUpgradePerCompany`, and `OnGetPerDatabaseUpgradeTags` for tags set inside `OnUpgradePerDatabase`." + }, + { + "path": "microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "get-execution-context", + "execution-context-upgrade", + "skip", + "report-selection", + "runtime-trigger" + ], + "title": "Skip non-essential runtime work when `GetExecutionContext() = ExecutionContext::Upgrade`", + "description": "Runtime procedures (table triggers, install routines, helpers called from many places) sometimes fire during the upgrade window because the upgrade itself touches the data they react to." + }, + { + "path": "microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "on-upgrade-per-company", + "on-upgrade-per-database", + "trigger-body", + "helper-procedure", + "structure" + ], + "title": "`OnUpgradePerCompany` / `OnUpgradePerDatabase` should call helpers, not inline logic", + "description": "The `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on an upgrade codeunit are dispatch points, not implementation slots." + }, + { + "path": "microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "upgrade-codeunit", + "subtype", + "on-upgrade-per-company", + "on-upgrade-per-database", + "trigger" + ], + "title": "Upgrade logic must live in a codeunit with `Subtype = Upgrade`", + "description": "A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`." + }, + { + "path": "microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md", + "layer": "microsoft", + "domain": "upgrade", + "bc-version": [ + "all" + ], + "technologies": [ + "al" + ], + "countries": [ + "w1" + ], + "application-area": [ + "all" + ], + "keywords": [ + "upgrade-tag", + "version-check", + "dataversion", + "has-upgrade-tag", + "set-upgrade-tag", + "control-flow" + ], + "title": "Control upgrade execution with upgrade tags, not version checks", + "description": "Each piece of upgrade logic must run exactly once per company (or database) across the lifetime of an extension." + } + ] +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al new file mode 100644 index 000000000..2fd95acc6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al @@ -0,0 +1,14 @@ +report 50221 "Perf Sample AddLoadFields Bad" +{ + dataset + { + // No AddLoadFields: every Cust. Ledger Entry column ships per row, even though + // only three columns feed the layout. + dataitem(CustLedgerEntry; "Cust. Ledger Entry") + { + column(CustomerNo; "Customer No.") { } + column(PostingDate; "Posting Date") { } + column(Amount; Amount) { } + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al new file mode 100644 index 000000000..3267418c0 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al @@ -0,0 +1,17 @@ +report 50220 "Perf Sample AddLoadFields Good" +{ + dataset + { + dataitem(CustLedgerEntry; "Cust. Ledger Entry") + { + column(CustomerNo; "Customer No.") { } + column(PostingDate; "Posting Date") { } + column(Amount; Amount) { } + + trigger OnPreDataItem() + begin + AddLoadFields("Customer No.", "Posting Date", Amount); + end; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md new file mode 100644 index 000000000..aa811ce1e --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [report, addloadfields, onpredataitem, dataitem, partial-record, layout] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In reports, declare the fields the layout needs with AddLoadFields + +## Description + +Reports iterate dataitems on potentially large source tables and pipe rows into a layout. The partial-record optimization is the same idea as `use-setloadfields-for-partial-records.md`, but the API is different: per the upstream guidance, "for reports, use `AddLoadFields()` in `OnPreDataItem` trigger to add fields needed by the layout." `AddLoadFields` is additive — call it for each field the layout consumes — and runs once per dataitem before iteration begins. + +## Best Practice + +In each dataitem's `OnPreDataItem` trigger, list the columns the layout binds to via `AddLoadFields(, , ...)`. The platform then materializes only those columns per row. Treat the layout column list as the spec: every column the layout uses must be added; columns the layout does not use should not be added. + +See sample: `addloadfields-in-report-onpredataitem.good.al`. + +## Anti Pattern + +Relying on the dataitem's default to load every field. On a report bound to a ledger-scale table this transfers an entire row per iteration, of which the layout reads a fraction. + +See sample: `addloadfields-in-report-onpredataitem.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md new file mode 100644 index 000000000..3e40ef3f7 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [admin-page, migration, wizard, hybrid, permissions, lower-severity] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Admin and migration pages tolerate lower performance discipline + +## Description + +Some pages run rarely and against small datasets, and the upstream guidance explicitly calls for treating them as lower severity. Per the review checklist, "Admin/migration pages (`Admin`, `Setup`, `Wizard`, `Migration`, `HybridBC14`, `HybridSL`, `HybridGP` namespaces, `Permissions`/`PermissionSet` pages) are infrequently used with small datasets — apply lower severity." The same logic covers one-time wizards and tenant-bootstrap routines: the code path runs a handful of times in the lifetime of a tenant, against a bounded dataset, by an administrator. + +## Best Practice + +When triaging a finding on an admin, migration, or wizard page, downgrade severity relative to the same finding on a hot business path. A `FindSet` loop without `SetLoadFields` on a migration page that processes setup records once per tenant is a different finding than the same loop on a posting routine that runs thousands of times a day. Note this context explicitly in the review so the call site is not "fixed" twice with diminishing returns. + +## Anti Pattern + +Treating a migration wizard's per-row loop with the same urgency as the same loop in `Sales-Post`. The fix cost is the same; the production benefit is not. Bulk-rewriting an admin page to use `ModifyAll` and partial records buys nothing the user will perceive. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-filters-before-iterating.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-filters-before-iterating.bad.al new file mode 100644 index 000000000..6dc23df45 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-filters-before-iterating.bad.al @@ -0,0 +1,18 @@ +codeunit 50229 "Perf Sample FilterEarly Bad" +{ + procedure ProcessUSCustomers() + var + Customer: Record Customer; + begin + // Reads every customer in the table, discards the non-US ones in AL. + if Customer.FindSet() then + repeat + if Customer."Country/Region Code" = 'US' then + ProcessCustomer(Customer); + until Customer.Next() = 0; + end; + + local procedure ProcessCustomer(var Customer: Record Customer) + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-filters-before-iterating.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-filters-before-iterating.good.al new file mode 100644 index 000000000..820b1023b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-filters-before-iterating.good.al @@ -0,0 +1,17 @@ +codeunit 50228 "Perf Sample FilterEarly Good" +{ + procedure ProcessUSCustomers() + var + Customer: Record Customer; + begin + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + ProcessCustomer(Customer); + until Customer.Next() = 0; + end; + + local procedure ProcessCustomer(var Customer: Record Customer) + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-filters-before-iterating.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-filters-before-iterating.md new file mode 100644 index 000000000..5f54c3b0c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-filters-before-iterating.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [setrange, setfilter, filter, loop, early, dataset] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply SetRange/SetFilter before iterating, not as an if-test inside the loop + +## Description + +A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at the database. The same condition expressed as an `if` inside the loop body filters in AL, after every row has crossed the boundary. Per the upstream guidance, "apply `SetRange`/`SetFilter` as early as possible to reduce dataset" and "more specific filters = better performance." On a production-scale table the difference is the difference between scanning a subset and scanning the whole table. + +## Best Practice + +Move every predicate that can be expressed as an equality or range filter into a `SetRange` or `SetFilter` ahead of the find. Combine with `SetCurrentKey` to choose a key whose first fields match the filter (see `setcurrentkey-aligns-key-with-filters.md`). The loop body should then contain only the work that depends on per-row state. + +See sample: `apply-filters-before-iterating.good.al`. + +## Anti Pattern + +`if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then ProcessCustomer(Customer); until Customer.Next() = 0;` — the loop pays for every row in the table and discards the non-matching ones in AL. The intent is the same as a `SetRange("Country/Region Code", 'US')` ahead of the find, but the cost is not. + +See sample: `apply-filters-before-iterating.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-guards-before-get.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-guards-before-get.bad.al new file mode 100644 index 000000000..2c0c710cb --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-guards-before-get.bad.al @@ -0,0 +1,14 @@ +codeunit 50215 "Perf Sample GuardBeforeGet Bad" +{ + procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line") + var + PurchaseHeader: Record "Purchase Header"; + begin + // Wasted lookup when the line has no allocation account: the procedure + // exits below, but the header was already fetched. + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + if PurchaseLine."Selected Alloc. Account No." = '' then + exit; + // ... + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-guards-before-get.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-guards-before-get.good.al new file mode 100644 index 000000000..52141f7d7 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-guards-before-get.good.al @@ -0,0 +1,12 @@ +codeunit 50214 "Perf Sample GuardBeforeGet Good" +{ + procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line") + var + PurchaseHeader: Record "Purchase Header"; + begin + if PurchaseLine."Selected Alloc. Account No." = '' then + exit; + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + // ... + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-guards-before-get.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-guards-before-get.md new file mode 100644 index 000000000..9e774115e --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/apply-guards-before-get.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, guard, early-exit, conditional, lookup, wasted-query] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply early-exit guards before calling Get + +## Description + +A `Get` (or any other database call) executed before a guard that may exit the procedure does a round-trip the procedure never uses. Per the upstream guidance, "Flag `Get()` calls that execute before a guard condition that may exit early — the DB lookup is wasted." The fix is structural: order the procedure body so cheap checks (parameter validation, in-memory field comparisons, enum tests) run first, and the database call runs only after the guards pass. + +## Best Practice + +Read the procedure top-to-bottom and place every condition that can short-circuit ahead of every database call. The check `if SomeNo = '' then exit;` belongs above `Header.Get(...)`, not below. Each guard moved upward saves one wasted query on the path that exits. + +See sample: `apply-guards-before-get.good.al`. + +## Anti Pattern + +`Record.Get(...)` at the top of a procedure followed by `if SomeField = '' then exit;`. The code reads top-down as "load the record, then decide whether we needed it" — exactly the order that wastes the query. The pattern is easy to introduce when guards are added later, defensively, without re-checking call ordering. + +See sample: `apply-guards-before-get.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al new file mode 100644 index 000000000..feacfcc6f --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-commit-inside-loops.bad.al @@ -0,0 +1,14 @@ +codeunit 50129 "Perf Sample CommitInLoop Bad" +{ + procedure NormalizeCustomerNames() + var + Customer: Record Customer; + begin + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + Commit(); + until Customer.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al new file mode 100644 index 000000000..afd57a2c6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-commit-inside-loops.good.al @@ -0,0 +1,21 @@ +codeunit 50128 "Perf Sample CommitInLoop Good" +{ + procedure NormalizeCustomerNames() + var + Customer: Record Customer; + RowsInChunk: Integer; + ChunkSize: Integer; + begin + ChunkSize := 500; + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + RowsInChunk += 1; + if RowsInChunk >= ChunkSize then begin + Commit(); + RowsInChunk := 0; + end; + until Customer.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-commit-inside-loops.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-commit-inside-loops.md new file mode 100644 index 000000000..98e0c3857 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-commit-inside-loops.md @@ -0,0 +1,29 @@ +--- +bc-version: [all] +domain: performance +keywords: [commit, loop, transaction, lock, checkpoint, codeunit-run] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Commit inside loops + +> Contributions welcome — open a PR to refine or extend this article. + +## Description + +Commit ends the current write transaction. Calling it inside a per-row loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with the platform's ability to batch write operations. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). When the batch is too large for one transaction, the fix is not a per-row Commit but bounded checkpoints that each process N rows. + +## Best Practice + +If the batch is large enough that a single transaction is untenable, process it in checkpoints driven by an outer loop that each time picks up the next N rows. Commit once per checkpoint at a clearly defined safe boundary, not inside the per-row loop. Wrapping each chunk in `Codeunit.Run` gives the same effect with native rollback on failure — see `codeunit-run-as-atomic-sub-operation.md`. + +See sample: `avoid-commit-inside-loops.good.al`. + +## Anti Pattern + +Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work. + +See sample: `avoid-commit-inside-loops.bad.al`. + diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al new file mode 100644 index 000000000..7ff67be52 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al @@ -0,0 +1,15 @@ +codeunit 50253 "Perf Sample NPlus1 Bad" +{ + procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + var + Item: Record Item; + begin + if BOMLine.FindSet() then + repeat + // Full-row Item.Get per BOM line — no partial loading, no caching. + Item.Get(BOMLine."No."); + if Item."Costing Method" = Item."Costing Method"::Standard then + TotalCost += Item."Standard Cost" * BOMLine."Quantity per"; + until BOMLine.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al new file mode 100644 index 000000000..2bdbf656a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al @@ -0,0 +1,15 @@ +codeunit 50252 "Perf Sample NPlus1 Good" +{ + procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + var + Item: Record Item; + begin + Item.SetLoadFields("Costing Method", "Standard Cost"); + if BOMLine.FindSet() then + repeat + if Item.Get(BOMLine."No.") then + if Item."Costing Method" = Item."Costing Method"::Standard then + TotalCost += Item."Standard Cost" * BOMLine."Quantity per"; + until BOMLine.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md new file mode 100644 index 000000000..2908d3f95 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid Get / FindFirst inside a loop on a large inner table + +## Description + +A `Get` or `FindFirst` against a different record inside a loop body produces one database round-trip per iteration — the classic N+1 pattern. Per the upstream guidance, "Flag when a `Get()`/`FindFirst()` is called inside a loop for each record — this creates N+1 database round-trips." The cost only matters when the inner table is meaningful: lookups against temporary tables, singleton setup tables, enum-mapping tables, permission objects, or Role IDs are bounded and safe. The pattern to catch is the inner lookup that hits a production-scale table for every outer row. + +## Best Practice + +When the loop needs values from another record, lift the lookup out of the loop if the rows can be collected up front, or apply `SetLoadFields` so each inner read transfers only the columns the loop actually uses (see `use-setloadfields-for-partial-records.md`). When the inner record is small or bounded, leave the call site alone — the rule targets large-table inner lookups specifically. + +See sample: `avoid-get-inside-loop-on-large-table.good.al`. + +## Anti Pattern + +Iterating BOM lines and calling `Item.Get(BOMLine."No.")` per row to read a costing method, with no `SetLoadFields` on `Item`. Each iteration issues one query against Item (~800k rows) and pulls the entire row to read two fields. The fix is `Item.SetLoadFields("Costing Method", "Standard Cost");` ahead of the loop — still N reads, but each one transfers only the needed columns. + +See sample: `avoid-get-inside-loop-on-large-table.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al new file mode 100644 index 000000000..f0cee4f19 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al @@ -0,0 +1,22 @@ +codeunit 50255 "Perf Sample RecRef Bad" +{ + procedure ProcessAllCustomerNames() + var + Customer: Record Customer; + RecRef: RecordRef; + FldRef: FieldRef; + begin + RecRef.Open(Database::Customer); + if RecRef.FindSet() then + repeat + // Table and field are fixed at compile time, but every iteration + // pays dynamic resolution cost. + FldRef := RecRef.Field(Customer.FieldNo(Name)); + ProcessName(Format(FldRef.Value)); + until RecRef.Next() = 0; + end; + + local procedure ProcessName(Name: Text) + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al new file mode 100644 index 000000000..86dcc3d06 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al @@ -0,0 +1,16 @@ +codeunit 50254 "Perf Sample RecRef Good" +{ + procedure ProcessAllCustomerNames() + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + ProcessName(Customer.Name); + until Customer.Next() = 0; + end; + + local procedure ProcessName(Name: Text[100]) + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md new file mode 100644 index 000000000..b718449ce --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [recordref, fieldref, hot-loop, typed-record, metadata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid RecordRef / FieldRef in hot loops when a typed record fits + +## Description + +`RecordRef` and `FieldRef` are slower than direct typed record access — the platform resolves the table and field at runtime instead of at compile time. The trade-off is intentional: per the upstream guidance, "RecordRef/FieldRef operations are slower than direct record access, but many features REQUIRE them for generic metadata iteration (permission checks, field copying, dynamic field access)." The rule, then, is not "never use them" but "only flag when used inside a clearly unbounded hot loop (10k+ iterations) where a typed alternative exists." + +## Best Practice + +Use `RecordRef`/`FieldRef` for genuinely generic code — permission checks, field copying, table-agnostic export. When the loop target is known at compile time and the loop iterates a large number of rows, declare the typed record and access fields directly; the saved per-iteration overhead is measurable at the volumes the rule targets. + +See sample: `avoid-recordref-in-hot-loop.good.al`. + +## Anti Pattern + +`RecRef.Open(Database::Customer); if RecRef.FindSet() then repeat FldRef := RecRef.Field(Customer.FieldNo(Name)); ProcessName(FldRef.Value); until RecRef.Next() = 0;` — the table is fixed at compile time, the field is fixed at compile time, and the loop pays the dynamic-resolution cost on every iteration. The direct `Customer.Name` form does the same work without the lookup. + +See sample: `avoid-recordref-in-hot-loop.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al new file mode 100644 index 000000000..e7358fbd5 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al @@ -0,0 +1,21 @@ +page 50217 "Perf Sample Redundant Bad" +{ + PageType = ListPart; + SourceTable = "Assembly Line"; + + var + AssemblyLineRec: Record "Assembly Line"; + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + // Redundant: the platform already fetched the row into Rec. + AssemblyLineRec.Get("Document Type", "Document No.", "Line No."); + ShowWarning := CheckAvailability(AssemblyLineRec); + end; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(AssemblyLine.Quantity > 0); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al new file mode 100644 index 000000000..6cb60ea45 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al @@ -0,0 +1,18 @@ +page 50216 "Perf Sample Redundant Good" +{ + PageType = ListPart; + SourceTable = "Assembly Line"; + + var + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + ShowWarning := CheckAvailability(Rec); + end; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(AssemblyLine.Quantity > 0); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md new file mode 100644 index 000000000..9684769ed --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, onaftergetrecord, redundant, page-trigger, rec, already-loaded] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Get the record the page already loaded + +## Description + +A list or card page's `OnAfterGetRecord` trigger fires *because* the platform has already fetched a row into `Rec`. Calling `Get` for that same row inside the trigger repeats the read the platform just did. Per the upstream guidance, this is "redundant — record already fetched by page runtime"; the correction is "use `Rec` directly — already loaded." The waste compounds on list pages, where the trigger runs once per row displayed. + +## Best Practice + +Inside page triggers — `OnAfterGetRecord`, `OnAfterGetCurrRecord`, validation triggers — read from `Rec` (or the trigger's record parameter). The platform exposes the freshly loaded record there for exactly this purpose. Reach for `Get` only when the trigger needs a *different* record than the one being displayed. + +See sample: `avoid-redundant-get-when-record-already-loaded.good.al`. + +## Anti Pattern + +`AssemblyLineRec.Get("Document Type", "Document No.", "Line No.");` at the top of `OnAfterGetRecord`, when the trigger is on the `Assembly Line` page itself and `Rec` already holds that row. The pattern often appears when a helper that expects a record parameter is invoked from a page trigger and the author writes a `Get` to "freshen" `Rec` rather than passing `Rec` through. + +See sample: `avoid-redundant-get-when-record-already-loaded.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al new file mode 100644 index 000000000..ce5cf31e8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al @@ -0,0 +1,18 @@ +codeunit 50239 "Perf Sample PromptInTxn Bad" +{ + procedure PostOrder(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + PostConfirmQst: Label 'Post this order?'; + begin + SalesHeader.LockTable(); + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + // Lock held while the dialog is on screen — minutes or hours. + if Confirm(PostConfirmQst) then + PostSalesOrder(SalesHeader); + end; + + local procedure PostSalesOrder(var SalesHeader: Record "Sales Header") + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al new file mode 100644 index 000000000..1407d3c9f --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al @@ -0,0 +1,18 @@ +codeunit 50238 "Perf Sample PromptInTxn Good" +{ + procedure PostOrder(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + PostConfirmQst: Label 'Post this order?'; + begin + if not Confirm(PostConfirmQst) then + exit; + SalesHeader.LockTable(); + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + PostSalesOrder(SalesHeader); + end; + + local procedure PostSalesOrder(var SalesHeader: Record "Sales Header") + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md new file mode 100644 index 000000000..834641a68 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [confirm, strmenu, dialog, transaction, lock, user-interaction] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not hold locks while waiting for the user + +## Description + +A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a write transaction stalls the transaction — and therefore every lock it holds — until the user responds. Per the upstream guidance, "Avoid user interactions (Confirm, StrMenu) inside transactions — they hold locks while waiting for user input." The wait is bounded only by the user; meanwhile other sessions block on whatever this transaction has acquired. + +## Best Practice + +Sequence the operation so user confirmation happens *before* any database write that takes a lock the prompt holds open. The shape is: ask the user → if confirmed, acquire locks and post. `if Confirm(...) then begin SalesHeader.LockTable(); SalesHeader.Get(DocNo); PostSalesOrder(SalesHeader); end;` keeps the lock window down to the work itself. + +See sample: `avoid-user-prompts-inside-transactions.good.al`. + +## Anti Pattern + +`SalesHeader.LockTable(); SalesHeader.Get(DocNo); if Confirm('Post this order?') then ...;` — the lock is held for as long as the dialog is up. A user who steps away to lunch holds the lock for an hour, and every other session that touches that row blocks for the duration. + +See sample: `avoid-user-prompts-inside-transactions.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al new file mode 100644 index 000000000..48318a963 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al @@ -0,0 +1,15 @@ +codeunit 50223 "Perf Sample CalcSums Bad" +{ + procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + // One SQL query per row over a 10M-row ledger. + if CustLedgerEntry.FindSet() then + repeat + CustLedgerEntry.CalcFields("Remaining Amount"); + Total += CustLedgerEntry."Remaining Amount"; + until CustLedgerEntry.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al new file mode 100644 index 000000000..2e1f36720 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al @@ -0,0 +1,11 @@ +codeunit 50222 "Perf Sample CalcSums Good" +{ + procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + CustLedgerEntry.CalcSums("Remaining Amount"); + Total := CustLedgerEntry."Remaining Amount"; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md new file mode 100644 index 000000000..7d0752ff8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [calcfields, calcsums, loop, flowfield, n-plus-one, aggregation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use CalcSums to aggregate, not CalcFields inside a loop + +## Description + +`CalcFields` materializes FlowField values for one record. Each call against a persistent table is "a separate SQL query"; running it inside a `repeat ... until Next() = 0` over a large table issues one query per row on top of the iteration itself. `CalcSums` answers the same aggregation question — "give me the sum of this FlowField over the filtered set" — as a single SQL statement. Per the upstream guidance, `CalcFields` inside loops on large persistent tables is "a performance problem"; the aggregation form is `CalcSums()`. + +## Best Practice + +When the procedure totals a FlowField (or several) across a filtered set, set the filters, then call `CalcSums("Field 1", "Field 2", ...)`. The platform issues one query; the result is read off the record's FlowField slot. Single `CalcFields` outside loops is fine, and `CalcFields` on the current row in a page's `OnAfterGetRecord` or in `OnValidate` is the standard pattern — those are per-action, not per-row over a large set. + +See sample: `calcsums-instead-of-calcfields-in-loop.good.al`. + +## Anti Pattern + +`if CustLedgerEntry.FindSet() then repeat CustLedgerEntry.CalcFields("Remaining Amount"); Total += CustLedgerEntry."Remaining Amount"; until CustLedgerEntry.Next() = 0;` — exactly the upstream-flagged shape. The iteration is the cheap part; the per-row `CalcFields` is what scales linearly with table size. + +See sample: `calcsums-instead-of-calcfields-in-loop.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al new file mode 100644 index 000000000..d74dfc1c6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.bad.al @@ -0,0 +1,12 @@ +codeunit 50144 "Perf Sample AtomicSub Bad" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + begin + if Customer.FindSet(true) then + repeat + Customer."Customer Price Group" := 'VIP'; + Customer.Modify(true); + Commit(); + until Customer.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al new file mode 100644 index 000000000..91d81e3b8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.good.al @@ -0,0 +1,29 @@ +codeunit 50142 "Perf Sample AtomicSub Good" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + var + ApplyOne: Codeunit "Perf Sample Apply Discount"; + begin + if Customer.FindSet() then + repeat + ClearLastError(); + if not ApplyOne.Run(Customer) then + LogSkipped(Customer."No.", GetLastErrorText()); + until Customer.Next() = 0; + end; + + local procedure LogSkipped(CustomerNo: Code[20]; ErrorText: Text) + begin + end; +} + +codeunit 50143 "Perf Sample Apply Discount" +{ + TableNo = Customer; + + trigger OnRun() + begin + Rec.Validate("Customer Price Group", 'VIP'); + Rec.Modify(true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md new file mode 100644 index 000000000..89777f165 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-as-atomic-sub-operation.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [codeunit-run, atomic, rollback, transaction, sub-transaction, try-pattern, implicit-commit] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use Codeunit.Run to bound an atomic sub-operation + +## Description + +`Codeunit.Run(ID)` is the AL-idiomatic way to run a unit of work as an atomic sub-operation with its own transactional boundary. When the return value is captured — `if Codeunit.Run(MyCodeunit) then ...` — the runtime treats the codeunit as a unit: on successful completion it performs an implicit commit of the codeunit's database changes; on error it rolls those changes back and the caller receives `false`. Per the platform reference, "any changes done to the database will be committed at the end of the codeunit, unless an error occurs." The caller decides how to react — compensate, surface an error, continue — without having to manage transactions by hand. + +## Best Practice + +When a piece of work must either complete fully or have no effect, put it in its own codeunit and invoke it via `Codeunit.Run`, capturing the return. Use `if not Codeunit.Run(X) then Error(...)` to abort and unwind; use the plain boolean branch to react to failure without aborting the caller. This replaces the SQL-style `BEGIN TRAN / COMMIT / ROLLBACK` habit with a pattern the AL runtime implements natively. Do not confuse `Codeunit.Run` with `[TryFunction]` — both catch errors, but only `Codeunit.Run` rolls back database changes on failure (see `use-tryfunction-for-error-catching-not-rollback.md`). Note that if the caller is already in a write transaction, the platform requires a `Commit()` before `Codeunit.Run` — the sub-operation cannot nest inside an open transaction (see `codeunit-run-requires-prior-commit-inside-transaction.md`). + +See sample: `codeunit-run-as-atomic-sub-operation.good.al`. + +## Anti Pattern + +Inlining the work in the caller and sprinkling `Commit()` to simulate sub-transaction boundaries. The caller's enclosing transaction is fused to the sub-work; any Commit between checkpoints survives subsequent errors, and any errors after a Commit cannot be cleanly unwound. Per-row Commits (see `avoid-commit-inside-loops.md`) are a frequent symptom. + +See sample: `codeunit-run-as-atomic-sub-operation.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al new file mode 100644 index 000000000..64cb7a237 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.bad.al @@ -0,0 +1,27 @@ +codeunit 50147 "Perf Sample OpenTxnRun Bad" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + var + ApplyOne: Codeunit "Perf Sample OpenTxnRun Apply"; + RunLog: Record "Custom Run Log"; + begin + if Customer.FindSet() then + repeat + RunLog.Init(); + RunLog."Customer No." := Customer."No."; + RunLog.Insert(); + if not ApplyOne.Run(Customer) then; + until Customer.Next() = 0; + end; +} + +codeunit 50148 "Perf Sample OpenTxnRun Apply" +{ + TableNo = Customer; + + trigger OnRun() + begin + Rec.Validate("Customer Price Group", 'VIP'); + Rec.Modify(true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al new file mode 100644 index 000000000..ac9fcfb5b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.good.al @@ -0,0 +1,37 @@ +codeunit 50145 "Perf Sample DeferredLog Good" +{ + procedure ApplyDiscountToSelection(var Customer: Record Customer) + var + ApplyOne: Codeunit "Perf Sample DeferredLog Apply"; + FailedCustomerNos: List of [Code[20]]; + FailureReasons: List of [Text]; + Index: Integer; + begin + if Customer.FindSet() then + repeat + ClearLastError(); + if not ApplyOne.Run(Customer) then begin + FailedCustomerNos.Add(Customer."No."); + FailureReasons.Add(GetLastErrorText()); + end; + until Customer.Next() = 0; + + for Index := 1 to FailedCustomerNos.Count() do + WriteFailureLog(FailedCustomerNos.Get(Index), FailureReasons.Get(Index)); + end; + + local procedure WriteFailureLog(CustomerNo: Code[20]; Reason: Text) + begin + end; +} + +codeunit 50146 "Perf Sample DeferredLog Apply" +{ + TableNo = Customer; + + trigger OnRun() + begin + Rec.Validate("Customer Price Group", 'VIP'); + Rec.Modify(true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md new file mode 100644 index 000000000..a07520f98 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/codeunit-run-requires-prior-commit-inside-transaction.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [codeunit-run, commit, write-transaction, nesting, loop, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Commit before Codeunit.Run when the caller already holds a write transaction + +## Description + +`Codeunit.Run` cannot nest inside an open write transaction. Per the platform reference, "If you're already in a transaction you must commit first before calling `Codeunit.Run`." The platform enforces this at runtime: the first call dies with an error, not at compile time. The rule most often surfaces in a loop that pairs outer-scope writes — progress records, audit log entries, failure markers — with a per-item `Codeunit.Run`: the first outer write opens a transaction, the subsequent `Codeunit.Run` throws. `[CommitBehavior]` does not silence this, because the implicit commit inside `Codeunit.Run` is exempt from the attribute: "The `CommitBehavior` only applies to explicit commits, not implicit commits done as part of [Codeunit.Run]." `[TryFunction]` is not a substitute either: a try method catches errors but does not open its own rollback boundary (see `use-tryfunction-for-error-catching-not-rollback.md`). + +## Best Practice + +For the `Codeunit.Run` atomic-sub-operation pattern (see `codeunit-run-as-atomic-sub-operation.md`) to work in a loop, keep the outer scope **read-only**. Move per-iteration writes — progress updates, logging, audit entries — into the sub-codeunit so they commit or roll back together with the per-item work. If logging must live outside the atomic boundary, defer it: collect failure info in memory during the loop (a `List of [Text]`, a temporary record, local variables) and write it in one pass after the loop ends, when no outer write transaction is open. + +See sample: `codeunit-run-requires-prior-commit-inside-transaction.good.al`. + +## Anti Pattern + +Inserting `Commit()` before each `Codeunit.Run` to silence the runtime error. The error goes away, but the outer scope now commits per iteration — the behavior `avoid-commit-inside-loops.md` exists to warn against. Attempting to silence the implicit commit inside the sub-codeunit with `[CommitBehavior(CommitBehavior::Ignore)]` also fails: the attribute does not apply to `Codeunit.Run`'s implicit commit. Conditioning the Commit on `Database.IsInWriteTransaction()` (runtime 11.0+) is another version of the same trap — the method has legitimate uses for diagnostics and library code that genuinely cannot control its caller, but branching production flow on runtime transaction state typically signals unclear ownership that would be better fixed by restructuring the caller so transaction state is predictable. + +See sample: `codeunit-run-requires-prior-commit-inside-transaction.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al new file mode 100644 index 000000000..79c524e52 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al @@ -0,0 +1,10 @@ +codeunit 50235 "Perf Sample LockReadOnly Bad" +{ + procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean + begin + // Read-only path, yet every caller's transaction now acquires UPDLOCK + // on Agent Status for the remainder of the transaction. + AgentStatus.LockTable(); + exit(AgentStatus.Get()); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al new file mode 100644 index 000000000..3f2a3a36e --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al @@ -0,0 +1,14 @@ +codeunit 50234 "Perf Sample LockReadOnly Good" +{ + procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean + begin + if AgentStatus.Get() then + exit(true); + AgentStatus.LockTable(); + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + exit(true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md new file mode 100644 index 000000000..f25af2eaa --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [locktable, read-only, helper, contention, transaction] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not LockTable in a read-only procedure + +## Description + +`LockTable` is a transaction-wide signal: from the call onward, every read against that table in the same transaction acquires `UPDLOCK`. Per the upstream guidance, "`LockTable()` before Modify/Insert/Delete in the same procedure is the correct pattern" — locking the read against the write that follows is what the call exists for. The anti-pattern is "`LockTable()` in read-only procedures — unnecessary lock contention": the procedure never writes, but the lock cost is paid by everyone sharing the transaction. + +## Best Practice + +Reserve `LockTable` for the read directly before a `Modify`, `Insert`, or `Delete` that depends on the read value. If a helper is sometimes called for reading and sometimes for writing, split it into separate read and write paths and call `LockTable` only on the write path. For read-only existence checks or lookups, the right primitive is `ReadIsolation` (see `prefer-readisolation-over-locktable-for-reads.md`). + +See sample: `do-not-locktable-in-read-only-procedure.good.al`. + +## Anti Pattern + +A pure getter that opens with `Rec.LockTable();`. Every caller's transaction now acquires `UPDLOCK` on that table for every subsequent read until commit. The contention shows up as blocking on unrelated sessions whose own code path looks innocent — the locker is invisible to the blocked reader. + +See sample: `do-not-locktable-in-read-only-procedure.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al new file mode 100644 index 000000000..944ea9971 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al @@ -0,0 +1,17 @@ +page 50247 "Perf Sample WriteScroll Bad" +{ + PageType = List; + SourceTable = Customer; + + trigger OnAfterGetRecord() + begin + // One DB write per row displayed, every time the user scrolls. + Rec."Reminder Terms Code" := CalcReminderTerms(); + Rec.Modify(); + end; + + local procedure CalcReminderTerms(): Code[10] + begin + exit(''); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al new file mode 100644 index 000000000..751875c2d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al @@ -0,0 +1,18 @@ +page 50246 "Perf Sample WriteScroll Good" +{ + PageType = List; + SourceTable = Customer; + + var + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + ShowWarning := CalcWarning(); + end; + + local procedure CalcWarning(): Boolean + begin + exit(false); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md new file mode 100644 index 000000000..9de0903a6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [page-trigger, onaftergetrecord, modify, display, scroll, db-write] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Modify inside OnAfterGetRecord + +## Description + +A list page's `OnAfterGetRecord` fires once per visible row, every time the user scrolls, sorts, or refreshes. A `Modify` inside that trigger means a database write per row displayed. Per the upstream guidance, "`Modify()` here means a DB write on every scroll. Use page variables for display-only state instead." `OnAfterGetCurrRecord` (single record on selection), `OnOpenPage`, and `OnInit` fire once or at much lower frequency and tolerate one-time setup logic. + +## Best Practice + +When the trigger needs to compute display-only state per row, write the result into a page variable (a global on the page object) rather than back to the database. Reserve `Modify` for triggers that fire on an explicit user action — `OnAction`, validation triggers, `OnQueryClosePage` — where one action maps to one write. + +See sample: `do-not-modify-in-onaftergetrecord.good.al`. + +## Anti Pattern + +`trigger OnAfterGetRecord() begin Rec."Warning Flag" := CalcWarning(); Rec.Modify(); end;` — on a list page over a moderately sized table, scrolling through fifty rows produces fifty writes. The page feels slow, the table accumulates churn, and the warning flag — which is recomputed on every refresh anyway — never needed persistence. + +See sample: `do-not-modify-in-onaftergetrecord.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al new file mode 100644 index 000000000..1f99d7c56 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al @@ -0,0 +1,12 @@ +page 50249 "Perf Sample TempAPI Bad" +{ + PageType = API; + APIPublisher = 'perf'; + APIGroup = 'sample'; + APIVersion = 'v1.0'; + EntityName = 'outboxEmail'; + EntitySetName = 'outboxEmails'; + SourceTable = "Sent Email"; + // SourceTableTemporary removed — every request now hits SQL. + DelayedInsert = true; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al new file mode 100644 index 000000000..0ec95f7e9 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al @@ -0,0 +1,12 @@ +page 50248 "Perf Sample TempAPI Good" +{ + PageType = API; + APIPublisher = 'perf'; + APIGroup = 'sample'; + APIVersion = 'v1.0'; + EntityName = 'outboxEmail'; + EntitySetName = 'outboxEmails'; + SourceTable = "Sent Email"; + SourceTableTemporary = true; + DelayedInsert = true; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md new file mode 100644 index 000000000..a7215be08 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [sourcetabletemporary, api-page, temporary, persistent, in-memory] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Removing SourceTableTemporary on an API page switches it from in-memory to persistent + +## Description + +`SourceTableTemporary = true` on a page makes the page's record buffer in-memory only — reads and writes do not touch SQL. The same applies to `TableType = Temporary` on a record. Removing either turns operations that were memory accesses into database round-trips. Per the upstream guidance, the change is "potentially increasing DB load for high-volume paths (API pages, background tasks)" — and on API pages especially, the change is invisible at the page definition but visible at production scale. + +## Best Practice + +If a page or record was declared temporary on purpose — to buffer payloads, accept synthetic rows, or expose computed data through an API surface without persisting it — keep it temporary. When removing the property looks necessary, audit the call sites first: a temporary API page is often consumed by integrations that issue many calls per minute, and the round-trip cost is paid per call. If persistence is genuinely required, weigh storage and lock cost against alternatives (a regular table the API page reads from, an event-driven write). + +See sample: `do-not-remove-sourcetabletemporary-from-api-page.good.al`. + +## Anti Pattern + +Dropping `SourceTableTemporary = true` from an API page to "simplify" it, without revisiting the access pattern. The page begins issuing real SQL on every request; locks now contend with other writers; bulk integrations slow proportionally. The same trap exists for a record that was `TableType = Temporary` and gets demoted to a persistent table to make a debugger view easier. + +See sample: `do-not-remove-sourcetabletemporary-from-api-page.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al new file mode 100644 index 000000000..d80786b11 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al @@ -0,0 +1,25 @@ +codeunit 50237 "Perf Sample FindSetTrue Bad" +{ + procedure NormalizeNames() + var + Customer: Record Customer; + begin + // Read takes a shared lock; the Modify then needs to upgrade — that gap + // is the deadlock window FindSet(true) was designed to close. + if Customer.FindSet() then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + until Customer.Next() = 0; + end; + + procedure ReadOnlyOverlocked() + var + Customer: Record Customer; + begin + // No Modify in the loop, yet every row is read under UpdLock. + if Customer.FindSet(true) then + repeat + until Customer.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al new file mode 100644 index 000000000..bb6bf9189 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al @@ -0,0 +1,23 @@ +codeunit 50236 "Perf Sample FindSetTrue Good" +{ + procedure NormalizeNames() + var + Customer: Record Customer; + begin + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + until Customer.Next() = 0; + end; + + procedure SumBalances() Total: Decimal + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md new file mode 100644 index 000000000..48a1deb87 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [findset, updlock, readisolation, locking, modify, obsolete-syntax] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FindSet(true) applies UpdLock on the read; the two-parameter form is obsolete + +## Description + +`FindSet()` and `FindSet(false)` are read-only — no locking. Per the upstream guidance, `FindSet(true)` "signifies the intent is to modify records" and "sets `ReadIsolation::UpdLock` on the record before finding rows." That is exactly the right shape when the loop body modifies each row: the read takes the same lock the modification will need, avoiding the deadlock window between an unlocked read and a later upgrade. The older two-parameter form `FindSet(ForUpdate, UpdateKey)` is obsolete — only the single-parameter signature should appear in new code. + +## Best Practice + +Use `FindSet(true)` only when the loop body genuinely modifies the iterated rows; use `FindSet()` (or `FindSet(false)`) when the loop only reads. Do not write `FindSet(true, true)` or `FindSet(true, false)` — the two-parameter form is the obsolete signature. + +See sample: `findset-true-applies-updlock-on-read.good.al`. + +## Anti Pattern + +`FindSet(true)` on a loop that does not modify the iterated rows takes an `UpdLock` the work does not need; competing readers and writers stall against a lock the loop never uses. The mirror anti-pattern is `FindSet()` (no parameter) on a loop that *does* modify each row — the read takes a shared lock, the `Modify` then needs to upgrade, and the gap between them is a deadlock candidate. + +See sample: `findset-true-applies-updlock-on-read.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al new file mode 100644 index 000000000..f78497934 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al @@ -0,0 +1,15 @@ +tableextension 50226 "Perf Sample SIFT Bad Cust" extends Customer +{ + fields + { + // No SIFT key on Detailed Cust. Ledg. Entry for (Customer No.) with + // "Debit Amount" in SumIndexFields — the sum falls back to row-by-row + // aggregation over a ledger-scale table. + field(50226; "Perf Sample Total Debit"; Decimal) + { + FieldClass = FlowField; + CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount" + where("Customer No." = field("No."))); + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al new file mode 100644 index 000000000..420a84afb --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al @@ -0,0 +1,23 @@ +tableextension 50224 "Perf Sample SIFT Good Ext" extends "Detailed Cust. Ledg. Entry" +{ + keys + { + key(PerfSampleByCustomer; "Customer No.", "Posting Date") + { + SumIndexFields = "Debit Amount"; + } + } +} + +tableextension 50225 "Perf Sample SIFT Good Cust" extends Customer +{ + fields + { + field(50225; "Perf Sample Total Debit"; Decimal) + { + FieldClass = FlowField; + CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount" + where("Customer No." = field("No."))); + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md new file mode 100644 index 000000000..3a63613be --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [flowfield, sumindexfields, sift, key, calcformula, aa0232] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A FlowField needs a source-table key that covers its CalcFormula + +## Description + +A FlowField is computed by SQL on demand. CodeCop AA0232 — "FlowFields should be indexed with SumIndexFields on corresponding keys" — captures the indexing requirement: the source table must declare a key that includes the fields the `CalcFormula` filters on, with the aggregated field listed in that key's `SumIndexFields`. When that alignment is in place, the platform answers `CalcFields`/`CalcSums` from SIFT; without it, the same query falls back to a row-by-row aggregation on what is often a ledger-scale table. Per the upstream guidance, "Missing SIFT indices cause performance issues on List pages." + +## Best Practice + +When introducing or changing a FlowField, walk the `CalcFormula`'s `WHERE` clause field by field and verify the source table has a key whose key fields cover those filters, with the aggregated field in `SumIndexFields`. The same applies when the destination side of the FlowField filter is a list-page column: the page filter triggers the FlowField on every visible row, and only SIFT keeps that affordable. + +See sample: `flowfield-source-key-needs-sumindexfields.good.al`. + +## Anti Pattern + +A `sum` FlowField against a large source table with no matching SIFT key. Each calculation aggregates rows directly; on a ledger-sized source the FlowField becomes the slowest column on every page that displays it. Pointing an existing FlowField's `CalcFormula` at a larger source table without verifying the new source's keys is the same trap a step removed — the upstream review guidance flags it as "CalcFormula changed to larger source table". + +See sample: `flowfield-source-key-needs-sumindexfields.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al new file mode 100644 index 000000000..15402a30f --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al @@ -0,0 +1,18 @@ +codeunit 50257 "Perf Sample EventGuard Bad" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)] + local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line") + var + Item: Record Item; + begin + // Item.Get fires on every Quantity edit — including lines whose Type is + // not Item. No cheap guard, no SetLoadFields. + Item.Get(Rec."No."); + if Item."Item Category Code" <> '' then + RecalculatePrice(Rec, Item); + end; + + local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item) + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al new file mode 100644 index 000000000..1530e975b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al @@ -0,0 +1,19 @@ +codeunit 50256 "Perf Sample EventGuard Good" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)] + local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line") + var + Item: Record Item; + begin + if Rec.Type <> Rec.Type::Item then + exit; + Item.SetLoadFields("Item Category Code"); + if Item.Get(Rec."No.") then + if Item."Item Category Code" <> '' then + RecalculatePrice(Rec, Item); + end; + + local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item) + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md new file mode 100644 index 000000000..c466ffe8c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [event-subscriber, guard, db-call, frequently-fired, validate] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard event subscribers with cheap checks before any database call + +## Description + +Event subscribers fire on every event matching their signature — for `OnAfterValidateEvent` on a hot field like `Sales Line.Quantity`, that is every quantity edit by every user. Per the upstream guidance, "Keep event subscriber code lightweight" and "Avoid database operations in frequently-fired events — guard with cheap checks first." A `Get` or `FindFirst` at the top of such a subscriber pays a database round-trip on every fire, including the calls for which the subscriber's work was not needed. + +## Best Practice + +Open the subscriber with an in-memory predicate that filters out the calls the subscriber does not handle — record type, document type, status, parameter-passed flags. Only after the cheap guard passes should the body issue a database call, and only with `SetLoadFields` for the columns the body actually reads. + +See sample: `guard-event-subscribers-before-db-call.good.al`. + +## Anti Pattern + +`[EventSubscriber(...'OnAfterValidateEvent', 'Quantity', ...)] local procedure ... var Item: Record Item; begin Item.Get(Rec."No."); if Item.HasCustomPricing() then ...;` — `Item.Get` runs on every quantity change, including changes to lines whose `Type` is not `Item`. A pre-check `if Rec.Type <> Rec.Type::Item then exit;` ahead of the `Get` removes most of the calls. + +See sample: `guard-event-subscribers-before-db-call.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al new file mode 100644 index 000000000..edce74266 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al @@ -0,0 +1,26 @@ +table 50227 "Perf Sample FA Journal Tmpl" +{ + fields + { + field(1; Name; Code[10]) { } + field(40; "No. of Lines"; Integer) + { + FieldClass = FlowField; + // Source key below has MaintainSQLIndex = false: SIFT cannot + // function, so this COUNT runs without a SQL index. + CalcFormula = count("FA Journal Line" + where("Journal Template Name" = field(Name))); + } + } +} + +tableextension 50228 "Perf Sample FA Jnl Line Ext" extends "FA Journal Line" +{ + keys + { + key(PerfSampleByTemplate; "Journal Template Name", "Journal Batch Name") + { + MaintainSQLIndex = false; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md new file mode 100644 index 000000000..e5408596b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [maintainsqlindex, key, sift, flowfield, sum, count, table-scan] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# MaintainSQLIndex = false on a key disables SIFT for FlowFields that depend on it + +## Description + +`MaintainSQLIndex = false` on a key tells the platform not to materialize that key as a SQL index. Per the upstream guidance, when a FlowField's source key carries that property, "SIFT cannot function, COUNT/SUM will table-scan." The flag is sometimes set to save write-path cost on a rarely-queried key, but if a `CalcFormula` aggregates through that exact key, the FlowField loses its index — every `CalcFields`/`CalcSums`/list-page filter that triggers it runs without one. + +## Best Practice + +When changing a key property to `MaintainSQLIndex = false`, find every FlowField whose `CalcFormula` filters on that key and verify another key covers the same fields. When adding a FlowField whose source table has only a `MaintainSQLIndex = false` key for its filter columns, add a fully-indexed key (or accept that the FlowField cannot ride SIFT and reshape the design — see `flowfield-source-key-needs-sumindexfields.md`). + +See sample: `maintainsqlindex-false-breaks-flowfield-sift.bad.al`. + +## Anti Pattern + +A FlowField whose `CalcFormula`'s `WHERE` columns line up with a key that has `MaintainSQLIndex = false`. The schema looks correct — the key exists, the SumIndexFields are listed — but at runtime the platform has no SQL index to use, and the aggregation table-scans on every invocation. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al new file mode 100644 index 000000000..ec5eec397 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al @@ -0,0 +1,13 @@ +codeunit 50209 "Perf Sample FindSetNext Bad" +{ + procedure SumCustomerBalances() Total: Decimal + var + Customer: Record Customer; + begin + // AA0233: FindFirst paired with Next — single-row API used to iterate. + if Customer.FindFirst() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al new file mode 100644 index 000000000..955c05b79 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al @@ -0,0 +1,18 @@ +codeunit 50208 "Perf Sample FindSetNext Good" +{ + procedure SumCustomerBalances() Total: Decimal + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; + + procedure GetFirstUSCustomer(var Customer: Record Customer): Boolean + begin + Customer.SetRange("Country/Region Code", 'US'); + exit(Customer.FindFirst()); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pair-findset-with-next-loop.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pair-findset-with-next-loop.md new file mode 100644 index 000000000..80f855cd2 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pair-findset-with-next-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [findset, findfirst, findlast, get, next, repeat-until, aa0181, aa0233] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use FindSet with repeat..Next; do not pair FindFirst/FindLast/Get with Next + +## Description + +Two CodeCop rules carve out the loop pattern. AA0181 says `FindSet()`/`Find()` "must be used with `Next()` method" — these are the multi-row APIs that the runtime sets up for forward iteration. AA0233 says do "NOT use `FindFirst()`/`FindLast()`/`Get()` with `Next()`" — these are single-row APIs, and iterating from them "wastes CPU and bandwidth." Both rules together define one boundary: choose `FindSet` when the body iterates; choose `FindFirst`, `FindLast`, or `Get` when the body uses exactly one record. + +## Best Practice + +When the body executes `repeat ... until Next() = 0;`, open the iteration with `FindSet()`. When the body needs one record and does not call `Next`, use `FindFirst`, `FindLast`, or — if the full primary key is known — `Get` (see `use-get-instead-of-findfirst-on-full-primary-key.md`). The choice is per call site, not a global preference. + +See sample: `pair-findset-with-next-loop.good.al`. + +## Anti Pattern + +`if Customer.FindFirst() then repeat ... until Customer.Next() = 0;` — AA0233 flags this. The single-row API does not prepare the runtime for iteration, so the loop pays a cost the FindSet path does not. The mirror anti-pattern is calling `FindSet` to read a single record (see `use-isempty-for-existence-check.md` when only existence is required). + +See sample: `pair-findset-with-next-loop.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al new file mode 100644 index 000000000..3bcdd9a98 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al @@ -0,0 +1,21 @@ +codeunit 50240 "Perf Sample Trigger Param Good" +{ + procedure BulkFlagOrders(var SalesHeader: Record "Sales Header") + begin + if SalesHeader.FindSet(true) then + repeat + SalesHeader."Job Queue Status" := SalesHeader."Job Queue Status"::"Scheduled for Posting"; + // Trigger has nothing to add for a status flip in this code path. + SalesHeader.Modify(false); + until SalesHeader.Next() = 0; + end; + + procedure CreateOrder(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]) + begin + SalesHeader.Init(); + SalesHeader."Document Type" := SalesHeader."Document Type"::Order; + SalesHeader."Sell-to Customer No." := CustomerNo; + // OnInsert allocates the No.-Series number — the trigger is required. + SalesHeader.Insert(true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md new file mode 100644 index 000000000..45214b87b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [insert, modify, delete, trigger, run-trigger, write-parameters] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass false to Insert/Modify/Delete when the table triggers do not need to fire + +## Description + +`Insert(true)`, `Modify(true)`, and `Delete(true)` run the table's `OnInsert`/`OnModify`/`OnDelete` trigger; the `(false)` form skips it. Per the upstream guidance, the trigger form should be used "only when needed" — every row whose write fires a trigger pays that cost, even when the trigger has nothing useful to add for the current call site. For tight bulk write paths the difference compounds linearly with row count. + +## Best Practice + +Reach for the `(false)` form when the calling code already enforces the invariants the trigger would, or when the trigger is empty for the current table/extension. Use `(true)` when the trigger does work the caller depends on (number-series allocation, validation, cascading writes). Decide per call, not by code style: a default of "always `true`" makes bulk writes pay for triggers they did not need, and a default of "always `false`" silently skips validation the trigger was put there to enforce. + +See sample: `pass-false-to-insert-when-trigger-not-needed.good.al`. + +## Anti Pattern + +Looping over thousands of rows and calling `Modify(true)` on each, when the table's `OnModify` trigger does nothing relevant for the operation. The trigger cost is paid per row; the user-visible behavior is identical to the `(false)` form. The mirror is using `(false)` for an operation that depends on trigger-side defaulting and silently producing rows that fail downstream validation. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md new file mode 100644 index 000000000..458d098e8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [dictionary, temporary-table, lookup, o-of-1, key-lookup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer a Dictionary over a temporary table for pure lookups + +## Description + +A temporary table supports a full record API — filters, iteration, multi-field keys — but a pure key→value lookup pays for plumbing it does not use. Per the upstream guidance, "if a temporary table record is ONLY used as a lookup table, it is faster to use a dictionary which supports O(1) lookups instead of O(lg n) for temporary tables." The Dictionary type has no record machinery to traverse; the key hash answers the lookup directly. + +## Best Practice + +When the use of a temp record is "set a key, see if the row exists, read a single value", switch to `Dictionary of [Key, Value]`. Use the temp-table form when the use genuinely needs filtering, iteration in a specific order, or a multi-field key. Compatibility with code that expects a `Record` parameter is a real reason to keep the temp table; performance alone, on a pure lookup, is not. + +## Anti Pattern + +A temp `Record` declared, populated row by row, then queried with `SetRange(KeyField, X); if Find('=') then Value := Rec.ValueField;`. The lookup hashes the key behind the scenes and does the same work a `Dictionary` would, plus the per-row record overhead. The pattern often appears because the author originally needed iteration and the iteration was later removed without revisiting the data structure. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al new file mode 100644 index 000000000..0b8c21dc8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al @@ -0,0 +1,15 @@ +codeunit 50243 "Perf Sample ModifyAll Bad" +{ + procedure ApplyPriceUpdate(NewPrice: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange(Type, SalesLine.Type::Item); + // N writes when one ModifyAll would do. + if SalesLine.FindSet() then + repeat + SalesLine.Validate("Unit Price", NewPrice); + SalesLine.Modify(true); + until SalesLine.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al new file mode 100644 index 000000000..c33d9c0de --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al @@ -0,0 +1,20 @@ +codeunit 50242 "Perf Sample ModifyAll Good" +{ + procedure ApplyPriceUpdate(NewPrice: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange(Type, SalesLine.Type::Item); + SalesLine.ModifyAll("Unit Price", NewPrice); + end; + + procedure ApplyTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Document No.", DocumentNo); + CustLedgerEntry.SetRange(Open, true); + CustLedgerEntry.ModifyAll("Accepted Payment Tolerance", ToleranceAmount); + CustLedgerEntry.ModifyAll("Accepted Pmt. Disc. Tolerance", false); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md new file mode 100644 index 000000000..73a095cba --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [modifyall, deleteall, bulk, loop, modify, set-based] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use ModifyAll / DeleteAll instead of per-row Modify / Delete in a loop + +## Description + +`ModifyAll` and `DeleteAll` are the bulk APIs. Per the upstream guidance, they "execute as single SQL statements" when the table supports it — one round-trip updates or deletes every row in the filtered set. The anti-pattern is the loop equivalent: `FindSet` followed by per-row `Modify`/`Delete`, where the runtime issues one write per row. On a production-scale table the difference is the difference between a single statement and N statements. + +## Best Practice + +When the loop body does nothing more than assign a constant value (or a value computed once) to one or more fields, replace the loop with `ModifyAll("Field 1", Value1)` — and chain additional `ModifyAll` calls for additional fields. The same shape applies to `DeleteAll`. Be aware that the bulk APIs can regress to row-by-row execution for tables with certain trigger or media-field configurations (see `triggers-and-media-field-regress-modifyall.md`); when that regression applies, multiple `ModifyAll` calls become more expensive than one manual loop, so the choice is conditional, not absolute. + +See sample: `prefer-modifyall-over-per-row-modify.good.al`. + +## Anti Pattern + +`if SalesLine.FindSet() then repeat SalesLine.Validate("Unit Price", NewPrice); SalesLine.Modify(true); until SalesLine.Next() = 0;` — N writes when one would do. The pattern is easy to introduce when the loop initially does per-row computation and is later simplified to assign a constant; the loop scaffolding survives the simplification. + +See sample: `prefer-modifyall-over-per-row-modify.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al new file mode 100644 index 000000000..c23886c4a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al @@ -0,0 +1,13 @@ +codeunit 50233 "Perf Sample ReadIso Bad" +{ + procedure GetOrCreate(var AgentStatus: Record "Agent Status") + begin + // LockTable poisons every subsequent read of Agent Status in the + // surrounding transaction with UPDLOCK — even for callers that only read. + AgentStatus.LockTable(); + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al new file mode 100644 index 000000000..be5cd5584 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al @@ -0,0 +1,11 @@ +codeunit 50232 "Perf Sample ReadIso Good" +{ + procedure GetOrCreate(var AgentStatus: Record "Agent Status") + begin + AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted; + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md new file mode 100644 index 000000000..c2f888b88 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [readisolation, locktable, updlock, read-only, transaction-scope, isolation-level] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer ReadIsolation over LockTable for read-only scenarios + +## Description + +`LockTable` and `ReadIsolation` solve different problems with different blast radii. Per the upstream guidance, "`LockTable` ensures that all READS against that table will happen with UPDLOCK for the remainder of the transaction." `ReadIsolation` "only pertains to the current record instance, while `LockTable` affects the lockstate of the entire transaction." `ReadIsolation` is also more expressive: it can heighten or lower the isolation level inside an already-established transaction. Reaching for `LockTable` when only a single read needs guarding therefore poisons every later read on that table — including reads in other code paths that share the transaction. + +## Best Practice + +For a read-only operation, or a single read that needs a higher isolation level than the surrounding transaction, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted;` (or the level the call requires) immediately before the read. The hint applies only to that record instance. Save `LockTable` for code that genuinely needs every subsequent read on the table to acquire an update lock (see `findset-true-applies-updlock-on-read.md` for the alternative narrower mechanism on iterated reads). + +See sample: `prefer-readisolation-over-locktable-for-reads.good.al`. + +## Anti Pattern + +`Rec.LockTable();` at the top of a helper that only reads, perhaps to "make sure the read is consistent". Every subsequent read on that table for the rest of the transaction acquires `UPDLOCK`, including reads from unrelated code paths fused into the same transaction. The contention surfaces in unrelated user sessions, not in the helper that introduced it. + +See sample: `prefer-readisolation-over-locktable-for-reads.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md new file mode 100644 index 000000000..f2904481e --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [table-size, hot-table, ledger-entry, item, customer, sales-line, scale] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Production-scale tables warrant concrete performance analysis + +## Description + +Some Business Central tables routinely reach sizes where access patterns matter much more than they do on a generic table. The upstream review guidance lists ten of them with P95 row counts: Item (~800k), Customer (~800k), Item Ledger Entry (~10M), Value Entry (~10M), G/L Entry (~10M), VAT Entry (~10M), Customer Ledger Entry (~10M), Vendor Ledger Entry (~10M), Sales Invoice Header (~300k), and Sales Invoice Line (~3M). These figures are not platform constants — they are the volumes a reviewer should assume when judging a change. + +## Best Practice + +For any code change that touches one of these tables, do not approve the pattern on intuition. Walk through the SQL the change implies (one query? one per row? one per chunk?), the memory it allocates (a `List` per row?), and the CPU work per row, against the row counts above. Smaller tables can tolerate a sub-optimal access pattern; these cannot. The rest of this domain — `apply-filters-before-iterating.md`, `use-setloadfields-for-partial-records.md`, `avoid-calcfields-in-loops.md`, `pair-findset-with-next-loop.md`, `avoid-get-inside-loop-on-persistent-tables.md` — exists primarily so that code touching these tables stays on the safe side of each rule. + +## Anti Pattern + +Generalizing from a unit test or a development tenant. A `FindSet` loop with a per-row `CalcFields` may execute in milliseconds against a few thousand rows on a developer's machine and become a multi-minute table scan against ten million Value Entry rows in production. Reasoning about performance from the dev-tenant timing instead of the production volume is the single most common way a regression ships. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al new file mode 100644 index 000000000..4ab3b0a2c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al @@ -0,0 +1,15 @@ +codeunit 50230 "Perf Sample SetCurrentKey Good" +{ + procedure ProcessLines(var SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetCurrentKey("Document Type", "Document No.", "Line No."); + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + if SalesLine.FindSet() then + repeat + // ... + until SalesLine.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md new file mode 100644 index 000000000..f7f8180ed --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [setcurrentkey, key, index, filter, sort] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pick a key whose fields cover the filter and sort with SetCurrentKey + +## Description + +The platform chooses a key for each record access. When the filters or required sort do not match the primary key — or any non-explicit choice — the query may run against a key that does not cover the filter columns. Per the upstream guidance, "Use `SetCurrentKey()` to select the most efficient key for your filters" and "match key fields to your filter/sort requirements." Filtering on fields that are not in any key is flagged as bad — there is no index to ride and the access ends up reading more than necessary. + +## Best Practice + +When the access pattern is anything other than primary-key lookup, look at the filters and the desired sort, then either pick an existing key whose leading fields cover them and call `SetCurrentKey(...)`, or declare a new key on the table for the pattern. Match leading fields first — a key starting with `"Document Type", "Document No.", "Line No."` serves a filter on those three; a key starting with `"Line No."` does not. + +See sample: `setcurrentkey-aligns-key-with-filters.good.al`. + +## Anti Pattern + +Applying filters on fields that no key indexes, leaving the platform to read more than it should. The query produces the right answer; the cost surfaces only at production volume. The mirror case is forgetting `SetCurrentKey` when the wanted sort differs from the primary key — the iteration may then be sorted in memory after a wider read than necessary. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md new file mode 100644 index 000000000..6b32de142 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [singleton, setup-table, sales-receivables-setup, general-ledger-setup, setloadfields, bounded] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Singleton setup tables hold one row; access-pattern optimization is wasted + +## Description + +Business Central setup tables — `Sales & Receivables Setup`, `General Ledger Setup`, `FA Setup`, `Purchases & Payables Setup`, and the broader pattern of any `*Setup` table — hold at most one record per company. Per the upstream guidance, "any access pattern is fine, no `SetLoadFields` needed" on these tables. The same applies to other small bounded tables (enum mappings, permission objects, Role IDs) and system metadata tables (`TableMetadata`, `Field`, `AllObjWithCaption`) where iteration is safe. + +## Best Practice + +Skip access-pattern optimization on singleton-setup-style tables. `SalesReceivablesSetup.Get()` does not need `SetLoadFields` (see `use-setloadfields-for-partial-records.md`); a `repeat ... until` over a permission-object table does not need bulk operations. Spend the review attention on the production-scale tables instead (see `production-scale-tables-warrant-extra-analysis.md`). + +## Anti Pattern + +Mechanically applying the rules in this domain to every `Record` variable in the codebase. Flagging "missing `SetLoadFields`" on `GeneralLedgerSetup` or "use `IsEmpty` instead of `FindSet`" on a setup table adds noise without payoff — the optimization saves nothing measurable on a one-row table — and trains readers to ignore the review channel. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md new file mode 100644 index 000000000..d799b5fbb --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [temporary-table, in-memory, findset, findfirst, get, no-db-cost] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Temporary tables are in-memory; access-pattern rules do not apply + +## Description + +A record declared `Temporary` (or a page with `SourceTableTemporary = true`) lives entirely in memory; reads and writes never reach SQL. Per the upstream guidance, "any access pattern (FindSet, FindFirst, Get, loops) on temp tables is acceptable — they are in-memory and fast." The rules in the rest of this domain — partial loading, bulk operations, N+1 detection, `IsEmpty` over `Count` — exist to avoid database round-trips that a temporary table does not perform. + +## Best Practice + +Recognize the `Temporary` property (on a record variable, table declaration, or page's `SourceTableTemporary`) and exempt the code from access-pattern flags. The `SetLoadFields`/`FindSet` discipline that matters for `Customer` does not matter for a temporary `Customer` variable used as a working set. The interesting performance question on a temp table is volume in memory, not query plan. + +## Anti Pattern + +Flagging a temporary table's `FindFirst` inside a loop, or a temporary table without `SetLoadFields`, as a performance issue. The recommendation produces no measurable gain and obscures genuine issues elsewhere in the same review. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md new file mode 100644 index 000000000..c4890a0b2 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [modifyall, deleteall, regression, triggers, media, getglobaltabletriggermask, subscriber] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Triggers, subscribers, and media fields can silently regress ModifyAll / DeleteAll + +## Description + +`ModifyAll` and `DeleteAll` usually execute as single SQL statements, but the platform falls back to a fetch-then-row-by-row loop under specific conditions. Per the upstream guidance, the regression is triggered by any of: global database triggers defined via `GetGlobalTableTriggerMask` or `GetDatabaseTableTriggerSetup` (so that `OnDatabaseDelete`/`OnGlobalDelete` must run); event subscribers on the table's `OnBeforeDelete`/`OnAfterDelete` (for `DeleteAll`) or `OnBeforeModify`/`OnAfterModify` (for `ModifyAll`); or "adding a Media or MediaSet table field to either the table or table extension." Each of these forces the platform to materialize each affected row in AL. + +## Best Practice + +Before introducing any of the above on a table — a global trigger registration, a `Modify`/`Delete` subscriber, a media or media-set field — note every `ModifyAll`/`DeleteAll` that targets the table and assess whether the regression cost is acceptable. The upstream guidance is explicit: "There should be a very good reason for doing any of the above since they will significantly regress performance of `ModifyAll` and/or `DeleteAll`." Once a table has regressed, multiple `ModifyAll` calls each iterate the rows themselves, so consolidating to one explicit `FindSet`+`Modify` loop becomes faster than chaining several `ModifyAll` calls. + +## Anti Pattern + +Adding a media field to a hot table — or subscribing to its modify/delete events from a generic logging codeunit — without auditing the bulk-write call sites. The schema change is mechanical; the performance change is invisible at the call site and only surfaces when a previously fast `ModifyAll` starts paying the per-row trigger cost in production. The mirror anti-pattern is chaining several `ModifyAll` calls on a table that has already regressed; each one re-iterates the same rows. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/understand-implicit-transaction-boundary.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/understand-implicit-transaction-boundary.md new file mode 100644 index 000000000..da9a1ebce --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/understand-implicit-transaction-boundary.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [commit, transaction, implicit-commit, write-transaction, runtime, boundary] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL auto-commits when code execution completes + +## Description + +In AL, write transactions are managed by the runtime, not by the developer. When AL code begins executing from an entry point — an outermost trigger, a codeunit invoked via `Codeunit.Run`, a report, a page action — the runtime opens a write transaction on the first database write. When that execution completes without error, the runtime commits automatically; if that execution errors, uncommitted writes are rolled back. Explicit `Commit()` is not how write transactions are *started*; it is how a single execution is *split* into multiple transactions. Per the platform reference, "The Commit method separates write transactions in an AL code module." + +## Best Practice + +Default to no explicit `Commit()`. Let the runtime open and close the transaction around the execution. Reach for `Commit()` only when the execution has a real reason to persist partial progress — for example, a long batch that must release locks between checkpoints (see `avoid-commit-inside-loops.md`), or work that calls an external service and must persist the resulting handle before continuing with operations that may fail independently. If a stretch of work needs to either complete fully or have no effect, prefer `Codeunit.Run` over manual Commit choreography (see `codeunit-run-as-atomic-sub-operation.md`). + +## Anti Pattern + +Sprinkling `Commit()` defensively — at the end of a procedure, after every Modify, or "just to be safe" — reflects a SQL-style mental model that does not apply here. Every stray Commit shortens the rollback window: work before the Commit survives later errors the developer almost certainly intended to unwind. A Commit without a specific reason is a bug waiting to surface. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al new file mode 100644 index 000000000..34f4ec3ec --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al @@ -0,0 +1,11 @@ +codeunit 50211 "Perf Sample GetByPK Bad" +{ + procedure ShowName(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + Customer.SetRange("No.", CustomerNo); + if Customer.FindFirst() then + Message(Customer.Name); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al new file mode 100644 index 000000000..d625150d5 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al @@ -0,0 +1,10 @@ +codeunit 50210 "Perf Sample GetByPK Good" +{ + procedure ShowName(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + Message(Customer.Name); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md new file mode 100644 index 000000000..06c038391 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, findfirst, primary-key, setrange, lookup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use Get when the full primary key is known; FindFirst is the wrong tool + +## Description + +`Get(...)` is the direct primary-key lookup. `FindFirst()` walks an index — even when narrowed by `SetRange` on every primary-key field. The upstream review guidance treats `Customer.SetRange("No.", CustomerNo); if Customer.FindFirst() then ...` as a bad pattern and `if Customer.Get(CustomerNo) then ...` as the correction. The two reach the same record; only `Get` expresses the lookup as a primary-key seek. + +## Best Practice + +When all primary-key fields are available at the call site, call `Get` (or `GetBySystemId`) with them. Reserve `FindFirst` for cases where the filter is on something other than the full primary key — a unique secondary field, a partial composite key, a sort that the caller cares about. + +See sample: `use-get-instead-of-findfirst-on-full-primary-key.good.al`. + +## Anti Pattern + +Composing `SetRange` calls that exactly cover the primary key and then calling `FindFirst`. The result is correct but the call site reads as "search the table" rather than "look up by key", which obscures both the intent and the access pattern from later reviewers. + +See sample: `use-get-instead-of-findfirst-on-full-primary-key.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al new file mode 100644 index 000000000..1ab47e731 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al @@ -0,0 +1,17 @@ +codeunit 50213 "Perf Sample IsEmpty Bad" +{ + procedure HasOpenSalesOrders(CustomerNo: Code[20]): Boolean + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); + // Count materializes a count the caller does not need. + if SalesHeader.Count() > 0 then + exit(true); + // FindFirst materializes a row the caller throws away. + if SalesHeader.FindFirst() then + exit(true); + exit(false); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al new file mode 100644 index 000000000..b1ea8d8c3 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al @@ -0,0 +1,11 @@ +codeunit 50212 "Perf Sample IsEmpty Good" +{ + procedure HasOpenSalesOrders(CustomerNo: Code[20]): Boolean + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); + exit(not SalesHeader.IsEmpty()); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-isempty-for-existence-check.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-isempty-for-existence-check.md new file mode 100644 index 000000000..ab574ecce --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-isempty-for-existence-check.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [isempty, count, findfirst, existence-check, exists] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use IsEmpty for existence checks, not Count() or FindFirst() + +## Description + +When the caller only needs to know whether any row matches a filter, `IsEmpty()` is the API designed for the question. Per the upstream guidance, "`IsEmpty()` is more efficient as it stops at first record found." `Count() > 0` materializes a count the caller does not need; `FindFirst()` materializes a row the caller does not need. Both do work that `IsEmpty` does not. + +## Best Practice + +Phrase existence checks as `if not Record.IsEmpty() then ...` (or `if Record.IsEmpty() then ...` for the negative). Apply filters via `SetRange`/`SetFilter` before the call so the existence check runs against the intended subset. Reserve `Count` for cases where the actual number matters and `FindFirst` for cases where the record fields are read. + +See sample: `use-isempty-for-existence-check.good.al`. + +## Anti Pattern + +`if Customer.Count() > 0 then ...` and `if Customer.FindFirst() then ...` (when the record is discarded) — both are flagged by the upstream guidance as the wrong tool. The first asks the database for the full count; the second asks for a row's fields. Both answers go unused. + +See sample: `use-isempty-for-existence-check.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al new file mode 100644 index 000000000..c9b4ce277 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al @@ -0,0 +1,14 @@ +codeunit 50219 "Perf Sample LoadFields Bad" +{ + procedure ListUSCustomerNames() + var + Customer: Record Customer; + begin + // Loads every Customer column on every row, when only Name is read. + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al new file mode 100644 index 000000000..772023c1a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al @@ -0,0 +1,23 @@ +codeunit 50218 "Perf Sample LoadFields Good" +{ + procedure ListUSCustomerNames() + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then + repeat + Message(Customer.Name); + until Customer.Next() = 0; + end; + + procedure LookupSkuPolicy(LocationCode: Code[10]) Policy: Enum "SKU Creation Method" + var + Location: Record Location; + begin + Location.SetLoadFields("SKU Creation Policy"); + if Location.Get(LocationCode) then + Policy := Location."SKU Creation Policy"; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md new file mode 100644 index 000000000..85d20b32c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [setloadfields, partial-record, normal-field, flowfield, get, findset] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SetLoadFields to load only the fields the code reads + +## Description + +`SetLoadFields(...)` declares the subset of normal fields the next read should materialize, "reducing data read and transfer thereby improving performance significantly." Per the upstream guidance, "the gains scale with the amount of rows read, so for loops that read many rows `SetLoadFields` is even more important." Primary-key fields, `SystemId`, and system audit fields are loaded automatically, "and fields that are filtered on are also automatically included" — those do not need to appear in the list. `SetLoadFields` only affects `FieldClass = Normal`; it does not narrow FlowFields or FlowFilters. + +## Best Practice + +Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. The pattern `SetLoadFields(...); if Record.Get(...) then ...` is the upstream-endorsed shape. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons (`singleton-setup-tables-need-no-access-optimization.md`, `temporary-tables-have-no-database-cost.md`). For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see `addloadfields-in-report-onpredataitem.md`). + +See sample: `use-setloadfields-for-partial-records.good.al`. + +## Anti Pattern + +Loading a wide table and reading one field per row in a loop. The bytes transferred per row are dominated by the columns the procedure does not touch; the SQL query selects them anyway. The same applies to a single `Get` on a wide table — the platform reads the whole row when a single field would have sufficed. + +See sample: `use-setloadfields-for-partial-records.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md new file mode 100644 index 000000000..f19636a7a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [textbuilder, string-concatenation, loop, append, immutable-text] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use TextBuilder for many string concatenations, especially inside loops + +## Description + +AL `Text` is immutable: each `Result += Piece;` allocates a new buffer and copies the previous content into it. Inside a loop the work is quadratic in the number of pieces. `TextBuilder` is the AL primitive designed for the pattern — per the upstream guidance, "Use `TextBuilder` when concatenating many strings together (for example inside loops)." Its `Append` mutates a growable internal buffer; `ToText()` materializes the final string once at the end. + +## Best Practice + +When a procedure assembles a string from many fragments — joining row data into a CSV, accumulating a log buffer, formatting a multi-line message inside a loop — declare a `TextBuilder` local, call `Append` per fragment, and call `ToText()` after the loop. For a fixed number of small fragments, `StrSubstNo` remains the right tool; the rule targets the loop case. + +## Anti Pattern + +`if Customer.FindSet() then repeat Csv += Customer."No." + ',' + Customer.Name + '\n'; until Customer.Next() = 0;` — every iteration reallocates and copies the entire string built so far. On a few hundred customers the cost is invisible; on the production-scale table list (`production-scale-tables-warrant-extra-analysis.md`) it dominates the loop. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al new file mode 100644 index 000000000..648bdfd8b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.bad.al @@ -0,0 +1,17 @@ +codeunit 50156 "Perf Sample TryFunc Bad" +{ + procedure ApplyDiscountAttempt(var Customer: Record Customer) + begin + if not TryApplyDiscount(Customer) then + Message('Discount not applied'); + end; + + [TryFunction] + local procedure TryApplyDiscount(var Customer: Record Customer) + begin + Customer.Validate("Customer Price Group", 'VIP'); + Customer.Modify(true); + if Customer."Credit Limit (LCY)" <= 0 then + Error('Customer %1 not eligible', Customer."No."); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al new file mode 100644 index 000000000..4e6f74724 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.good.al @@ -0,0 +1,23 @@ +codeunit 50155 "Perf Sample TryFunc Good" +{ + procedure ParseAndProcess(Payload: Text) + var + ParsedValue: Decimal; + begin + ClearLastError(); + if not TryParseDecimal(Payload, ParsedValue) then begin + LogParseFailure(Payload, GetLastErrorText()); + exit; + end; + end; + + [TryFunction] + local procedure TryParseDecimal(Input: Text; var Result: Decimal) + begin + Evaluate(Result, Input); + end; + + local procedure LogParseFailure(Payload: Text; Reason: Text) + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md new file mode 100644 index 000000000..bb015f623 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/performance/use-tryfunction-for-error-catching-not-rollback.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: performance +keywords: [try-function, try-method, error-handling, rollback, atomic, exception, get-last-error, session-buffer] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use [TryFunction] for error catching, Codeunit.Run for atomic rollback + +## Description + +`[TryFunction]` annotates a method so that errors raised inside it can be caught by the caller instead of propagating. Per the platform reference, "changes to the database that are made with a try method aren't rolled back" — the attribute catches the error; it does not unwind database state. This is the critical distinction from `Codeunit.Run`, which does roll back on error (see `codeunit-run-as-atomic-sub-operation.md`). A try function also only catches when its return value is used: "If the return variable for a call to a function, which is attributed with [TryFunction] isn't used, then the call isn't considered a try function call." `DoTry();` propagates errors normally; only `ok := DoTry();` or `if DoTry() then ...` catches. The return type is forced to Boolean; user-defined return types are not allowed, and the value isn't accessible inside the try method itself. On Business Central on-premises, writes inside a try method are blocked by default and raise a runtime error unless `DisableWriteInsideTryFunctions` is set to `false` on the server — SaaS has no such restriction. + +## Best Practice + +Reach for `[TryFunction]` when you want to catch a failure without unwinding the transaction — HTTP calls whose non-2xx responses should surface a user-friendly message, .NET interop whose exceptions you want to translate, validation or parsing routines whose errors you intend to log and continue past. Always capture the return: `if MyTry() then ... else HandleFailure(GetLastErrorText());`. When the work is transactional — writes that must either fully apply or fully revert — use `Codeunit.Run` instead. The two primitives solve different problems: one catches errors, the other bounds a rollback scope. + +Use `[TryFunction]` sparingly. Each caught error writes to the session-wide `GetLastErrorText` and `GetLastErrorCallStack` buffers, and every subsequent catch overwrites the earlier state — a helper that reads `GetLastErrorText` later may see a different error than the one it intended to inspect. Prefer explicit checks (non-throwing predicates, guard conditions, upfront validation) for operations with predictable failure modes; reserve `[TryFunction]` for genuinely unpredictable failures such as network calls, third-party interop, or evaluation of user-supplied expressions. When you do catch, read `GetLastErrorText` immediately after the failed call, and call `ClearLastError` before the call if an earlier catch in the same scope could have left state behind — per the platform reference, "If you call the GetLastErrorText method immediately after you call the ClearLastError method, then an empty string is returned." + +See sample: `use-tryfunction-for-error-catching-not-rollback.good.al`. + +## Anti Pattern + +Wrapping database writes in `[TryFunction]` expecting the writes to roll back when the method errors. They do not: the writes that succeeded before the error remain, the caller receives `false`, and the corrupted-state bug surfaces in production. A related anti-pattern is calling a try function without capturing the return (`DoTry();`), which silently strips the error-catching behavior and lets the error propagate — the code looks defensive but behaves identically to an unwrapped call. A third is defensive sprinkling: wrapping every operation that *could* theoretically error in `[TryFunction]` on the theory that catching is always safer than propagating. Each extra catch pollutes the shared error buffer and makes the diagnostic signal harder to find when something real does fail. + +See sample: `use-tryfunction-for-error-catching-not-rollback.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al new file mode 100644 index 000000000..08f170273 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al @@ -0,0 +1,11 @@ +codeunit 50207 "Privacy Sample StrSubstNo Bad" +{ + procedure ReportFailure(var Customer: Record Customer) + var + ErrorMsg: Text; + begin + ErrorMsg := StrSubstNo('Customer %1 (%2) at %3 has invalid data', + Customer.Name, Customer."E-Mail", Customer.Address); + Error(ErrorMsg); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al new file mode 100644 index 000000000..6886e2989 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al @@ -0,0 +1,9 @@ +codeunit 50206 "Privacy Sample StrSubstNo Good" +{ + procedure ReportFailure(var Customer: Record Customer) + var + CustomerInvalidErr: Label 'Customer %1 has invalid data (email: %2).', Comment = '%1 = Customer No., %2 = E-Mail'; + begin + Error(CustomerInvalidErr, Customer."No.", Customer."E-Mail"); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md new file mode 100644 index 000000000..7d4c1e72a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [strsubstno, error, telemetry, pii, prebuild, text-variable] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not pre-build an error string with `StrSubstNo` before calling `Error()` + +## Description + +`StrSubstNo` returns a plain `Text` value with the substitutions already performed. When that result is then passed to `Error()`, the platform sees a single plain-text parameter with no field references left to inspect, so it cannot apply `DataClassification` to anything inside it. Whatever PII the `StrSubstNo` call interpolated — customer name, e-mail, address, error text — is logged verbatim to telemetry. This is the canonical way to accidentally leak customer data through error telemetry, and it is the only `Error()` shape that needs to be flagged. + +## Best Practice + +Call `Error()` directly with the format string and the substitution parameters. The platform classifies each parameter individually and handles telemetry correctly even when the parameters are PII fields (see `error-direct-substitution-safe-for-telemetry.md`). If the message text needs to be a `Label`, pass the `Label` and the parameters to `Error()` — do not pre-render via `StrSubstNo`. + +See sample: `avoid-strsubstno-prebuild-before-error.good.al`. + +## Anti Pattern + +Assigning `StrSubstNo('Customer %1 (%2) ...', Customer.Name, Customer."E-Mail")` to a `Text` variable and then calling `Error(ErrorMsg)`. The platform has nothing to classify by the time `Error` runs — the PII is baked into the string and goes straight to telemetry. Detection signal for a reviewer: any `Text` variable assigned from `StrSubstNo` and later passed as the *only* parameter to `Error()`. + +See sample: `avoid-strsubstno-prebuild-before-error.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-is-table-field-property.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-is-table-field-property.md new file mode 100644 index 000000000..acfc0e787 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-is-table-field-property.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, page-field, table-field, api-page, card-page, list-page] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataClassification is a table-field property, not a page-field property + +## Description + +`DataClassification` is defined on table fields. Pages — including `Card`, `List`, `API`, and `ListPart` — do not own a classification; they simply expose fields whose classification is inherited from the underlying table. A page-level `DataClassification` property does not exist, so neither a missing nor a "wrong" classification can be reported against a page. When the underlying table field is misclassified, the fix is on the table definition, not on every page that surfaces the field. + +## Best Practice + +When reviewing a page that exposes a field believed to be under-classified, follow the field back to its source table and inspect (or correct) the `DataClassification` there. A single corrected table field propagates to every page, report and API that uses it. + +## Anti Pattern + +Flagging a page (or trying to add a `DataClassification` property to a page field) because the page displays personal data. Pages display data that authenticated, permissioned users are already entitled to see; the classification belongs on the table field that stores the data, not on the UI that renders it. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al new file mode 100644 index 000000000..72b217428 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al @@ -0,0 +1,11 @@ +tableextension 50201 "Customer Contact Ext Bad" extends Customer +{ + fields + { + field(50201; "Secondary Email"; Text[80]) + { + DataClassification = SystemMetadata; + Caption = 'Secondary Email'; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al new file mode 100644 index 000000000..aef930157 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al @@ -0,0 +1,11 @@ +tableextension 50200 "Customer Contact Ext" extends Customer +{ + fields + { + field(50200; "Secondary Email"; Text[80]) + { + DataClassification = CustomerContent; + Caption = 'Secondary Email'; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md new file mode 100644 index 000000000..808e10333 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, pii, gdpr, customer-content, table-field, under-classified] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataClassification is required on table fields containing sensitive data + +## Description + +`DataClassification` is the AL property that tells the platform what kind of data a table field stores so that telemetry, GDPR data-subject requests, and the platform's audit surfaces can treat it correctly. It is required on any field that holds personal or customer data. The default value `SystemMetadata` means "no user or customer data" — applying it to a field that actually holds PII (an email address, a customer name, an employee code) is an under-classification and a privacy bug, even though the code still compiles. + +## Best Practice + +Set `DataClassification` to the value that matches the data the field actually stores. A `Customer."E-Mail"`-style field is `CustomerContent` (data belonging to the tenant's customers); a personal identifier such as an employee number or user ID is `EndUserIdentifiableInformation` or `EndUserPseudonymousIdentifiers` depending on whether it is directly identifying. Choose the classification at field definition time — fixing it later is a schema change. + +See sample: `data-classification-required-on-pii-fields.good.al`. + +## Anti Pattern + +Declaring a field that stores PII with `DataClassification = SystemMetadata` to silence the compiler warning. The field compiles but the platform now treats customer data as system metadata in telemetry, GDPR exports and admin reports. + +See sample: `data-classification-required-on-pii-fields.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al new file mode 100644 index 000000000..1b8c88669 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al @@ -0,0 +1,10 @@ +codeunit 50205 "Privacy Sample Direct Error" +{ + procedure ValidateCustomer(var Customer: Record Customer) + var + InvalidEmailErr: Label 'Customer %1 has an invalid e-mail address: %2.', Comment = '%1 = Customer No., %2 = E-Mail'; + begin + if not Customer."E-Mail".Contains('@') then + Error(InvalidEmailErr, Customer."No.", Customer."E-Mail"); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md new file mode 100644 index 000000000..8653ecf5a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [error, strsubstno, direct-substitution, telemetry, classification, label] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `Error()` with direct substitution parameters is always safe for telemetry + +## Description + +When `Error()` is called with a format string and direct substitution parameters (`%1`, `%2`, …), the BC platform intercepts the call, inspects each parameter individually, and applies the `DataClassification` of the source field — stripping or masking sensitive data before writing the message to telemetry. This is true regardless of whether a parameter is a record field reference, a local variable, a function return value, or any other expression. Patterns such as `Error('Invalid email: %1', Customer."E-Mail")` are therefore safe even when the parameter is PII: the platform sees `Customer."E-Mail"` as a `CustomerContent` field reference and handles it correctly. + +## Best Practice + +Pass values to `Error()` as direct substitution parameters — either inline or via a `Label` with `Comment = '%1 = …'` placeholders. Let the platform do the per-parameter classification. This works equally well for record fields, local text variables, and document IDs. + +See sample: `error-direct-substitution-safe-for-telemetry.good.al`. + +## Anti Pattern + +Treating any `Error()` call that mentions PII as a leak. A review skill that flags `Error('Invalid email: %1', EmailAddress)` is wrong; the platform handles that pattern correctly. The only `Error()` shape that genuinely leaks PII to telemetry is the pre-built `StrSubstNo` form covered in `avoid-strsubstno-prebuild-before-error.md`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md new file mode 100644 index 000000000..f7372b7e9 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [error, message, confirm, notification, telemetry, logging, ui-dialog] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Only `Error()` is logged to telemetry — `Message`, `Confirm`, `Notification` are not + +## Description + +The privacy concern with dialog APIs is not what the signed-in user sees on the screen — it is what the platform writes to telemetry. The BC platform automatically captures `Error()` invocations in the telemetry stream; it does not capture `Message()`, `Confirm()` or `Notification` calls. That asymmetry is the reason privacy review focuses on `Error()` text and ignores the other dialog APIs: a `Message` that shows a customer's email to the signed-in user reveals nothing they were not already entitled to see, while an `Error` carrying the same email leaks it to a separate, longer-lived telemetry destination. + +## Best Practice + +Treat `Error()` as a telemetry surface, not just a UI surface — review the message text and parameters with the same scrutiny you apply to `Session.LogMessage`. Treat `Message()`, `Confirm()`, and `Notification` as pure UI: showing business data the user is permissioned for is normal functionality. + +## Anti Pattern + +Flagging `Message`/`Confirm`/`Notification` calls for "showing PII" — they are not logged to telemetry, and the user already has permission to the underlying data. The inverse anti-pattern is treating `Error()` as harmless because the user sees only a dialog: the message is also written verbatim to telemetry. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al new file mode 100644 index 000000000..822526984 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al @@ -0,0 +1,12 @@ +codeunit 50215 "Privacy Sample FeatureTelemetry Bad" +{ + procedure LogDocumentReleased(ExpenseHeader: Record "Sales Header"; var User: Record User) + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('EmployeeNo', ExpenseHeader."Sell-to Customer No."); + CustomDimensions.Add('UserName', User."Full Name"); + FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al new file mode 100644 index 000000000..6172f9df7 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al @@ -0,0 +1,10 @@ +codeunit 50214 "Privacy Sample FeatureTelemetry Good" +{ + procedure LogUptake() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUptake('0000EA2', 'Expense Agent', + Enum::"Feature Uptake Status"::"Set up"); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md new file mode 100644 index 000000000..6d8bfb5b8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [feature-telemetry, customdimensions, logusage, loguptake, logerror, pii, euii, eupi] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `FeatureTelemetry` `CustomDimensions` follow the same privacy rules as `Session.LogMessage` + +## Description + +`Codeunit "Feature Telemetry"` is the second telemetry surface in AL. Its methods — `LogUsage()`, `LogUptake()` and `LogError()` — each accept a `CustomDimensions` dictionary parameter whose contents are sent to telemetry as-is. The platform does not classify per-dimension values for you, so any customer or employee data placed into the dictionary is logged verbatim. The privacy rules that apply to `Session.LogMessage` message text apply to every value in `CustomDimensions`: no customer or employee names, email addresses, phone numbers (`CustomerContent`/EUII); no employee codes, user IDs or user security IDs (EUPI); no user-provided content (addresses, descriptions, notes); no `GetLastErrorText()` output. + +## Best Practice + +Pass only non-personal context through `CustomDimensions` — feature names, status enums, counts, error codes, durations. For uptake or usage signals that do not need per-call context, prefer the parameterless overload of `LogUptake`/`LogUsage` over a `CustomDimensions` dictionary that risks accreting PII over time. + +See sample: `featuretelemetry-customdimensions-no-pii.good.al`. + +## Anti Pattern + +`CustomDimensions.Add('EmployeeNo', ExpenseHeader."Employee No.")` followed by `FeatureTelemetry.LogUsage(...)` — the employee number is a pseudonymous user identifier (EUPI) and is now in telemetry. Same pattern with `'UserName'`, `'CustomerEmail'`, `'AttachmentName'` etc. + +See sample: `featuretelemetry-customdimensions-no-pii.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al new file mode 100644 index 000000000..c31ced8bd --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al @@ -0,0 +1,18 @@ +tableextension 50203 "Customer Order Stats" extends Customer +{ + fields + { + field(50203; "Open Order Count"; Integer) + { + FieldClass = FlowField; + CalcFormula = count("Sales Header" where("Sell-to Customer No." = field("No."))); + Caption = 'Open Order Count'; + } + + field(50204; "Date Filter"; Date) + { + FieldClass = FlowFilter; + Caption = 'Date Filter'; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md new file mode 100644 index 000000000..d3a1b7bae --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [flowfield, flowfilter, data-classification, systemmetadata, calculated] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FlowFields and FlowFilters are classified `SystemMetadata` automatically + +## Description + +`FlowField` and `FlowFilter` are not stored fields — a FlowField is computed from a CalcFormula at read time and a FlowFilter is a transient filter scoped to the record variable. Because nothing is ever written to the database for these fields, the platform automatically classifies them as `DataClassification = SystemMetadata` and AL does not require — or expect — the developer to set `DataClassification` on them. A FlowField that surfaces PII (e.g., a sum or lookup over a `CustomerContent` table) is still `SystemMetadata` at the FlowField level; the privacy classification lives on the underlying stored field that the CalcFormula references. + +## Best Practice + +Do not declare `DataClassification` on `FieldClass = FlowField` or `FieldClass = FlowFilter` fields — the inherited `SystemMetadata` is correct and the property is redundant. If a FlowField exposes sensitive data, ensure the underlying source field has the right `DataClassification`; that is where the platform reads classification from for GDPR and telemetry purposes. + +See sample: `flowfield-flowfilter-classification-systemmetadata.good.al`. + +## Anti Pattern + +Flagging a FlowField for "missing `DataClassification`" or trying to override it to `CustomerContent` because the formula references customer data. The platform's automatic `SystemMetadata` value is the documented, intentional behavior for non-stored fields; overriding it adds nothing and misrepresents the field as if it were stored. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al new file mode 100644 index 000000000..b9430317b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al @@ -0,0 +1,17 @@ +codeunit 50209 "Privacy Sample GetLastError Bad" +{ + procedure AddAttachment() + var + ErrorMsg: Text; + begin + if not TryAddAttachment() then begin + ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); + Error(ErrorMsg); + end; + end; + + [TryFunction] + local procedure TryAddAttachment() + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al new file mode 100644 index 000000000..4b07537be --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al @@ -0,0 +1,16 @@ +codeunit 50208 "Privacy Sample GetLastError Good" +{ + procedure AddAttachmentSafely() + var + AttachmentFailedErr: Label 'Failed to add email attachment. Please try again.'; + begin + if not TryAddAttachment() then + Error(AttachmentFailedErr); + end; + + [TryFunction] + local procedure TryAddAttachment() + begin + // ... attachment logic that may fail with a customer-data-bearing error ... + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md new file mode 100644 index 000000000..769a3f5a4 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [getlasterrortext, error, strsubstno, telemetry, customer-data, attachment] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Treat `GetLastErrorText()` as potential customer content + +## Description + +`GetLastErrorText()` returns the text of the last error that occurred in the context where it is called. That text routinely contains customer content — field values that triggered the validation, record keys, customer names, file names from upload failures, and similar fragments lifted from the failing operation. Re-emitting it through `StrSubstNo` into `Error()` bakes that customer data into a single plain-text parameter that the platform can no longer classify, so it is logged verbatim to telemetry (the same problem as any other `StrSubstNo`-pre-built error — see `avoid-strsubstno-prebuild-before-error.md`). + +## Best Practice + +When the goal is to surface a recoverable failure to the user, raise a generic message that does not embed `GetLastErrorText()` content, and log technical detail separately via `Session.LogMessage` with the correct `DataClassification`. If you must propagate the inner error verbatim, re-raise it as a direct parameter of `Error()` (e.g., `Error('%1', GetLastErrorText())`) rather than concatenating with `StrSubstNo` so the platform can apply its own handling. + +See sample: `getlasterrortext-customer-content-in-errors.good.al`. + +## Anti Pattern + +`ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); Error(ErrorMsg);` — the inner error text may carry filenames or record values, and `StrSubstNo` strips the platform's ability to filter them before they hit telemetry. + +See sample: `getlasterrortext-customer-content-in-errors.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md new file mode 100644 index 000000000..38fb6f263 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [in-memory, dictionary, list, temporary-table, variable, memory-dump] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In-memory variables, dictionaries, lists and temporary tables are not a privacy concern + +## Description + +AL runs in a managed server environment. Local variables, `Dictionary`, `List`, temporary `Record` variables, and other in-process data structures exist only for the duration of the request or session and are released by the runtime when it ends — they are not persisted, not visible across sessions, and not exposed outside the server process. Memory dumps are not a realistic threat vector against Business Central's hosted architecture, so holding business data (emails, names, addresses, document content) in these structures while processing a request is normal and expected. + +## Best Practice + +Use whatever in-memory shape (`Dictionary`, `List`, temporary tables, plain variables) the algorithm needs. The privacy review applies to *persistent* surfaces — table fields, telemetry, outgoing HTTP — not to per-request memory. + +## Anti Pattern + +Flagging a `Dictionary of [Text, Text]` populated with customer emails, or a temporary `Record Customer` holding rows mid-processing, as a privacy leak. These structures are scoped to the request and do not leave the server's memory. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/migration-destination-classification.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/migration-destination-classification.md new file mode 100644 index 000000000..afb8e8dd7 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/migration-destination-classification.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-migration, hybridsl, hybridgp, hybridbc, destination-classification, ssn, tin] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In data migration code, classify the destination — not the migration itself + +## Description + +Migration codeunits such as `HybridSL`, `HybridGP`, and `HybridBC` exist to copy sensitive data — TINs, Federal IDs, social security numbers, financial records — from a source system into Business Central. The fact that PII flows through these codeunits is the entire point of their existence, not a defect. The privacy concern is whether the destination field where the data lands carries the correct `DataClassification`. If it does, the migration is doing its job; if it doesn't, the right fix is on the destination table field, never on the migration code that writes to it. + +## Best Practice + +When reviewing a migration codeunit, trace each `Dest."" := Source.""` assignment to the destination field's `DataClassification`. Confirm that fields receiving PII (SSNs, Federal IDs, customer names, addresses) are classified `EndUserIdentifiableInformation` or `CustomerContent` as appropriate — and not left as `SystemMetadata` or `ToBeClassified`. + +## Anti Pattern + +Flagging the migration code itself for "processing sensitive data" or recommending that it filter, hash, or skip PII fields — these tables exist to migrate that data. The actionable finding is always on the destination field's classification, not on the migration's assignment statement. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al new file mode 100644 index 000000000..06970e2f4 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al @@ -0,0 +1,21 @@ +codeunit 50213 "Privacy Sample Telemetry Bad" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All, + 'Category', 'Privacy'); + end; + + procedure LogFileError(FileName: Text) + begin + Session.LogMessage('0001', StrSubstNo('Error processing file %1', FileName), Verbosity::Error, + DataClassification::SystemMetadata, TelemetryScope::All); + end; + + procedure LogEmployeeUpdate(EmployeeCode: Code[20]) + begin + Session.LogMessage('0002', StrSubstNo('Employee %1 updated record', EmployeeCode), Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al new file mode 100644 index 000000000..96a553ac1 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al @@ -0,0 +1,15 @@ +codeunit 50212 "Privacy Sample Telemetry Good" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage('0000', 'Customer record processed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All, + 'Category', 'Privacy'); + end; + + procedure LogFileError() + begin + Session.LogMessage('0001', 'Error processing uploaded file', Verbosity::Error, + DataClassification::SystemMetadata, TelemetryScope::All); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md new file mode 100644 index 000000000..e27d1e4ae --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md @@ -0,0 +1,33 @@ +--- +bc-version: [all] +domain: privacy +keywords: [telemetry, session-logmessage, strsubstno, pii, customer-data, employee-code, filename] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not embed customer data in the telemetry message text + +## Description + +`Session.LogMessage`'s message argument is a plain `Text`. Unlike `Error()`, the platform does not inspect this string field-by-field — whatever is in the text is what telemetry receives. So a call that builds the message via `StrSubstNo` from customer-bearing fields ships those values to telemetry verbatim, regardless of the `DataClassification` argument on the same call. Flagged content includes customer names, email addresses, phone numbers, addresses, employee codes or IDs, attachment filenames, user-provided text that may carry PII, and dumps of `Record` content. + +## Best Practice + +Keep the telemetry message a static, non-personal string ("Customer record processed", "Error processing uploaded file"). When structured context is genuinely needed, attach it through custom dimensions, where individual values can be reviewed and classified at the dimension level rather than baked into a free-text message. + +If a pseudonymous identifier (record `No.`, primary key value) genuinely belongs in the diagnostic, prefer attaching it through a custom dimension and set the call's `DataClassification` to match the data actually shipped — `EndUserPseudonymousIdentifiers` for pseudonymous IDs, `CustomerContent` for content-bearing telemetry. Changing the `DataClassification` alone does **not** make embedding a customer name into the message string acceptable; the data still ships in the message, and downstream consumers still see the literal string. + +See sample: `no-pii-in-telemetry-message-string.good.al`. + +## Related + +- `session-logmessage-requires-dataclassification.md` — every `Session.LogMessage` call must specify `DataClassification`; choose the value to match the actual data shipped, not the value that makes the entry retained the longest. +- `featuretelemetry-customdimensions-no-pii.md` — custom dimensions are the right surface for structured context, but they have their own PII rules. + +## Anti Pattern + +`Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), ...)` — the customer name is in telemetry the moment the line runs. Detection signal: a `StrSubstNo` whose result is the second argument of `Session.LogMessage`. The same shape with `FileName`, `EmployeeCode`, or any record field is the same problem. + +See sample: `no-pii-in-telemetry-message-string.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md new file mode 100644 index 000000000..6e8d62d21 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [page, card, list, api, listpart, permission-system, display, ui-dialog] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Displaying fields on a page (or in a UI dialog) is not a privacy concern + +## Description + +Every page in Business Central — `Card`, `List`, `API`, `ListPart`, request pages — renders data to an authenticated user who has been granted permission to see it. The BC permission system, not the page definition, controls who sees what; once a user is permissioned to a table, displaying any field of that table is normal business functionality. The same logic extends to `Message`, `Notification` and `Confirm` dialogs: the signed-in user already has access to the data the dialog is showing them. Privacy review for pages and dialogs is therefore the wrong layer — the actionable findings live on the underlying data (table-field classification, telemetry message text, outbound HTTP consent), not on the UI. + +## Best Practice + +When asked "is it OK to show this email/name/employee code on this page?", the answer is yes — provided the user has permission to the underlying record. Drive privacy concerns to the data layer (classification, telemetry, external transfer) rather than the UI layer. + +## Anti Pattern + +Flagging an API page, list, card, or notification for surfacing customer-bearing fields (`E-Mail`, `Name`, `Phone No.`, audit fields, `User ID`). The permission system governs visibility; the page does not. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al new file mode 100644 index 000000000..63086742b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al @@ -0,0 +1,13 @@ +codeunit 50217 "Privacy Sample Consent Bad" +{ + procedure SendDataToExternalService(Customer: Record Customer) + var + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', + Customer."E-Mail", Customer.Name)); + HttpClient.Post('https://api.externalservice.com/sync', Content, Response); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al new file mode 100644 index 000000000..0ac939c23 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al @@ -0,0 +1,22 @@ +codeunit 50216 "Privacy Sample Consent Good" +{ + procedure SendDataToExternalService(Customer: Record Customer) + var + PrivacyNotice: Codeunit "Privacy Notice"; + PrivacyNoticeRegistrations: Codeunit "Privacy Notice Registrations"; + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + PrivacyConsentRequiredErr: Label 'Privacy notice consent is required for this integration.'; + begin + if PrivacyNotice.GetPrivacyNoticeApprovalState( + PrivacyNoticeRegistrations.GetExchangePrivacyNoticeId()) + <> "Privacy Notice Approval State"::Agreed + then + Error(PrivacyConsentRequiredErr); + + Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', + Customer."E-Mail", Customer.Name)); + HttpClient.Post('https://api.externalservice.com/sync', Content, Response); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md new file mode 100644 index 000000000..a0647921e --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, getprivacynoticeapprovalstate] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Outgoing requests to external services require a Privacy Notice consent check + +## Description + +Business Central ships a built-in Privacy Notice framework that the admin uses to grant or withhold per-integration consent for sending data to external services. The relevant API surface is `Codeunit "Privacy Notice"` (consent checks via `GetPrivacyNoticeApprovalState()`), `Codeunit "Privacy Notice Registrations"` (well-known notice IDs for integrations such as Exchange, OneDrive, Teams), and the `Enum "Privacy Notice Approval State"` with values `Agreed`, `Disagreed`, and `Not Set`. The admin UI is the **Privacy Notices Status** page. The compliance concern in code review is therefore not that personal data is included in an outgoing HTTP body — that is normal business functionality — but that the code path issuing the request contains no `PrivacyNotice.GetPrivacyNoticeApprovalState(...)` check. + +## Best Practice + +Before issuing an outgoing HTTP request to an external service, verify `PrivacyNotice.GetPrivacyNoticeApprovalState() = "Privacy Notice Approval State"::Agreed`. The check does not have to live next to the `HttpClient.Post` call — it can sit anywhere upstream in the same code path (for example in the page's `OnOpenPage`, in a wizard step, or in a setup action) as long as no execution path reaches the request without passing through it. + +See sample: `privacy-notice-consent-for-external-data-transfer.good.al`. + +## Anti Pattern + +A `procedure SendDataToExternalService(...)` that posts customer data to an external endpoint with no `PrivacyNotice.GetPrivacyNoticeApprovalState` anywhere upstream. The same anti-pattern applies in reverse: removing an existing privacy-notice check from code that still issues the external call. + +See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al new file mode 100644 index 000000000..d8a20f519 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al @@ -0,0 +1,11 @@ +codeunit 50218 "Privacy Sample Register Integration" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Privacy Notice Registrations", 'OnRegisterPrivacyNotices', '', false, false)] + local procedure OnRegisterPrivacyNotices(var TempPrivacyNotice: Record "Privacy Notice" temporary) + var + PrivacyNotice: Codeunit "Privacy Notice"; + begin + PrivacyNotice.CreatePrivacyNoticeForIntegration( + 'My External Sync', 'External Customer Sync Service'); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md new file mode 100644 index 000000000..40779e9cf --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [privacy-notice-registrations, integration, register, exchange, onedrive, teams, notice-id] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register every new external integration with `Privacy Notice Registrations` + +## Description + +`Codeunit "Privacy Notice Registrations"` is the registry of integrations whose consent state the platform tracks. Built-in integrations such as Exchange, OneDrive and Teams already have notice IDs exposed via accessor methods on this codeunit (`GetExchangePrivacyNoticeId`, etc.); a new integration introduced by an extension must add itself to the registry so that the admin can grant or withhold consent on the **Privacy Notices Status** page. Without registration, there is nothing for `Codeunit "Privacy Notice"` to return an approval state for — the call cannot meaningfully gate the outbound request. + +## Best Practice + +When introducing a new outbound integration: pick a stable notice ID, register it via `Privacy Notice Registrations`, and then gate every outbound call with `PrivacyNotice.GetPrivacyNoticeApprovalState()` as described in `privacy-notice-consent-for-external-data-transfer.md`. + +See sample: `register-integration-in-privacy-notice-registrations.good.al`. + +## Anti Pattern + +Shipping a new outbound integration without registering it. Even if the code calls `GetPrivacyNoticeApprovalState`, the admin has no surface to express consent — the integration is effectively unmanaged from a privacy-notice standpoint. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md new file mode 100644 index 000000000..682070413 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [tobeclassified, data-classification, release, appsource, development] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Resolve every `ToBeClassified` before release + +## Description + +`DataClassification = ToBeClassified` is the sentinel value the AL compiler accepts while a developer has not yet decided what a new field actually stores. It exists for the development phase only and must be resolved to a real classification (`CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `AccountData`, `OrganizationIdentifiableInformation` or `SystemMetadata`) before the code ships. A released field left at `ToBeClassified` tells the platform "we have not classified this data" — which means GDPR data-subject requests, telemetry and audit reports cannot reason about it. + +## Best Practice + +Treat `ToBeClassified` as a TODO marker that fails release readiness. Sweep new table objects and table extensions for it before submitting a build for publication. If the right classification is genuinely unclear, decide between `CustomerContent` and `EndUserIdentifiableInformation` from the data's content, not from convenience. + +## Anti Pattern + +Leaving `ToBeClassified` in a shipped extension. Reviewers who treat the value as "I'll figure it out later" ship a field whose privacy posture is undefined for every customer that installs the app. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al new file mode 100644 index 000000000..e895d7c71 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al @@ -0,0 +1,7 @@ +codeunit 50211 "Privacy Sample LogMessage Bad" +{ + procedure LogCompleted() + begin + Session.LogMessage('0003', 'Operation completed', Verbosity::Normal); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al new file mode 100644 index 000000000..d3353ec45 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al @@ -0,0 +1,8 @@ +codeunit 50210 "Privacy Sample LogMessage Good" +{ + procedure LogCompleted() + begin + Session.LogMessage('0003', 'Operation completed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md new file mode 100644 index 000000000..67381f7e6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [session-logmessage, telemetry, data-classification, verbosity, telemetry-scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every `Session.LogMessage` call must specify `DataClassification` + +## Description + +`Session.LogMessage` writes a record to the telemetry pipeline. The platform requires the call to carry an explicit `DataClassification` argument so that the entry can be routed and retained correctly downstream — telemetry consumers, GDPR exports, and Application Insights dashboards all rely on it. The compiler accepts overloads without the parameter (the two-argument and three-argument shapes that omit it), but for any telemetry that ships to customers, the `DataClassification`-bearing overload is the correct one. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al new file mode 100644 index 000000000..e1e580853 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al @@ -0,0 +1,16 @@ +table 50202 "System Configuration Log" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Changed By"; Code[50]) { } + field(3; "Change Description"; Text[250]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/table-level-data-classification-cascades.md new file mode 100644 index 000000000..bf457e939 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, table-level, inheritance, override, cascading] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Table-level DataClassification cascades to every field unless overridden + +## Description + +`DataClassification` may be set at the table level. When it is, every field in the table inherits that classification and individual fields do not need their own `DataClassification` property. The cascade is the platform's intended way of classifying tables whose fields are homogeneous — for example, a system configuration log whose every column is `SystemMetadata`. A field only needs its own classification when its content genuinely differs from the table's default and the inherited value would be wrong. + +## Best Practice + +Set `DataClassification` once at the table level whenever every field in the table shares the same classification. Omit field-level `DataClassification` properties in that case. Override only on the specific fields whose data class differs from the table's — for example, a `SystemMetadata` audit table that nonetheless captures a `CustomerContent` value somewhere. + +See sample: `table-level-data-classification-cascades.good.al`. + +## Anti Pattern + +Flagging individual fields for "missing `DataClassification`" when the table declares one — the inheritance is the correct, intentional pattern. The mirror anti-pattern is repeating the same `DataClassification` on every field of a table that already declares it at the table level; the property is redundant and adds nothing the platform did not already know. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al new file mode 100644 index 000000000..ab698a164 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al @@ -0,0 +1,7 @@ +codeunit 50227 "Sec Sample HtmlEncode Bad" +{ + procedure BuildWelcomeHtml(UserName: Text): Text + begin + exit('
Welcome ' + UserName + '!
'); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al new file mode 100644 index 000000000..6da1911b5 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al @@ -0,0 +1,19 @@ +codeunit 50226 "Sec Sample HtmlEncode Good" +{ + procedure BuildWelcomeHtml(UserName: Text): Text + var + SafeName: Text; + begin + SafeName := EncodeHtml(UserName); + exit('
Welcome ' + SafeName + '!
'); + end; + + local procedure EncodeHtml(Value: Text): Text + begin + Value := Value.Replace('&', '&'); + Value := Value.Replace('<', '<'); + Value := Value.Replace('>', '>'); + Value := Value.Replace('"', '"'); + exit(Value); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md new file mode 100644 index 000000000..7441712de --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [html, xss, encoding, htmlencode, injection, email] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL has no built-in HtmlEncode — encode HTML output by hand or avoid it + +## Description + +AL does not ship a built-in `HtmlEncode` (or equivalent) function. Code that builds an HTML fragment — an email body, a report header, a chart label rendered as HTML — by concatenating record-field values into a string is therefore unencoded by default, and any `<`, `>`, `&`, or `"` in the user content is interpreted as markup by the receiving renderer. The result is cross-site scripting in the recipient's mail client, browser, or report viewer. The absence of a built-in encoder is non-obvious to anyone used to platforms where `HtmlEncode` is a one-liner. + +## 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`. + +## 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 (`<`, ` 200 then + Error('API key too long for encrypted storage'); + IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module); + end; + + local procedure ReadApiKey(var ApiKey: SecretText): Boolean + begin + if not IsolatedStorage.Contains('ApiKey', DataScope::Module) then + exit(false); + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md new file mode 100644 index 000000000..4e4f6b9f6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, setencrypted, encryption, secret, storage] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer IsolatedStorage.SetEncrypted over Set for sensitive values + +## Description + +`IsolatedStorage` exposes two write entry points: `Set` stores the value as-is, and `SetEncrypted` stores it encrypted at rest. Both are scoped per extension, but only `SetEncrypted` adds the additional protection that the value is not readable from the underlying storage by anything that bypasses the AL `IsolatedStorage` API. The choice between them is by intent: configuration that is not sensitive (a user preference, a default flag) can use `Set`; anything that would harm the tenant if leaked — API keys, tokens, connection strings, OAuth client secrets — uses `SetEncrypted`. + +## Best Practice + +Use `IsolatedStorage.SetEncrypted` for every value that meets the definition of a secret. Pair it with the matching retrieval pattern: `IsolatedStorage.Contains` to test for presence and `IsolatedStorage.Get` (preferably with a `SecretText` destination) to read. Constrain the input length before storing — long values can exceed the encrypted-storage size limit and the write will fail at runtime. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`. + +## Anti Pattern + +`IsolatedStorage.Set('ApiKey', ApiKeyValue, DataScope::Module)` — the key is now sitting in storage unencrypted, and any future incident that exposes the underlying storage exposes the key. Reviewers should flag any `IsolatedStorage.Set` whose key name or surrounding context suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `ClientSecret`). See sample: `isolatedstorage-setencrypted-for-sensitive-values.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al new file mode 100644 index 000000000..a22b60fd3 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al @@ -0,0 +1,19 @@ +codeunit 50214 "Sec Sample NonDebug Bad" +{ + procedure BuildConnectionString(ApiKey: SecretText): Text + begin + exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + end; + + procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) + var + ResponseText: Text; + JsonObject: JsonObject; + JsonToken: JsonToken; + begin + Response.Content.ReadAs(ResponseText); + JsonObject.ReadFrom(ResponseText); + JsonObject.Get('access_token', JsonToken); + SessionToken := JsonToken.AsValue().AsText(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al new file mode 100644 index 000000000..421e9bd80 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al @@ -0,0 +1,21 @@ +codeunit 50213 "Sec Sample NonDebug Good" +{ + [NonDebuggable] + procedure BuildConnectionString(ApiKey: SecretText): Text + begin + exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + end; + + [NonDebuggable] + procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) + var + ResponseText: Text; + JsonObject: JsonObject; + JsonToken: JsonToken; + begin + Response.Content.ReadAs(ResponseText); + JsonObject.ReadFrom(ResponseText); + JsonObject.Get('access_token', JsonToken); + SessionToken := JsonToken.AsValue().AsText(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md new file mode 100644 index 000000000..b214977ba --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [nondebuggable, attribute, secrettext, unwrap, debugger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mark procedures that call SecretText.Unwrap() as [NonDebuggable] + +## Description + +`SecretText` transit — assignment, parameter passing, and return values — is auto-protected: the debugger sees a redacted placeholder, not the value. The protection ends the moment code calls `.Unwrap()`, which converts the `SecretText` back to plain `Text`. From that point on, the local variable holding the result is visible in the debugger like any other `Text`. The `[NonDebuggable]` attribute marks a procedure so that none of its locals or parameters are visible to the debugger during execution, which is exactly what is needed for any procedure that performs an `Unwrap()` or that otherwise materializes a secret as `Text` (for example, while parsing a JSON response to extract an access token). + +## Best Practice + +Apply `[NonDebuggable]` to any procedure whose body calls `.Unwrap()` on a `SecretText`, and to any procedure that constructs a `SecretText` from a `Text` source (such as a procedure that reads a JSON response body and converts the resulting `Text` into a `SecretText` for the caller). Keep the unwrap window as small as possible — ideally a single one-line helper that hands the unwrapped value straight to the consuming API. See sample: `nondebuggable-required-when-unwrapping-secrettext.good.al`. + +## Anti Pattern + +Calling `ApiKey.Unwrap()` inside a procedure that is not marked `[NonDebuggable]`. The unwrapped value is now an ordinary `Text` local and the debugger will display it, defeating the purpose of using `SecretText` in the first place. Reviewers should flag any `Unwrap()` call in a procedure that lacks the attribute, and any procedure that parses a credential out of a response (`access_token`, `id_token`, `client_secret`) without the attribute. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al new file mode 100644 index 000000000..054892d95 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al @@ -0,0 +1,10 @@ +permissionset 50201 "Sec Sample Full Access" +{ + Permissions = tabledata * = RIMD; +} + +permissionset 50202 "Sec Sample Basic User" +{ + Permissions = table * = X, + tabledata * = R; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al new file mode 100644 index 000000000..5a3c8a349 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al @@ -0,0 +1,8 @@ +permissionset 50200 "Sec Sample Sales Entry" +{ + Permissions = tabledata "Sales Header" = RIM, + tabledata "Sales Line" = RIMD, + tabledata Customer = R, + table "Sales Header" = X, + table "Sales Line" = X; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md new file mode 100644 index 000000000..20332b2f1 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [permissionset, wildcard, rimd, tabledata, least-privilege] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid wildcard grants in permission sets + +## Description + +A `permissionset` object can grant access object-by-object or with the `*` wildcard. Wildcard grants — `tabledata * = RIMD` (Read/Insert/Modify/Delete on every table) and `table * = X` (Execute on every table object) — collapse the principle of least privilege into a single line and are almost never what the author intended. The grant binds for the lifetime of the permission set wherever it is assigned, including indirectly via role assignment. Permission sets should be granular and role-specific, enumerating only the objects the role actually needs. + +## Best Practice + +Enumerate each `tabledata` and each `table` entry explicitly. Grant only the letters required: `R` for read-only consumers, `RIM` for editors that do not delete, `RIMD` only for owners of the data. When a role needs Execute on objects, list those objects rather than using `table *`. See sample: `permission-set-avoid-wildcard-grants.good.al`. + +## Anti Pattern + +`Permissions = tabledata * = RIMD;` and `Permissions = table * = X, tabledata * = R;` — both grant access to objects the role's author never inspected, and the grant silently broadens every time a new table ships in the platform or in another extension. Reviewers should flag any `*` on the left-hand side of a `tabledata` or `table` entry. See sample: `permission-set-avoid-wildcard-grants.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al new file mode 100644 index 000000000..d0977e451 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al @@ -0,0 +1,12 @@ +codeunit 50233 "Sec Sample RecRef Bad" +{ + procedure ArchiveRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al new file mode 100644 index 000000000..9bd4bec67 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al @@ -0,0 +1,29 @@ +codeunit 50232 "Sec Sample RecRef Good" +{ + internal procedure ArchiveRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; + + procedure ArchiveAllowedRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + if not IsAllowedTable(RecId.TableNo) then + Error('Operation not permitted on this table.'); + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; + + local procedure IsAllowedTable(TableNo: Integer): Boolean + begin + exit(TableNo in [Database::Customer, Database::Vendor]); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md new file mode 100644 index 000000000..c84e15a34 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [recordref, open, public, system-table, scope-onprem, confused-deputy, saas] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Procedures that RecordRef.Open a caller-provided table must not be public + +## Description + +When a codeunit holds permission to system tables — directly, via a permission set granted at install, or via `[InherentPermissions]` — and exposes a public procedure that accepts a table number (or a `RecordId`, from which the table number is derived) and calls `RecordRef.Open` on it, the procedure becomes a confused deputy. Any other extension on the same tenant can invoke the procedure with the table number of a system table the calling extension does not own permissions for and obtain access to its rows through the wrapper. This is especially acute in SaaS: an on-premises-style extension that holds broad permissions can be exploited by a co-tenant extension that calls its public surface. + +## Best Practice + +Mark such procedures `local` (callable only inside the containing object), `internal` (callable only inside the owning extension), or `[Scope('OnPrem')]` (not callable from SaaS extensions). If the procedure must be public, validate the table number against an allow-list before `RecordRef.Open` — `if not IsAllowedTable(RecId.TableNo) then Error(...)` — so the caller cannot specify an arbitrary table. See sample: `recordref-open-with-caller-table-must-not-be-public.good.al`. + +## Anti Pattern + +`procedure ArchiveRecord(RecId: RecordId)` (public by default) whose body calls `RecRef.Open(RecId.TableNo)` and then reads, modifies, or deletes the record. Reviewers should flag any procedure that is public (no `local`/`internal`/`[Scope('OnPrem')]`), takes a `RecordId`, `Integer` table number, or `Variant` as a parameter, and calls `RecordRef.Open` with that parameter — unless an allow-list check on the table number precedes the open. See sample: `recordref-open-with-caller-table-must-not-be-public.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al new file mode 100644 index 000000000..84dda4542 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al @@ -0,0 +1,12 @@ +codeunit 50212 "Sec Sample SecretSubst Bad" +{ + procedure BuildAuthHeader(Token: SecretText): Text + begin + exit(StrSubstNo('Bearer %1', Token.Unwrap())); + end; + + procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): Text + begin + exit(BaseUrl + '?key=' + ApiKey.Unwrap()); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al new file mode 100644 index 000000000..f550025c0 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al @@ -0,0 +1,12 @@ +codeunit 50211 "Sec Sample SecretSubst Good" +{ + procedure BuildAuthHeader(Token: SecretText): SecretText + begin + exit(SecretStrSubstNo('Bearer %1', Token)); + end; + + procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): SecretText + begin + exit(SecretStrSubstNo('%1?key=%2', BaseUrl, ApiKey)); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md new file mode 100644 index 000000000..6e315f749 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secretstrsubstno, secrettext, strsubstno, format, compose] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SecretStrSubstNo to compose strings that contain secrets + +## Description + +`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It accepts a format string and arguments (any of which may be `SecretText`) and returns a `SecretText` — the substitution happens without ever materializing the result as plain `Text`. It is the right tool whenever a secret needs to be embedded in a larger string: an `Authorization: Bearer ` header value, a URI that includes an API key as a query parameter, or any other interpolation that combines a `SecretText` with surrounding context. + +## Best Practice + +Compose every secret-bearing string through `SecretStrSubstNo` and keep the result as `SecretText` end-to-end. Pass the result to the `SecretText` overload of the consumer — `HttpClient.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: `secretstrsubstno-for-composing-secrets.good.al`. + +## Anti Pattern + +Calling `StrSubstNo('Bearer %1', Token.Unwrap())` to build the header value, or concatenating `'Bearer ' + Token.Unwrap()`. Both produce a plain `Text` containing the secret, which is then visible in the debugger and in any subsequent log or trace. Reviewers should flag any `Unwrap()` whose result is fed into `StrSubstNo` or used in `+` concatenation — `SecretStrSubstNo` removes the need for either. See sample: `secretstrsubstno-for-composing-secrets.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-for-credentials.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-for-credentials.bad.al new file mode 100644 index 000000000..5b5ac2362 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-for-credentials.bad.al @@ -0,0 +1,22 @@ +codeunit 50208 "Sec Sample SecretText Bad" +{ + procedure CallExternalApi() + var + ApiKey: Text; + BearerToken: Text; + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + ApiKey := GetApiKey(); + BearerToken := GetAccessToken(); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', 'Bearer ' + BearerToken); + Headers.Add('X-Api-Key', ApiKey); + HttpClient.Get('https://api.example.com/data', Response); + end; + + local procedure GetApiKey(): Text begin end; + + local procedure GetAccessToken(): Text begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-for-credentials.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-for-credentials.good.al new file mode 100644 index 000000000..d98f127a1 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-for-credentials.good.al @@ -0,0 +1,16 @@ +codeunit 50207 "Sec Sample SecretText Good" +{ + procedure CallExternalApi() + var + ApiKey: SecretText; + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + if IsolatedStorage.Contains('ApiKey', DataScope::Module) then + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('X-Api-Key', ApiKey); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-for-credentials.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-for-credentials.md new file mode 100644 index 000000000..17fec22ec --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-for-credentials.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secrettext, credentials, api-key, token, debugger, unwrap] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SecretText for credentials, API keys, and tokens + +## Description + +`SecretText` is the AL data type for values that should never appear in a debugger session, in a log, or in a variable watch. The compiler enforces two guarantees: a string literal cannot be assigned directly to a `SecretText` variable, and a `SecretText` cannot be assigned back to a `Text` or `Code` without an explicit `Unwrap` call. Together these prevent the two common accidents — embedding a secret in source code, and quietly converting a secret to plain text where the debugger can read it. Use `SecretText` for parameters, return values, and local variables that carry API keys, tokens, passwords, connection strings, or any other value an attacker with debugger access should not see. + +## Best Practice + +Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an `HttpClient` header or URI). Never round-trip through `Text` — every conversion is a potential exposure point. Retrieve secrets from `IsolatedStorage` with the `SecretText` overload of `Get` rather than the `Text` overload. See sample: `secrettext-for-credentials.good.al`. + +## Anti Pattern + +Holding a credential in a `Text` variable (`BearerToken: Text`), concatenating it into a header, then passing it to `HttpClient`. The token is visible in the debugger and in any error that prints the variable, and the compiler offers no help because the type was wrong from the start. Reviewers should flag any local or parameter named like a secret (`ApiKey`, `Token`, `Password`, `ClientSecret`) whose type is `Text` or `Code`. See sample: `secrettext-for-credentials.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-with-httpclient.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-with-httpclient.bad.al new file mode 100644 index 000000000..6ddb883de --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-with-httpclient.bad.al @@ -0,0 +1,23 @@ +codeunit 50210 "Sec Sample SecretHttp Bad" +{ + procedure CallApiWithSecretInUri(ApiKey: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + RequestUri: Text; + begin + RequestUri := 'https://api.example.com/data?key=' + ApiKey.Unwrap(); + HttpClient.Get(RequestUri, Response); + end; + + procedure CallApiWithBearer(BearerToken: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', 'Bearer ' + BearerToken.Unwrap()); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-with-httpclient.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-with-httpclient.good.al new file mode 100644 index 000000000..50f0e31bd --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-with-httpclient.good.al @@ -0,0 +1,28 @@ +codeunit 50209 "Sec Sample SecretHttp Good" +{ + procedure CallApiWithSecretUri(ApiKey: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + SecretUri: SecretText; + begin + SecretUri := SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey); + HttpClient.SetSecretRequestUri(SecretUri); + HttpClient.Get('', Response); + end; + + procedure CallApiWithBearer(BearerToken: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + AuthHeader: SecretText; + begin + AuthHeader := SecretStrSubstNo('Bearer %1', BearerToken); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', AuthHeader); + if not Headers.ContainsSecret('Authorization') then + Error('Authorization header missing'); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-with-httpclient.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-with-httpclient.md new file mode 100644 index 000000000..f8be895a4 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/secrettext-with-httpclient.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secrettext, httpclient, setsecretrequesturi, containssecret, headers, http] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the SecretText-aware HttpClient surface for secrets in requests + +## Description + +`HttpClient` and its companion types expose a parallel surface that accepts `SecretText` instead of `Text`, so that secret URIs, secret headers, and secret request bodies never round-trip through plain text. The key entry points are: `HttpClient.SetSecretRequestUri()` for URIs that contain secrets (the subsequent `Get`/`Post` is then called with an empty string); `HttpHeaders.Add()` overload that accepts a `SecretText` value for authorization headers; `HttpHeaders.ContainsSecret()` to test whether a secret header is present (the plain `Contains()` returns false for secret headers); `HttpContent.WriteFrom()` and `HttpContent.ReadAs()` overloads that accept and produce `SecretText` for request and response bodies that carry credentials. + +## Best Practice + +When the URI contains a secret query parameter, compose it as `SecretText` (see `secretstrsubstno-for-composing-secrets.md`), pass it to `SetSecretRequestUri`, and call `Get('', Response)` with an empty string as the URI argument. When the credential is an authorization header, build the header value as `SecretText` and pass it to `Headers.Add`. Use `ContainsSecret` rather than `Contains` to check for the presence of a secret header. See sample: `secrettext-with-httpclient.good.al`. + +## Anti Pattern + +Calling `ApiKey.Unwrap()` to build a URI or header string and passing the resulting `Text` to `HttpClient.Get` or `Headers.Add`. The unwrapped secret is now visible in the debugger, in any HTTP trace that captures the request URI, and in any error that includes the URI. Reviewers should flag any `Unwrap()` call whose result flows into an `HttpClient` argument; the `SecretText` overload exists precisely so the unwrap is not needed. See sample: `secrettext-with-httpclient.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validate-user-configurable-urls.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validate-user-configurable-urls.bad.al new file mode 100644 index 000000000..a5b0658fd --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validate-user-configurable-urls.bad.al @@ -0,0 +1,20 @@ +codeunit 50222 "Sec Sample UrlValidation Bad" +{ + procedure SyncWithExternalService(ServiceUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + begin + HttpClient.Get(ServiceUrl, Response); + end; + + procedure SendWebhookNotification(CallbackUrl: Text; Payload: Text) + var + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + Content.WriteFrom(Payload); + HttpClient.Post(CallbackUrl, Content, Response); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validate-user-configurable-urls.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validate-user-configurable-urls.good.al new file mode 100644 index 000000000..0925b1492 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validate-user-configurable-urls.good.al @@ -0,0 +1,24 @@ +codeunit 50221 "Sec Sample UrlValidation Good" +{ + procedure SyncWithExternalService(ServiceUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Uri: Codeunit Uri; + begin + if not Uri.AreURIsHaveSameHost(ServiceUrl, 'https://api.contoso.com') then + Error('Service URL must point to api.contoso.com'); + HttpClient.Get(ServiceUrl, Response); + end; + + procedure SyncWithShopify(ShopUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Uri: Codeunit Uri; + begin + if not Uri.IsValidURIPattern(ShopUrl, 'https://*.myshopify.com/*') then + Error('Shop URL must match the Shopify pattern'); + HttpClient.Get(ShopUrl + '/admin/api/2024-01/orders.json', Response); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validate-user-configurable-urls.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validate-user-configurable-urls.md new file mode 100644 index 000000000..06cc31d1b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validate-user-configurable-urls.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [ssrf, uri, url-validation, areurishavesamehost, isvaliduripattern, httpclient] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate URLs that come from table fields before calling them + +## Description + +A URL stored in a table field is user-configurable: anyone with write access to the row can change it. If that URL is then used as the target of an `HttpClient.Get`/`Post`, the extension becomes a server-side request forgery (SSRF) primitive — an attacker can redirect the call to an internal endpoint, to a metadata service, or to a malicious host that mirrors the legitimate API. The `Uri` codeunit from System Modules provides two validators built for this situation: `AreURIsHaveSameHost()` checks that two URLs share the same host (use when the hostname should not change — for example, the extension always talks to `api.contoso.com`). `IsValidURIPattern()` checks that a URL matches a wildcard pattern (use when the host varies but follows a predictable shape — for example `https://{store}.myshopify.com/...`). + +## Best Practice + +Before any `HttpClient` call whose URL came from a table field, call `Uri.AreURIsHaveSameHost(StoredUrl, ExpectedBaseUrl)` against a hard-coded expected base, or `Uri.IsValidURIPattern(StoredUrl, 'https://*.myshopify.com/*')` against a fixed pattern. Fail the call with an `Error` when the validator returns false. For webhook scenarios where the host is registered out-of-band, compare against the registered host stored alongside the URL. See sample: `validate-user-configurable-urls.good.al`. + +## Anti Pattern + +`HttpClient.Get(Setup."Service URL", Response)` or `HttpClient.Post(WebhookSetup."Callback URL", Content, Response)` with no validation step in between. The extension will dutifully send the request — and any sensitive payload — to whatever host the attacker put in the field. Reviewers should flag any `HttpClient` call whose first argument is a record field, an `OnValidate`-mutable field, or a value sourced from a table read, unless a `Uri.AreURIsHaveSameHost` or `Uri.IsValidURIPattern` check precedes it. See sample: `validate-user-configurable-urls.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al new file mode 100644 index 000000000..7029908e6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al @@ -0,0 +1,11 @@ +tableextension 50225 "Sec Sample VTR Bad" extends Customer +{ + fields + { + field(50225; "Linked Customer No."; Code[20]) + { + TableRelation = Customer."No."; + ValidateTableRelation = false; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al new file mode 100644 index 000000000..9a49bc325 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al @@ -0,0 +1,26 @@ +tableextension 50223 "Sec Sample VTR Good" extends Customer +{ + fields + { + field(50223; "System Batch ID"; Code[20]) + { + TableRelation = "Sales Header"."No."; + ValidateTableRelation = false; + Editable = false; + } + field(50224; "External Customer Ref"; Code[50]) + { + TableRelation = Customer."No."; + ValidateTableRelation = false; + trigger OnValidate() + var + Customer: Record Customer; + begin + if "External Customer Ref" = '' then + exit; + if not Customer.Get("External Customer Ref") then + Error('External customer reference %1 does not exist.', "External Customer Ref"); + end; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md new file mode 100644 index 000000000..275587cba --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [validatetablerelation, tablerelation, field, validation, input] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not set ValidateTableRelation = false on user-editable fields + +## Description + +`TableRelation` on a field declares that the field's value must exist in another table; the platform validates the value on entry and on `Validate`. Setting `ValidateTableRelation = false` keeps the relation as metadata (used by lookups, by Edit-in-Excel, by APIs) but turns off the runtime check. On a system-controlled, non-editable field that is populated only by the platform or by a posting routine, that is acceptable. On a user-editable field, it is dangerous: users can type any value, and downstream code that assumes the relation holds will read a `Customer` record that does not exist, post to an account that was deleted, or join against missing rows. + +## Best Practice + +Leave `ValidateTableRelation` at its default (true) on any field a user can edit. If there is a legitimate reason to turn it off — typically because the relation is not on the primary key, or because the relation is computed — replace it with an `OnValidate` trigger that performs the equivalent check (`if FieldValue <> '' then VerifyExternalReferenceExists(FieldValue)`). Combine `ValidateTableRelation = false` with `Editable = false` for system-controlled fields, so the metadata is correct and the field is unreachable from the UI. See sample: `validatetablerelation-false-on-user-input.good.al`. + +## Anti Pattern + +`ValidateTableRelation = false` on a user-facing input field (a `Customer No.` typed by a sales user) with no alternative validation. Reviewers should flag the combination of `ValidateTableRelation = false` and any of: `Editable = true` (the default), an `OnValidate` trigger that does not perform the relation check, or a page that surfaces the field as input. See sample: `validatetablerelation-false-on-user-input.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al new file mode 100644 index 000000000..a32072fba --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al @@ -0,0 +1,5 @@ +page 50258 "Sample AboutTitle Bad" +{ + PageType = List; + SourceTable = Customer; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al new file mode 100644 index 000000000..b49797485 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al @@ -0,0 +1,15 @@ +page 50256 "Sample AboutTitle Good List" +{ + PageType = List; + SourceTable = Customer; + AboutTitle = 'About customers'; + AboutText = 'Manage your customer database and track customer interactions. You can create new customers, update contact information, and view customer statistics.'; +} + +page 50257 "Sample AboutTitle Good Card" +{ + PageType = Card; + SourceTable = Customer; + AboutTitle = 'About customer details'; + AboutText = 'View and edit detailed customer information including contact details, payment terms, and billing preferences.'; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md new file mode 100644 index 000000000..f72b9591d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [abouttitle, abouttext, teaching-tip, onboarding, page] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use `AboutTitle` and `AboutText` to surface teaching tips on top-level pages + +## Description + +The `AboutTitle` and `AboutText` properties on a page render a teaching tip — an onboarding callout that appears the first time a user opens the page. They are supported on pages, individual page controls, FactBoxes, and report request pages. They are NOT supported on Role Centers or modal dialogs. The conventions: `AboutTitle` answers "what is this page about?" and uses the plural for list pages (`'About sales invoices'`) and the `[entity] details` form for card and document pages (`'About sales invoice details'`); `AboutText` answers "what can I do with this page?" in two or three short sentences. Both are translation-aware and surface to the end user verbatim. + +The reviewer signal is "this is a new top-level card or list page in an app whose sibling pages already define teaching tips" — when the surrounding app sets the precedent, a new page without `AboutTitle`/`AboutText` is an inconsistency worth flagging. + +## Best Practice + +Set `AboutTitle` and `AboutText` on every new top-level card, list, and document page in an app that already uses them. Keep `AboutText` to two or three short sentences. Describe what the page does, not the navigation steps to use it — teaching tips explain WHAT, not HOW. + +See sample: `abouttitle-abouttext-teaching-tips.good.al`. + +## Anti Pattern + +A new top-level page in an app whose siblings have `AboutTitle`/`AboutText`, but with no teaching tips defined. Equally wrong is filling `AboutText` with step-by-step instructions ("Click New, then enter…") — the property is for orientation, not procedural help. + +See sample: `abouttitle-abouttext-teaching-tips.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-camelcase-properties.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-camelcase-properties.bad.al new file mode 100644 index 000000000..47f2f1103 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-camelcase-properties.bad.al @@ -0,0 +1,10 @@ +page 50219 "Sample API Camel Bad" +{ + PageType = API; + APIPublisher = 'Contoso-App'; + APIGroup = 'app_1'; + APIVersion = 'v2.0'; + EntityName = 'sales_order'; + EntitySetName = 'sales_orders'; + SourceTable = "Sales Header"; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-camelcase-properties.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-camelcase-properties.good.al new file mode 100644 index 000000000..f460bb9f2 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-camelcase-properties.good.al @@ -0,0 +1,22 @@ +page 50218 "Sample API Camel Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v2.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; + + layout + { + area(Content) + { + repeater(Group) + { + field(displayName; Rec.Name) { Caption = 'displayName'; } + } + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-camelcase-properties.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-camelcase-properties.md new file mode 100644 index 000000000..3baa00997 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-camelcase-properties.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, camelcase, apipublisher, apigroup, entityname, entitysetname] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# API pages use camelCase, alphanumeric-only values for API properties + +## Description + +API pages — pages declared with `PageType = API` — surface as OData/JSON endpoints. The strings that appear in the URL (`APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`) and the JSON payload field names follow different naming rules from the rest of AL. They must be camelCase and use only alphanumeric characters: no hyphens, no underscores, no spaces, no punctuation. `'Contoso-App'`, `'contoso_app'`, and `'contoso.app'` are all rejected. The same rule applies to page field names exposed via `Name = '…'` on API page controls — those names appear verbatim in the JSON keys. + +## Best Practice + +Pick camelCase identifiers up front: `APIPublisher = 'contoso'`, `APIGroup = 'app1'`, `EntityName = 'customer'`, field `Name = 'displayName'`. Keep them short — they end up in URL paths and JSON keys that every consumer types. + +See sample: `api-page-camelcase-properties.good.al`. + +## Anti Pattern + +`APIPublisher = 'Contoso-App'` (hyphen rejected, capitalization wrong for camelCase), `EntityName = 'sales_order'` (underscore rejected), or fields exposed with `Name = 'Display Name'` (space rejected). The compiler usually catches these, but the failure mode is opaque and the rename cost on a deployed API is high. + +See sample: `api-page-camelcase-properties.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al new file mode 100644 index 000000000..4cf3fe252 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al @@ -0,0 +1,10 @@ +page 50227 "Sample DelayedInsert Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-delayedinsert-true.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-delayedinsert-true.good.al new file mode 100644 index 000000000..532d3cd90 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-delayedinsert-true.good.al @@ -0,0 +1,11 @@ +page 50226 "Sample DelayedInsert Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-delayedinsert-true.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-delayedinsert-true.md new file mode 100644 index 000000000..6045c2f80 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-delayedinsert-true.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, delayedinsert, insert-trigger, validation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set `DelayedInsert = true` on API pages + +## Description + +On a normal page, `DelayedInsert = false` is the default: the record is inserted into the table as soon as the user enters the first field, and subsequent fields are written via `Modify` triggers. That model does not work for an API endpoint, where the consumer sends a complete JSON payload in a single request and expects exactly one `Insert` to fire with all fields already populated. `DelayedInsert = true` defers the insert until every field on the page has been assigned, so the `OnInsert` trigger runs once with the full record and `OnValidate` triggers on individual fields run in a predictable order. The convention is that API pages always set `DelayedInsert = true`. + +## Best Practice + +Declare `DelayedInsert = true` on every page with `PageType = API`. The setting plays well with `Modify(true)` and `Insert(true)` calls inside `OnInsert` and avoids the half-populated record states that otherwise reach validation logic. + +See sample: `api-page-delayedinsert-true.good.al`. + +## Anti Pattern + +Omitting `DelayedInsert` (which defaults to `false`) on an API page. Validation triggers fire on a partially populated record, mandatory-field errors come back to the caller for fields the JSON payload was about to supply, and the API surface produces failures that have no analogue in the UI page model. + +See sample: `api-page-delayedinsert-true.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al new file mode 100644 index 000000000..ecd1ec392 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al @@ -0,0 +1,10 @@ +page 50225 "Sample API Entity Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customers'; + EntitySetName = 'customer'; + SourceTable = Customer; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al new file mode 100644 index 000000000..72981b70a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al @@ -0,0 +1,23 @@ +page 50223 "Sample API Entity Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} + +page 50224 "Sample API Compound Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'salesOrder'; + EntitySetName = 'salesOrders'; + SourceTable = "Sales Header"; + DelayedInsert = true; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md new file mode 100644 index 000000000..6ba698e79 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, entityname, entitysetname, singular, plural] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `EntityName` is singular; `EntitySetName` is plural + +## Description + +`EntityName` and `EntitySetName` on an API page are the two halves of the OData naming contract. `EntityName` names a single record — `'customer'`, `'salesOrder'`, `'item'`. `EntitySetName` names the collection — `'customers'`, `'salesOrders'`, `'items'`. Swapping them — `EntityName = 'customers'`, `EntitySetName = 'customer'` — produces URLs that lie to consumers: `GET /customers` returns one row, `GET /customers('id')` returns a collection. The OData conventions consumers rely on for client-side code generation depend on the singular/plural pairing being correct. + +## Best Practice + +Pick the singular noun for `EntityName` and its grammatical plural for `EntitySetName`, both in camelCase. For compound nouns, only the trailing noun is pluralized: `EntityName = 'salesOrder'`, `EntitySetName = 'salesOrders'`. For nouns whose plural is irregular, use the natural English form — `EntitySetName = 'people'` for `EntityName = 'person'`. + +See sample: `api-page-entity-naming-singular-plural.good.al`. + +## Anti Pattern + +`EntityName = 'customers'`, `EntitySetName = 'customer'` — singular and plural swapped. Equally wrong is reusing the same form for both — `EntityName = 'customer'`, `EntitySetName = 'customer'` — which breaks OData metadata parsers and client codegen. + +See sample: `api-page-entity-naming-singular-plural.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-version-format.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-version-format.bad.al new file mode 100644 index 000000000..2efe6234a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-version-format.bad.al @@ -0,0 +1,10 @@ +page 50222 "Sample API Version Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v2'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-version-format.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-version-format.good.al new file mode 100644 index 000000000..dc115b491 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-version-format.good.al @@ -0,0 +1,23 @@ +page 50220 "Sample API Version Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} + +page 50221 "Sample API Beta Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'beta'; + EntityName = 'preview'; + EntitySetName = 'previews'; + SourceTable = Customer; + DelayedInsert = true; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-version-format.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-version-format.md new file mode 100644 index 000000000..633c53bd5 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/api-page-version-format.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, apiversion, version, format, beta] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `APIVersion` must follow the pattern `vX.Y` (or `beta`) + +## Description + +The `APIVersion` property on an API page is part of the public URL path: `/api////`. The platform accepts only two value shapes for it: a `vMAJOR.MINOR` string such as `'v1.0'`, `'v2.0'`, or `'v2.1'`, or the literal string `'beta'` for pre-release endpoints. Anything else — `'v2'`, `'2.0'`, `'1'`, `'v2.0.0'` — is rejected. The major-minor pair lets consumers detect compatibility through URL inspection alone; the explicit `'beta'` channel signals "this contract may break without notice." + +## Best Practice + +Start a new public endpoint at `'v1.0'`. Bump the minor when adding fields or non-breaking changes; bump the major when changing field types, removing fields, or any breaking change. Use `'beta'` for endpoints that are still iterating and SHOULD NOT be consumed by external integrations. + +See sample: `api-page-version-format.good.al`. + +## Anti Pattern + +`APIVersion = 'v2'` (missing minor), `APIVersion = '2.0'` (missing `v` prefix), `APIVersion = 'v2.0.0'` (extra segment). All three either fail to compile or produce a URL that consumers cannot reach. + +See sample: `api-page-version-format.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al new file mode 100644 index 000000000..fd3ac45c8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al @@ -0,0 +1,14 @@ +codeunit 50235 "Sample Begin Own Line Bad" +{ + procedure Run(Condition: Boolean) + begin + if Condition then + begin + DoSomething(); + DoSomethingElse(); + end; + end; + + local procedure DoSomething() begin end; + local procedure DoSomethingElse() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al new file mode 100644 index 000000000..6043c5bb5 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al @@ -0,0 +1,24 @@ +codeunit 50234 "Sample Begin Same Line Good" +{ + procedure Run(Condition: Boolean) + var + i: Integer; + begin + if Condition then begin + DoSomething(); + DoSomethingElse(); + end else begin + Reset(); + Notify(); + end; + for i := 1 to 10 do begin + DoSomething(); + DoSomethingElse(); + end; + end; + + local procedure DoSomething() begin end; + local procedure DoSomethingElse() begin end; + local procedure Reset() begin end; + local procedure Notify() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md new file mode 100644 index 000000000..fbf7aec07 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [begin, end, compound-statement, aa0005, codecop, formatting] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `begin` goes on the same line as `then`, `else`, or `do` (CodeCop AA0005) + +## Description + +When a compound block follows `then`, `else`, or `do`, the `begin` keyword must sit on the same line as the preceding keyword, separated by exactly one space. `if Condition then begin` and `for i := 1 to N do begin` are correct. The form that puts `begin` on its own line — common in older AL and in languages like Pascal — is flagged by CodeCop AA0005. The rule does not change indentation of the block body; it only governs the placement of `begin` relative to `then`/`else`/`do`. + +## Best Practice + +`if Condition then begin … end;`, `else begin … end;`, `for i := 1 to N do begin … end;`. The block body is indented one level below the `if`/`for` line, and `end;` sits at the same indentation as the line that opened the block. + +See sample: `begin-on-same-line-as-then-else-do.good.al`. + +## Anti Pattern + +A line that ends with `then` (or `else`, or `do`) and is followed by a line whose only content is `begin`. The compiler accepts it but CodeCop AA0005 flags it; the visual cost is a wasted line per block and a layout that looks alien to readers used to current AL style. + +See sample: `begin-on-same-line-as-then-else-do.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/block-keywords-start-new-line.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/block-keywords-start-new-line.bad.al new file mode 100644 index 000000000..ef7f93718 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/block-keywords-start-new-line.bad.al @@ -0,0 +1,15 @@ +codeunit 50239 "Sample Block Kw Bad" +{ + procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) + var + i: Integer; + begin + if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode(); + for i := 1 to 10 do begin DoSomething(i); DoSomethingElse(i); end; + end; + + local procedure ValidateContactName() begin end; + local procedure ValidateSalespersonCode() begin end; + local procedure DoSomething(I: Integer) begin end; + local procedure DoSomethingElse(I: Integer) begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/block-keywords-start-new-line.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/block-keywords-start-new-line.good.al new file mode 100644 index 000000000..eb6c3d492 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/block-keywords-start-new-line.good.al @@ -0,0 +1,23 @@ +codeunit 50238 "Sample Block Kw Good" +{ + procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) + var + i: Integer; + begin + if IsContactName then + ValidateContactName() + else + if IsSalespersonCode then + ValidateSalespersonCode(); + + for i := 1 to 10 do begin + DoSomething(i); + DoSomethingElse(i); + end; + end; + + local procedure ValidateContactName() begin end; + local procedure ValidateSalespersonCode() begin end; + local procedure DoSomething(I: Integer) begin end; + local procedure DoSomethingElse(I: Integer) begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/block-keywords-start-new-line.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/block-keywords-start-new-line.md new file mode 100644 index 000000000..d40ea61db --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/block-keywords-start-new-line.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [block-keyword, end, if, repeat, until, for, while, case, aa0018] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Block keywords (`end`, `if`, `repeat`, `until`, `for`, `while`, `case`) start a new line (CodeCop AA0018) + +## Description + +CodeCop AA0018 requires that the block-introducing keywords `if`, `repeat`, `until`, `for`, `while`, `case`, and the block-terminating keyword `end` always start a new line. Multiple statements packed onto one line — `if A then X() else if B then Y();` written inline, or `for i := 1 to 10 do begin X(i); Y(i); end;` — defeat code review tooling that operates line-by-line and obscure the control flow. The rule does not prohibit short single-statement constructs spread across two lines (`if Cond then X();`); it prohibits packing the entire control structure onto one line. + +## Best Practice + +Each `if`, `else if`, `repeat`, `for`, `while`, and `case` starts a line. Each `end;` (the closing of a `begin … end` block or a `case`) starts a line. Branch bodies are on their own line, indented. + +See sample: `block-keywords-start-new-line.good.al`. + +## Anti Pattern + +`if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode();` collapses an `if/else if` chain onto a single line; AA0018 flags both the `else` and the second `if`. The same applies to `for i := 1 to 10 do begin DoX(i); DoY(i); end;` — `end` is not at the start of its line. + +See sample: `block-keywords-start-new-line.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/caption-required-on-page-fields.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/caption-required-on-page-fields.bad.al new file mode 100644 index 000000000..bd12f3697 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/caption-required-on-page-fields.bad.al @@ -0,0 +1,13 @@ +table 50253 "Sample Caption Bad" +{ + fields + { + field(1; "Customer No."; Code[20]) + { + } + field(2; "Is Active"; Boolean) + { + Caption = ''; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/caption-required-on-page-fields.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/caption-required-on-page-fields.good.al new file mode 100644 index 000000000..7de715b63 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/caption-required-on-page-fields.good.al @@ -0,0 +1,17 @@ +table 50252 "Sample Caption Good" +{ + fields + { + field(1; "Customer No."; Code[20]) + { + Caption = 'Customer No.'; + } + field(2; "Enabled"; Boolean) + { + } + field(3; Amount; Decimal) + { + CaptionClass = '3,5,' + 'USD'; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/caption-required-on-page-fields.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/caption-required-on-page-fields.md new file mode 100644 index 000000000..e3a69c308 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/caption-required-on-page-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [caption, page-field, aa0225, aa0226, codecop, captionclass] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every page field needs a `Caption` (CodeCop AA0225/AA0226) + +## Description + +CodeCop AA0225 and AA0226 require every field control to expose a `Caption` property, separately from the field's source name. The caption is what the user sees as the column header or label; the source name is what the code uses to reference the field. Without an explicit `Caption`, AL falls back to the source field's caption — which may be wrong for the page's context — or to the field name itself in code casing, which surfaces internal naming to users and to translators. + +Acceptable exceptions: a field whose caption is inherited via `CaptionClass = '3,5,' + CurrencyCode` (or another CaptionClass formula) does not need a literal `Caption`; the formula provides it. API pages and test pages may omit captions because their consumers are not human users. Boolean fields whose name already reads as a sentence — `Enabled`, `Posted`, `Released` — do not need a redundant Caption that repeats the name. + +## Best Practice + +`Caption = 'Customer No.';` paired with `ToolTip = 'Specifies …';`. Captions are short, noun-phrase, title-case for primary labels; sentence-case is allowed for descriptive labels that read as a sentence fragment. + +See sample: `caption-required-on-page-fields.good.al`. + +## Anti Pattern + +A field control with no `Caption` and no `CaptionClass`, or `Caption = '';`. The user sees the internal identifier as the column header and the translation pipeline has nothing to translate. + +See sample: `caption-required-on-page-fields.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al new file mode 100644 index 000000000..e4fbe585c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al @@ -0,0 +1,16 @@ +codeunit 50241 "Sample Case Format Bad" +{ + procedure Translate(Letter: Char): Code[10] + var + Letter2: Code[10]; + begin + case Letter of + 'A': Letter2 := '10'; + 'B': Letter2 := '11'; + 'C': begin Letter2 := '12'; DoSomething(); end; + end; + exit(Letter2); + end; + + local procedure DoSomething() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al new file mode 100644 index 000000000..a7ff73184 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al @@ -0,0 +1,21 @@ +codeunit 50240 "Sample Case Format Good" +{ + procedure Translate(Letter: Char): Code[10] + var + Letter2: Code[10]; + begin + case Letter of + 'A': + Letter2 := '10'; + 'B': + Letter2 := '11'; + 'C': begin + Letter2 := '12'; + DoSomething(); + end; + end; + exit(Letter2); + end; + + local procedure DoSomething() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/case-action-on-line-after-possibility.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/case-action-on-line-after-possibility.md new file mode 100644 index 000000000..c92837572 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/case-action-on-line-after-possibility.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [case, statement, formatting, possibility, action, line-break] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `case` action goes on the line after the possibility + +## Description + +In an AL `case` statement, the action for each label is written on the line that follows the label, not on the same line. `'A': Letter2 := '10';` on a single line is the discouraged form; the convention is `'A':` on one line and `Letter2 := '10';` on the next, indented one level deeper. The exception is when the action is a `begin … end` block — there the `begin` follows the colon on the same line, consistent with the rule for `then begin` / `else begin` / `do begin`. + +## Best Practice + +Each case label sits on its own line, terminated by `:`. The action below it is indented; multi-statement actions open with `begin` on the label line and close with `end;` on its own line. + +See sample: `case-action-on-line-after-possibility.good.al`. + +## Anti Pattern + +`'A': Letter2 := '10';` (single-line label and action), and `'C': begin Letter2 := '12'; DoSomething(); end;` (everything on one line including the block body). Both defeat per-line diff review and crowd the control flow. + +See sample: `case-action-on-line-after-possibility.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al new file mode 100644 index 000000000..4d47c3105 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al @@ -0,0 +1,15 @@ +codeunit 50207 "Sample Error Params Bad" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + + procedure CheckCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then + Error(StrSubstNo(CustomerNotFoundErr, CustomerNo)); + if not Customer.Get(CustomerNo) then + Error('Customer ' + CustomerNo + ' not found'); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al new file mode 100644 index 000000000..96e3d9c72 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al @@ -0,0 +1,13 @@ +codeunit 50206 "Sample Error Params Good" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + + procedure CheckCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then + Error(CustomerNotFoundErr, CustomerNo); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md new file mode 100644 index 000000000..f8ee5bbbc --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [error, strsubstno, label, parameters, concatenation, aa0231] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass parameters directly to `Error()`, do not wrap with `StrSubstNo` + +## Description + +`Error()` accepts a format string and a variable number of arguments — `Error(SomeLabelErr, Arg1, Arg2)`. The platform performs the substitution itself, which is the path the translation pipeline understands. Wrapping the same call as `Error(StrSubstNo(SomeLabelErr, Arg1, Arg2))` hides the placeholders from the platform and removes the format-string identity from the call-site, so analyzers cannot match the call to its label and translators lose the link between the formatted message and its template. The corresponding anti-pattern for hardcoded strings — `Error('Customer ' + CustomerNo + ' not found')` — is even worse: it builds an untranslatable, unanalyzable string at runtime. + +## Best Practice + +Declare a `Label` with the `Err` suffix and the appropriate `Comment` for placeholders, then call `Error(YourErr, arg1, arg2)`. The same rule applies to `Message`, `Confirm`, and other UI primitives: format string in, parameters as separate arguments, no `StrSubstNo` wrapper at the call site, no string concatenation. An `Error('')` (empty message) is acceptable when the calling code expects another layer to emit the actual diagnostic. + +See sample: `error-passes-parameters-directly-not-strsubstno.good.al`. + +## Anti Pattern + +`Error(StrSubstNo(CustomerNotFoundErr, CustomerNo))` and `Error(CustomerNotFoundErr + ': ' + CustomerNo)` both defeat the translation and analysis machinery. Reviewers should treat `StrSubstNo` appearing as an argument to `Error`, `Message`, `Confirm`, or `StrMenu` as an unconditional signal to rewrite. + +See sample: `error-passes-parameters-directly-not-strsubstno.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md new file mode 100644 index 000000000..aaf3729e6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: style +keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Event subscriber parameter names must match the publisher signature + +## Description + +In AL, an `[EventSubscriber]` procedure is bound to its publisher by event name and parameter list. The parameter names on the subscriber are not a style choice — they must match the names the publisher declared. The compiler validates the match at build time and emits an error if the subscriber renames a parameter. This means a reviewer cannot apply a generic "use better names" pass to subscriber parameters: `Sender`, `Rec`, `xRec`, `RunTrigger`, the table-and-field-specific parameter names a publisher emits — all are dictated by the publisher and must be reproduced verbatim. + +## Best Practice + +Copy the publisher signature exactly when declaring the subscriber. When in doubt, navigate to the publisher (`OnAfterValidateEvent`, `OnBeforePostSalesDoc`, etc.) and copy its parameter list. Style rules that apply to other locals — descriptive names, no spaces — do not apply to subscriber parameters. + +## Anti Pattern + +Renaming a publisher parameter to look prettier in the subscriber. The build breaks immediately. More insidiously, a parameter name that happens to match by coincidence in one event publisher but not in a similar one will compile in some versions of BC and fail in others when the publisher signature evolves. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al new file mode 100644 index 000000000..4328ce2c0 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al @@ -0,0 +1,13 @@ +tableextension 50211 "Sample FieldCaption Bad" extends Customer +{ + procedure ConfirmAndAnnounce(): Boolean + var + UpdateLocationQst: Label 'Update %1?'; + UpdatedMsg: Label 'Updated %1.'; + begin + if not Confirm(UpdateLocationQst, true, FieldName("Location Code")) then + exit(false); + Message(UpdatedMsg, TableName()); + exit(true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al new file mode 100644 index 000000000..449b98d3a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al @@ -0,0 +1,13 @@ +tableextension 50210 "Sample FieldCaption Good" extends Customer +{ + procedure ConfirmAndAnnounce(): Boolean + var + UpdateLocationQst: Label 'Update %1?'; + UpdatedMsg: Label 'Updated %1.'; + begin + if not Confirm(UpdateLocationQst, true, FieldCaption("Location Code")) then + exit(false); + Message(UpdatedMsg, TableCaption()); + exit(true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md new file mode 100644 index 000000000..a74f1aa27 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [fieldcaption, fieldname, tablecaption, tablename, translation, message, error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use FieldCaption/TableCaption (not FieldName/TableName) in user-facing text + +## Description + +`FieldName` and `TableName` return the developer-facing identifier of a field or table — a fixed English string used in metadata and in code. `FieldCaption` and `TableCaption` return the translated, user-facing label declared by the field's or table's `Caption` property. When the value is embedded in a `Message`, `Error`, `Confirm`, or any other string shown to a user, the caption is the correct source. Otherwise the user sees the English internal name regardless of locale, and any caption change must be re-applied at every call site instead of being picked up from the single point of definition. + +## Best Practice + +Reach for `FieldCaption("Location Code")` and `TableCaption()` whenever the value flows into a UI primitive. The same rule applies to format parameters: `Error(SomeErr, FieldCaption("Status"), TableCaption(), "Status")` rather than `Error(SomeErr, FieldName("Status"), TableName(), "Status")`. The captions follow the user's language; the names do not. + +See sample: `fieldcaption-not-fieldname-in-user-messages.good.al`. + +## Anti Pattern + +`Message('Updated %1', TableName())` or `Confirm(UpdateLocationQst, true, FieldName("Location Code"))`. The user sees the English internal name in every locale, and any future rename of the caption fails to reach the message. + +See sample: `fieldcaption-not-fieldname-in-user-messages.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/file-name-object-type-pattern.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/file-name-object-type-pattern.md new file mode 100644 index 000000000..dd8e0f1a8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/file-name-object-type-pattern.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [file-name, object-type, suffix, naming-convention] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Name AL source files `..al` + +## Description + +Each AL source file holds a single object, and the file name is expected to be of the form `..al` — `CustomerCard.Page.al`, `PostSalesInvoice.Codeunit.al`, `NoSeriesTests.Codeunit.al`, `SalesHeader.TableExt.al`. The pattern makes object types greppable from a file listing and lets tooling — symbol search, project explorers, code generators — locate objects without parsing the AL source. Snake-case, lowercase-only, or type-less file names (`customer_page.al`, `tests_noSeries.al`, `PostSalesInvoiceLogic.al`) all break that contract. + +## Best Practice + +Use PascalCase for the object portion, no spaces, no underscores; the type segment is one of the AL object-type names — `Page`, `Codeunit`, `Table`, `TableExt`, `Report`, `Query`, `XmlPort`, `Enum`, `EnumExt`, `Interface`, `PermissionSet`, `PageExt`, `ReportExt`. The object portion should echo the object's name as it appears in AL. + +See sample (file-naming pattern is structural; no AL sample shipped here). + +## Anti Pattern + +`customer_page.al`, `PostSalesInvoiceLogic.al`, `tests_noSeries.al`. The first uses snake_case and lower-case; the second omits the type segment entirely; the third inverts the order and uses mixed casing. All three break grep, symbol search, and the implicit map between file system and AL object table. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/function-call-parentheses-required.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/function-call-parentheses-required.bad.al new file mode 100644 index 000000000..2677716bd --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/function-call-parentheses-required.bad.al @@ -0,0 +1,11 @@ +codeunit 50213 "Sample Parens Bad" +{ + procedure Run() + var + Customer: Record Customer; + begin + Customer.Init; + if Customer.FindFirst then + Customer.Modify; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/function-call-parentheses-required.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/function-call-parentheses-required.good.al new file mode 100644 index 000000000..53f6f85a9 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/function-call-parentheses-required.good.al @@ -0,0 +1,11 @@ +codeunit 50212 "Sample Parens Good" +{ + procedure Run() + var + Customer: Record Customer; + begin + Customer.Init(); + if Customer.FindFirst() then + Customer.Modify(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/function-call-parentheses-required.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/function-call-parentheses-required.md new file mode 100644 index 000000000..31fbaf8f0 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/function-call-parentheses-required.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [parentheses, function-call, method-call, aa0008, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Always write parentheses on procedure calls (CodeCop AA0008) + +## Description + +AL allows a parameterless procedure to be called without parentheses — `Customer.Init` instead of `Customer.Init()` — and the result is syntactically identical at runtime. CodeCop AA0008 still flags the parenthesis-less form. The reason is twofold: written without parentheses, a procedure call is visually indistinguishable from a property read, which makes BC code harder to scan; and the same identifier may exist as both a property and a procedure on different objects, so the parentheses are the only local signal that this is a call. The rule applies to every parameterless invocation, including `Init`, `Insert`, `Modify`, `Delete`, `DeleteAll`, `FindFirst`, `FindSet`, `Next`, `Get`, `CalcFields`, and user-defined procedures. + +## Best Practice + +Always write `()` on a procedure call, even when it takes no arguments: `Customer.Init();`, `TempBuffer.DeleteAll();`, `if Customer.FindFirst() then …`. The same applies inside expressions and as a condition. + +See sample: `function-call-parentheses-required.good.al`. + +## Anti Pattern + +`Customer.Init;`, `TempBuffer.DeleteAll;`, `if Customer.FindFirst then …`. Every one of those is an AA0008 violation. Reviewers should treat a parameterless procedure name appearing without parentheses as a defect, even though the compiler accepts it. + +See sample: `function-call-parentheses-required.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al new file mode 100644 index 000000000..7f5893b47 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al @@ -0,0 +1,11 @@ +codeunit 50203 "Sample Label Comment Bad" +{ + var + DocumentErrorErr: Label 'Document %1 has errors in %2.'; + ValidationErr: Label 'Field %1 in table %2 contains invalid value %3.'; + + procedure Validate(DocNo: Code[20]; Loc: Code[10]) + begin + Error(DocumentErrorErr, DocNo, Loc); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-comment-explains-placeholders.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-comment-explains-placeholders.good.al new file mode 100644 index 000000000..7318333d1 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-comment-explains-placeholders.good.al @@ -0,0 +1,12 @@ +codeunit 50202 "Sample Label Comment Good" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist for sales document %2.', Comment = '%1 = Customer No., %2 = Sales Header No.'; + ValidationErr: Label 'Field %1 in table %2 contains invalid value %3.', Comment = '%1 = Field Name, %2 = Table Caption, %3 = Field Value'; + CustomerSimpleLbl: Label 'Customer %1'; + + procedure Validate(CustNo: Code[20]; DocNo: Code[20]) + begin + Error(CustomerNotFoundErr, CustNo, DocNo); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-comment-explains-placeholders.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-comment-explains-placeholders.md new file mode 100644 index 000000000..43d9c1fc6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-comment-explains-placeholders.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, comment, placeholder, strsubstno, translation, aa0470] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Document each Label placeholder with the Comment parameter + +## Description + +`Label` and `TextConst` strings that contain placeholders (`%1`, `%2`, …) need a `Comment` parameter that names what each placeholder is. Translators do not see the call site, so without the Comment they cannot disambiguate `'Customer %1 not found in %2.'` — is `%2` a location code, a posting date, a company name? The pattern is `Comment = '%1 = , %2 = '`. The Comment is not required when the placeholder meaning is obvious from the surrounding text — `'Customer %1'` is unambiguously a Customer No. — but for any non-trivial label the Comment is a hard requirement. + +## Best Practice + +Write the Comment in the form `'%1 = Customer No., %2 = Sales Header No.'` — one entry per placeholder, matched by ordinal, named in the vocabulary of the BC domain. When the label is reused across multiple call sites, the Comment names the canonical meaning all call sites must conform to. + +See sample: `label-comment-explains-placeholders.good.al`. + +## Anti Pattern + +A label with two or more placeholders and no Comment, leaving the translator to guess. Equally bad is a Comment that only restates the placeholders (`'%1 and %2 are values'`) without naming what they are. Both fail in translation: the localized string ends up grammatically or semantically wrong, and the bug surfaces only in a non-English tenant. + +See sample: `label-comment-explains-placeholders.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al new file mode 100644 index 000000000..0ec72e82d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al @@ -0,0 +1,7 @@ +codeunit 50205 "Sample Locked Label Bad" +{ + var + HttpsUrl: Label 'https://example.com'; + GetVerbTok: Label 'GET'; + JsonTypeLbl: Label 'application/json'; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-locked-for-non-translatable.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-locked-for-non-translatable.good.al new file mode 100644 index 000000000..02b35d99a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-locked-for-non-translatable.good.al @@ -0,0 +1,8 @@ +codeunit 50204 "Sample Locked Label Good" +{ + var + GetMethodTok: Label 'GET', Locked = true; + ContentTypeJsonTok: Label 'application/json', Locked = true; + ApiBaseUrlTok: Label 'https://api.contoso.com/v1', Locked = true; + TelemetryStartTxt: Label 'Operation started for %1.', Locked = true; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-locked-for-non-translatable.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-locked-for-non-translatable.md new file mode 100644 index 000000000..77f054b23 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-locked-for-non-translatable.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, locked, translation, token, url, json, xml] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set `Locked = true` on Labels that must not be translated + +## Description + +A `Label` is by default surfaced to translators and rewritten per locale. That is wrong for strings that are not natural language: HTTP verbs (`GET`, `PUT`), URL fragments, JSON/XML snippets, content-type strings, GUIDs, application keys, and field tokens used by integrations. Translating these breaks the integration the moment a non-English tenant runs the code. The `Locked = true` parameter on the Label declaration tells the translation pipeline to keep the string verbatim, and signals to reviewers that the value is part of a wire-level contract rather than display text. + +## Best Practice + +Pair `Locked = true` with the `Tok` suffix for short tokens (`GetMethodTok: Label 'GET', Locked = true;`) and with the `Txt` suffix for telemetry strings that contain format placeholders but should not be localized. The `Locked` parameter and the `Tok` / `Txt` suffix together make the intent unambiguous. + +See sample: `label-locked-for-non-translatable.good.al`. + +## Anti Pattern + +`HttpsUrl: Label 'https://example.com';` or `ContentTypeTok: Label 'application/json';` declared without `Locked = true`. The translator localizes them, the integration fails in production for the affected tenant, and the failure is invisible in the developer's English-locale tests. + +See sample: `label-locked-for-non-translatable.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-suffix-approved-list.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-suffix-approved-list.bad.al new file mode 100644 index 000000000..6b227de65 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-suffix-approved-list.bad.al @@ -0,0 +1,14 @@ +codeunit 50201 "Sample Label Suffix Bad" +{ + var + CannotDeleteLine: Label 'Cannot delete this line.'; + Text000: Label 'Update complete'; + UpdateLocation: Label 'Update location?'; + WrongSuffixTok: Label 'Customer %1 not found.'; + + procedure ShowMessages() + begin + Error(WrongSuffixTok, '10000'); + Message(Text000); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-suffix-approved-list.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-suffix-approved-list.good.al new file mode 100644 index 000000000..f3ec561ec --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-suffix-approved-list.good.al @@ -0,0 +1,15 @@ +codeunit 50200 "Sample Label Suffix Good" +{ + var + UpdateCompleteMsg: Label 'Update complete.'; + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + DeleteRecordQst: Label 'Delete this record?'; + CustomerNameLbl: Label 'Customer Name'; + GetMethodTok: Label 'GET', Locked = true; + TelemetryStartedTxt: Label 'Operation started for customer %1.', Locked = true; + + procedure ShowMessage() + begin + Message(UpdateCompleteMsg); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-suffix-approved-list.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-suffix-approved-list.md new file mode 100644 index 000000000..8e937f33c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/label-suffix-approved-list.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, textconst, suffix, aa0074, codecop, msg, err, qst, lbl, tok] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use approved suffixes on Label and TextConst names (CodeCop AA0074) + +## Description + +CodeCop AA0074 flags `Label` and `TextConst` identifiers that do not end with an approved usage suffix. The suffix signals at the call site how the text is consumed and what translation behaviour it should get. The approved suffixes and their intended usage are: `Msg` for text shown via `Message()`; `Err` for text passed to `Error()`; `Qst` for text used with `Confirm` or `StrMenu`; `Lbl` for captions and tooltips; `Tok` for short tokens such as `'GET'`, `'PUT'`, `'HTTPS'`, GUIDs, or JSON/XML snippets that are not translated (typically with `Locked = true`); and `Txt` for general text including telemetry messages. A `Label` named `Text000` or `CannotDeleteLine` without a suffix violates the rule, regardless of how readable the prose is. + +## Best Practice + +Pick the suffix that matches the call where the label is consumed: `UpdateCompleteMsg` for `Message(...)`, `CustomerNotFoundErr` for `Error(...)`, `DeleteRecordQst` for `Confirm(...)`, `CustomerNameLbl` for tooltips and captions, `GetMethodTok` for locked tokens, `TelemetryDataTxt` for telemetry payloads. Suffix choices between `Tok`, `Lbl`, `Txt`, and `Msg` are judgment calls when the suffix is valid for the usage — what matters is that the suffix is on the approved list and matches the actual call. + +See sample: `label-suffix-approved-list.good.al`. + +## Anti Pattern + +A `Label` declared with no suffix (`CannotDeleteLine: Label '…';`), a generic name (`Text000: Label '…';`), or a suffix that contradicts the usage (`WrongSuffixTok: Label 'Customer %1 not found.'` then passed to `Error()`). All three trip AA0074 or its reviewers and obscure the call-site contract. + +See sample: `label-suffix-approved-list.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/labels-declared-at-object-scope.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/labels-declared-at-object-scope.bad.al new file mode 100644 index 000000000..33c63fb3c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/labels-declared-at-object-scope.bad.al @@ -0,0 +1,11 @@ +codeunit 50262 "Sample Label Scope Bad" +{ + procedure LookupCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + GreetingMsg: Label 'Hello %1', Comment = '%1 = Customer Name'; + begin + if Customer.Get(CustomerNo) then + Message(GreetingMsg, Customer.Name); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/labels-declared-at-object-scope.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/labels-declared-at-object-scope.good.al new file mode 100644 index 000000000..415bda75e --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/labels-declared-at-object-scope.good.al @@ -0,0 +1,13 @@ +codeunit 50263 "Sample Label Scope Good" +{ + var + GreetingMsg: Label 'Hello %1', Comment = '%1 = Customer Name'; + + procedure LookupCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + Message(GreetingMsg, Customer.Name); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/labels-declared-at-object-scope.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/labels-declared-at-object-scope.md new file mode 100644 index 000000000..24a868e6d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/labels-declared-at-object-scope.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: style +keywords: [label, scope, procedure, translation, localization, xliff] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Declare Labels at object scope, not inside procedure `var` blocks + +## Description + +`Label` is the AL declaration that participates in the translation pipeline: the build extracts every Label declared in an object into the `.xlf` file shipped to translators, and the runtime substitutes the localized value when the object is loaded. Translation tooling discovers Labels by walking the object's top-level declarations. + +Labels declared inside a procedure-local `var` block are still **compiled** as Label values, but their participation in localization is fragile: depending on the BC version, the build pipeline, and the translation toolchain in use, procedure-local Labels may be missed during XLIFF extraction, may be re-emitted with auto-generated keys that change between builds, or may not be addressable by reviewers triaging translations. The reliable, supported pattern is to declare every Label in the object's top-level `var` block. + +The same rule applies to all object types that own behavior: codeunits, pages, tables, reports, queries, and their extensions. For shared messages used by multiple objects, declare the Label in the most appropriate owning object and reference it — do not duplicate the literal across procedure-scoped declarations in several places. + +## Best Practice + +Move every `Label` to the object's top-level `var` block. Use the appropriate suffix (`Msg`, `Err`, `Qst`, `Lbl`, `Tok`, `Txt`) on the variable name so reviewers and the translation team can see at a glance what role the string plays. Pair non-translatable strings (URLs, JSON/XML fragments, integration tokens) with `Locked = true`, as covered by `label-locked-for-non-translatable.md`. + +See sample: `labels-declared-at-object-scope.good.al`. + +## Anti Pattern + +Declaring `Label` inside a procedure-local `var` block — `procedure Lookup() var GreetingMsg: Label 'Hello %1';` — couples the translatable string to one procedure, hides it from object-level review, and depends on a translation pipeline behavior that is not part of the AL language contract. + +See sample: `labels-declared-at-object-scope.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al new file mode 100644 index 000000000..83d5994f6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al @@ -0,0 +1,14 @@ +codeunit 50245 "Sample Upper Keywords Bad" +{ + procedure Walk(VAR Customer: Record Customer) + VAR + Found: Boolean; + BEGIN + IF Customer.FindSet() THEN + REPEAT + Found := TRUE; + UNTIL Customer.Next() = 0; + IF Found THEN + EXIT; + END; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/lowercase-reserved-keywords.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/lowercase-reserved-keywords.good.al new file mode 100644 index 000000000..25fb20e9c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/lowercase-reserved-keywords.good.al @@ -0,0 +1,14 @@ +codeunit 50244 "Sample Lower Keywords Good" +{ + procedure Walk(var Customer: Record Customer) + var + Found: Boolean; + begin + if Customer.FindSet() then + repeat + Found := true; + until Customer.Next() = 0; + if Found then + exit; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/lowercase-reserved-keywords.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/lowercase-reserved-keywords.md new file mode 100644 index 000000000..f14b9735f --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/lowercase-reserved-keywords.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [reserved-keyword, lowercase, aa0241, codecop, if, then, begin] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reserved keywords are written in lowercase (CodeCop AA0241) + +## Description + +CodeCop AA0241 requires reserved AL keywords — `if`, `then`, `else`, `begin`, `end`, `var`, `procedure`, `local`, `internal`, `for`, `while`, `repeat`, `until`, `case`, `of`, `do`, `not`, `and`, `or`, `exit`, `break`, `skip`, `quit`, and the rest — to be lowercase. Old Navision and C/AL code used `IF…THEN…BEGIN…END` in uppercase, and that style still lingers in training data and legacy modules. New AL code is lowercase. The rule applies to keywords only — type names (`Record`, `Codeunit`, `Integer`), property names (`Caption`, `ToolTip`), and identifiers are unaffected. + +Test codeunits that retain legacy uppercase forms (`OPENEDIT`, `ASSERTERROR`, `VALUE`) are an accepted exception: the test framework historically uses those identifiers and rewriting them brings no benefit. The rule applies to new code in modified lines, not to long-standing test patterns. + +## Best Practice + +Write keywords lowercase: `if Condition then begin … end;`, `repeat … until Found;`, `for i := 1 to N do …`. The standard AL formatter normalizes casing automatically. + +See sample: `lowercase-reserved-keywords.good.al`. + +## Anti Pattern + +`IF Condition THEN BEGIN DoSomething(); END;`, `REPEAT GetNext(); UNTIL Found;`. Uppercase keywords trip AA0241 and signal C/AL-era code that has not been modernized. + +See sample: `lowercase-reserved-keywords.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al new file mode 100644 index 000000000..47e25d84d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al @@ -0,0 +1,12 @@ +codeunit 50209 "Sample Named Invocations Bad" +{ + procedure ShowShipmentLines(var SalesShptLine: Record "Sales Shipment Line") + begin + Page.RunModal(525, SalesShptLine); + end; + + procedure RunInvoiceReport() + begin + Report.Run(206, true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/named-invocations-not-object-ids.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/named-invocations-not-object-ids.good.al new file mode 100644 index 000000000..858698412 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/named-invocations-not-object-ids.good.al @@ -0,0 +1,12 @@ +codeunit 50208 "Sample Named Invocations Good" +{ + procedure ShowShipmentLines(var SalesShptLine: Record "Sales Shipment Line") + begin + Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine); + end; + + procedure RunInvoiceReport() + begin + Report.Run(Report::"Sales - Invoice", true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/named-invocations-not-object-ids.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/named-invocations-not-object-ids.md new file mode 100644 index 000000000..413dfc26e --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/named-invocations-not-object-ids.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [page, report, codeunit, runmodal, run, object-id, named-invocation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Call objects by name, not by numeric ID + +## Description + +`Page.RunModal`, `Report.Run`, `Codeunit.Run`, and the `Page::`, `Report::`, `Codeunit::`, `Table::`, `XmlPort::` selectors accept either a numeric ID or a named alias. The named form — `Page::"Posted Sales Shipment Lines"`, `Report::"Sales - Invoice"` — is the one to use. Numeric IDs are an implementation detail that change with renumbering, do not survive a rename, and carry no signal to a reader about what the call actually does. The compiler resolves named aliases at build time, so the named form is no slower than the numeric form. + +## Best Practice + +When invoking an object whose named alias is available in the same app (or in a dependency the current app already references), use the named form: `Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine)`, `Report.Run(Report::"Sales - Invoice", true)`. The same applies to `Codeunit.Run`, `XmlPort.Run`, `Query.Open`, and any platform method that takes an object reference. The named form makes diffs reviewable — a rename is visible — and makes log output and stack traces interpretable. + +See sample: `named-invocations-not-object-ids.good.al`. + +## Anti Pattern + +`Page.RunModal(525, …)` or `Report.Run(206, true)`. The numeric form is unreadable, fragile across renumbering, and breaks every search that looks for callers of a named object. + +See sample: `named-invocations-not-object-ids.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al new file mode 100644 index 000000000..1803e1ca3 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al @@ -0,0 +1,11 @@ +codeunit 50237 "Sample Single Stmt Bad" +{ + procedure Validate(IsAssemblyOutputLine: Boolean) + var + SalesLine: Record "Sales Line"; + begin + if IsAssemblyOutputLine then begin + SalesLine.TestField("Order Line No.", 0); + end; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al new file mode 100644 index 000000000..684a86c26 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al @@ -0,0 +1,10 @@ +codeunit 50236 "Sample Single Stmt Good" +{ + procedure Validate(IsAssemblyOutputLine: Boolean) + var + SalesLine: Record "Sales Line"; + begin + if IsAssemblyOutputLine then + SalesLine.TestField("Order Line No.", 0); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-begin-end-around-single-statement.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-begin-end-around-single-statement.md new file mode 100644 index 000000000..d7665f767 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-begin-end-around-single-statement.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [begin, end, single-statement, aa0013, codecop, compound] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not wrap a single statement in `begin … end` (CodeCop AA0013) + +## Description + +CodeCop AA0013 flags `begin … end` blocks that contain exactly one statement. The compound-block syntax exists to group multiple statements as a unit; using it for a single statement adds two lines and a level of nesting without adding meaning. `if IsAssemblyOutputLine then begin TestField("Order Line No.", 0); end;` should be `if IsAssemblyOutputLine then TestField("Order Line No.", 0);` — one statement, no block. The same logic applies after `else`, `for`, `while`, and `repeat`. + +## Best Practice + +A single statement following `then`, `else`, `do`, or a case label is written on its own line, indented one level, with no `begin … end`. Use `begin … end` only when there are two or more statements to group. + +See sample: `no-begin-end-around-single-statement.good.al`. + +## Anti Pattern + +`if Cond then begin OneCall(); end;` — single statement wrapped in a block. AA0013 flags it. The reviewer signal is "a `begin` followed by exactly one statement before its `end`." + +See sample: `no-begin-end-around-single-statement.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al new file mode 100644 index 000000000..eefa88801 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al @@ -0,0 +1,13 @@ +codeunit 50243 "Sample Redundant Else Bad" +{ + procedure Validate(IsAdjmtBinCodeChanged: Boolean) + var + AdjmtBinErr: Label 'Adjustment bin code change not allowed.'; + BinCodeErr: Label 'Bin code change not allowed.'; + begin + if IsAdjmtBinCodeChanged then + Error(AdjmtBinErr) + else + Error(BinCodeErr); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-else-after-terminating-statement.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-else-after-terminating-statement.good.al new file mode 100644 index 000000000..15a0941dd --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-else-after-terminating-statement.good.al @@ -0,0 +1,12 @@ +codeunit 50242 "Sample No Else Good" +{ + procedure Validate(IsAdjmtBinCodeChanged: Boolean) + var + AdjmtBinErr: Label 'Adjustment bin code change not allowed.'; + BinCodeErr: Label 'Bin code change not allowed.'; + begin + if IsAdjmtBinCodeChanged then + Error(AdjmtBinErr); + Error(BinCodeErr); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-else-after-terminating-statement.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-else-after-terminating-statement.md new file mode 100644 index 000000000..76d7ede18 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-else-after-terminating-statement.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [else, exit, break, skip, quit, error, terminating, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Omit `else` when the `then` branch ends with `exit`, `break`, `skip`, `quit`, or `error` + +## Description + +When the `then` branch of an `if` ends in a terminating statement — `exit`, `break`, `skip`, `quit`, or `error` — the `else` branch becomes the natural fall-through. `if Cond then exit; DoX();` and `if Cond then exit else DoX();` are equivalent, and the second form adds a layer of nesting that the reader has to mentally flatten. The same applies to `Error(...)`: `if IsAdjmtBinCodeChanged() then Error(AdjmtErr) else Error(BinErr);` is better written as `if IsAdjmtBinCodeChanged() then Error(AdjmtErr); Error(BinErr);` — the second `Error` is always reached when the first branch is not taken. + +## Best Practice + +Drop the `else` when the `then` branch unconditionally exits the procedure or the enclosing loop. The body that would have been inside `else` becomes the unindented continuation. + +See sample: `no-else-after-terminating-statement.good.al`. + +## Anti Pattern + +An `if … then Error(…) else Error(…)` pair where both branches terminate. The `else` is structural noise — the reader cannot tell at a glance whether it exists to handle an actual continuation or simply mirrors the `then`. The fix is to drop `else` and let the second `Error` fall through naturally. + +See sample: `no-else-after-terminating-statement.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al new file mode 100644 index 000000000..b2f8295ae --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al @@ -0,0 +1,11 @@ +codeunit 50231 "Sample No Space Paren Bad" +{ + procedure Lookup(CustomerNo: Code[20]) + var + Customer: Record Customer; + GreetingMsg: Label 'Hello %1'; + begin + if Customer.Get ( CustomerNo ) then + Message ( GreetingMsg, Customer.Name ); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al new file mode 100644 index 000000000..eb16dc30f --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al @@ -0,0 +1,11 @@ +codeunit 50230 "Sample No Space Paren Good" +{ + procedure Lookup(CustomerNo: Code[20]) + var + Customer: Record Customer; + GreetingMsg: Label 'Hello %1'; + begin + if Customer.Get(CustomerNo) then + Message(GreetingMsg, Customer.Name); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-space-before-method-parenthesis.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-space-before-method-parenthesis.md new file mode 100644 index 000000000..d7a2f6978 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/no-space-before-method-parenthesis.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, parenthesis, method-call, aa0002, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# No space between a method name and its opening parenthesis (CodeCop AA0002) + +## Description + +CodeCop AA0002 forbids whitespace between a procedure/method name and its `(`. `Customer.Get(CustomerNo)` is correct; `Customer.Get (CustomerNo)` is not. The rule applies to user-defined procedures, system methods (`Insert`, `FindFirst`, `CalcFields`), trigger-style invocations, and the parenthesised cast/conversion forms (`Format(Value)`, `CopyStr(Source, 1, 10)`). The whitespace between `(` and the first argument, and between the last argument and `)`, is also forbidden by the same rule. + +## Best Practice + +`Customer.Get(CustomerNo)`, `Customer.SetFilter("No.", '%1', '*A*')`, `Message(GreetingMsg, UserName)`. The standard AL formatter enforces this automatically. + +See sample: `no-space-before-method-parenthesis.good.al`. + +## Anti Pattern + +`Customer.Get ( CustomerNo )`, `Message ( GreetingMsg, UserName )`. Both trip AA0002 and read as if the call had an extra unnamed parameter — a small but persistent friction every reader pays. + +See sample: `no-space-before-method-parenthesis.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/object-name-30-char-limit.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/object-name-30-char-limit.md new file mode 100644 index 000000000..f0e96eef5 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/object-name-30-char-limit.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: style +keywords: [object-name, length, prefix, affix, 30-characters, appsource] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep object names within the 30-character platform limit + +## Description + +Business Central object names — for tables, pages, codeunits, reports, queries, XML ports, enums, and permission sets — are limited to 30 characters in total. AppSource and per-tenant extensions also have to carry a mandatory prefix or affix (typically 3–4 characters), which leaves roughly 26 characters for the descriptive part of the name. Names hitting the 30-character ceiling are routinely rejected at publish time, and over-aggressive abbreviation to fit (`CustLE`, `SIPoster`, `SalesInv`) makes the object name opaque to reviewers and to anyone reading dependency lists. The right move is to plan name length around the budget — descriptive base + prefix — not to discover the limit during AppSource validation. + +## Best Practice + +Choose a clear, descriptive name in the 20–26-character range and reserve the remaining characters for the mandatory app prefix. `"Customer Ledger Entry"`, `"Sales Invoice Posting"`, `"Sales Invoice"` are descriptive and well under the budget. When you genuinely need to abbreviate, prefer abbreviations that are already established in BC (`Cust.`, `Vend.`, `Gen. Jnl.`, `WHSE`) over ad-hoc shortenings. + +## Anti Pattern + +Names like `"CustLE"` or `"SIPoster"` that abbreviate beyond comprehensibility, or names like `"Customer Ledger Entry Posting Helper Codeunit"` that breach 30 characters and force a rename during publish. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al new file mode 100644 index 000000000..9d5269c3f --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al @@ -0,0 +1,17 @@ +table 50255 "Sample OptionCaption Bad" +{ + fields + { + field(1; Status; Option) + { + Caption = 'Status'; + OptionMembers = Open,Released,Pending; + } + field(2; Priority; Option) + { + Caption = 'Priority'; + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High'; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al new file mode 100644 index 000000000..d0e9866d7 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al @@ -0,0 +1,18 @@ +table 50254 "Sample OptionCaption Good" +{ + fields + { + field(1; Status; Option) + { + Caption = 'Status'; + OptionMembers = Open,Released,Pending; + OptionCaption = 'Open,Released,Pending'; + } + field(2; Priority; Option) + { + Caption = 'Priority'; + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High,Critical'; + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md new file mode 100644 index 000000000..d961040f6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [optioncaption, option, member-count, aa0221, aa0223, aa0224] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Option fields need `OptionCaption`, and its element count must match `OptionMembers` (CodeCop AA0221/AA0223/AA0224) + +## Description + +CodeCop AA0221 requires an `OptionCaption` on every option-type field that is not sourced from a table column (table-sourced option fields inherit the captions of the underlying field). AA0223 and AA0224 add two integrity checks: the number of comma-separated entries in `OptionCaption` must equal the number of entries in `OptionMembers`, and each caption must align by position with its member. The position alignment is what the platform uses to translate option values — the `OptionMembers` list never changes per locale, the `OptionCaption` list does. A mismatch in count or order produces silent corruption: the option `Released` shows the caption that belongs to `Pending`, and the bug is locale-dependent. + +## Best Practice + +`OptionMembers = Open,Released,Pending;` and `OptionCaption = 'Open,Released,Pending';` — same count, same order. When adding a new member, update both lines in the same commit. + +See sample: `optioncaption-required-and-matches-membercount.good.al`. + +## Anti Pattern + +`OptionMembers = Open,Released,Pending;` with no `OptionCaption` at all (the user sees the raw English members and translation is impossible), or `OptionMembers = Low,Medium,High,Critical;` paired with `OptionCaption = 'Low,Medium,High';` — count mismatch, `Critical` displays as blank or carries the wrong caption depending on platform version. + +See sample: `optioncaption-required-and-matches-membercount.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/page-name-must-match-source-table.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/page-name-must-match-source-table.md new file mode 100644 index 000000000..b8706cbb8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/page-name-must-match-source-table.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [page-name, source-table, misleading, naming] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A page or view name must describe the table it shows + +## Description + +A page (or filtered page View) whose name references one entity but whose `SourceTable` is a different entity misleads every consumer of the object's metadata. A page named `"Items with Negative Inventory"` that sources `"Stockkeeping Unit"` looks like a list of items in the search bar and in role explorer, but presents stockkeeping-unit fields and behaviour. The fix is either to rename the page to match the source table — `"Stockkeeping Units with Negative Inventory"` — or to change the source table to the entity the name promises. The choice depends on which the actual users are asking for; the constraint is that the two MUST agree. + +The rule extends to filtered Views declared inside a page: the `View` name should describe the filter applied to the page's existing source, not introduce a different entity. + +## Best Practice + +Read the page name out loud and ask: "If a user typed this into the search bar, would they expect to see rows from ``?" If the answer is no, rename one side or the other. The same check applies whenever the source table changes — the name has to follow. + +## Anti Pattern + +`page "Items with Negative Inventory" { SourceTable = "Stockkeeping Unit"; … }`. The Tell-Don't-Ask name asserts items; the source contradicts it. Reviewers should flag every mismatch they spot, even when both sides "make sense individually" — they have to agree. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-after-not-operator.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-after-not-operator.bad.al new file mode 100644 index 000000000..c7143e744 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-after-not-operator.bad.al @@ -0,0 +1,11 @@ +codeunit 50233 "Sample Not Spacing Bad" +{ + procedure Check(): Boolean + var + Customer: Record Customer; + begin + if NOT Customer.IsEmpty() then + exit(true); + exit(false); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-after-not-operator.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-after-not-operator.good.al new file mode 100644 index 000000000..92d8f33a7 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-after-not-operator.good.al @@ -0,0 +1,11 @@ +codeunit 50232 "Sample Not Spacing Good" +{ + procedure Check(): Boolean + var + Customer: Record Customer; + begin + if not Customer.IsEmpty() then + exit(true); + exit(false); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-after-not-operator.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-after-not-operator.md new file mode 100644 index 000000000..c5d2077b0 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-after-not-operator.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, not, operator, aa0003, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Exactly one space between `not` and its argument (CodeCop AA0003) + +## Description + +CodeCop AA0003 requires exactly one space between the `not` operator and the expression it negates. `if not Customer.FindFirst() then …` is correct; `if not Customer.FindFirst() then …` (two spaces) and `if notCustomer.FindFirst() then …` (zero — which fails parsing anyway) are not. The rule is also the place where uppercase `NOT` is flagged in combination with CodeCop AA0241 (reserved keywords must be lowercase): `if NOT Condition then` is doubly wrong. + +## Best Practice + +`if not Condition then`, `if not Customer.IsEmpty() then`, `exit(not Result)`. One space, lowercase keyword, no parentheses around the bare boolean. + +See sample: `single-space-after-not-operator.good.al`. + +## Anti Pattern + +`if NOT condition then`, `if not condition then`, `if !condition then` (which is not even AL — `!` is not a negation operator in AL). All three either trip AA0003 / AA0241 or fail to compile. + +See sample: `single-space-after-not-operator.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-around-binary-operators.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-around-binary-operators.bad.al new file mode 100644 index 000000000..2515d3391 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-around-binary-operators.bad.al @@ -0,0 +1,12 @@ +codeunit 50229 "Sample Spaces Op Bad" +{ + 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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-around-binary-operators.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-around-binary-operators.good.al new file mode 100644 index 000000000..55793d382 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-around-binary-operators.good.al @@ -0,0 +1,12 @@ +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-around-binary-operators.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-around-binary-operators.md new file mode 100644 index 000000000..a09fef94c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/single-space-around-binary-operators.md @@ -0,0 +1,26 @@ +--- +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/telemetry-event-id-stable-unique.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/telemetry-event-id-stable-unique.bad.al new file mode 100644 index 000000000..be60f4bd6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/telemetry-event-id-stable-unique.bad.al @@ -0,0 +1,13 @@ +codeunit 50260 "Sample Telemetry Id Bad" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage( + '0000', + 'Customer record processed', + Verbosity::Normal, + DataClassification::SystemMetadata, + TelemetryScope::All, + 'Category', 'QualitySamples'); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/telemetry-event-id-stable-unique.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/telemetry-event-id-stable-unique.good.al new file mode 100644 index 000000000..4491e31b3 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/telemetry-event-id-stable-unique.good.al @@ -0,0 +1,13 @@ +codeunit 50261 "Sample Telemetry Id Good" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage( + 'QS0001', + 'Customer record processed', + Verbosity::Normal, + DataClassification::SystemMetadata, + TelemetryScope::All, + 'Category', 'QualitySamples'); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/telemetry-event-id-stable-unique.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/telemetry-event-id-stable-unique.md new file mode 100644 index 000000000..da00af3dc --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/telemetry-event-id-stable-unique.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: style +keywords: [telemetry, logmessage, event-id, sessionlogmessage, observability] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Telemetry event IDs must be stable, unique, and non-placeholder + +## Description + +The first parameter of `Session.LogMessage` is the **event ID**. Telemetry consumers — Application Insights queries, KQL dashboards, alert rules, support runbooks — pivot on this ID to filter and aggregate events. The contract works only when the ID is: + +- **Stable** across releases: the same logical event keeps the same ID, so existing queries continue to match it. +- **Unique** within the extension's telemetry catalogue: two different events MUST NOT share an ID, or downstream consumers cannot distinguish them. +- **Non-placeholder**: literal IDs like `'0000'`, `'1234'`, `'TODO'`, or `'XX0000'` are placeholders that collide with other placeholder-using extensions, are unsearchable, and indicate the catalogue entry was never registered. + +The convention used by Microsoft first-party AL code is a short prefix identifying the publisher or feature followed by a numeric suffix — for example `'AL0001'`, `'CUST0042'`, `'SHPFY-0007'`. The exact format is up to the extension; the requirements are stability, uniqueness, and that the chosen ID is registered in whatever catalogue or wiki the extension's telemetry consumers reference. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al new file mode 100644 index 000000000..62a1dbec0 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al @@ -0,0 +1,12 @@ +codeunit 50217 "Sample Temp Prefix Bad" +{ + procedure BuildBuffer(var SalesLine: Record "Sales Line" temporary) + var + WIPBuffer: Record "Job WIP Buffer" temporary; + begin + WIPBuffer.Init(); + WIPBuffer.Insert(); + SalesLine.Init(); + SalesLine.Insert(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al new file mode 100644 index 000000000..09123a94d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al @@ -0,0 +1,12 @@ +codeunit 50216 "Sample Temp Prefix Good" +{ + procedure BuildBuffer(var TempSalesLine: Record "Sales Line" temporary) + var + TempJobWIPBuffer: Record "Job WIP Buffer" temporary; + begin + TempJobWIPBuffer.Init(); + TempJobWIPBuffer.Insert(); + TempSalesLine.Init(); + TempSalesLine.Insert(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/temporary-variable-temp-prefix.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/temporary-variable-temp-prefix.md new file mode 100644 index 000000000..2211b4cc8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/temporary-variable-temp-prefix.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [temporary, temp, prefix, record-variable, naming] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefix temporary record variables with `Temp` + +## Description + +A `Record` variable declared with the `temporary` modifier behaves nothing like a normal record variable: it never touches the database, holds rows only for the lifetime of the variable, and is not visible to filters or queries on the underlying table. The BC convention is to make that difference visible at every call site by prefixing the variable name with `Temp` — `TempJobWIPBuffer`, `TempSalesLine`, `TempIntegerBuffer`. The convention is load-bearing for code review: when a reader sees `SalesLine.Insert()`, they expect a database write; when they see `TempSalesLine.Insert()`, they know it is an in-memory buffer. + +## Best Practice + +Every variable of type `Record X temporary` must start with `Temp`. The same applies to parameters: a procedure that receives a temporary record as a buffer names the parameter `TempBuffer`, `TempSalesLine`, and so on. The convention extends naturally to derived names — `TempJobWIPBufferCopy`, `TempSourceSalesLine` — anything that starts with `Temp` is in-memory. + +See sample: `temporary-variable-temp-prefix.good.al`. + +## Anti Pattern + +`WIPBuffer: Record "Job WIP Buffer" temporary;` reads at the call site as if it were a database operation: `WIPBuffer.Insert()` looks identical to a write to the underlying table. The reader has to scroll back to the declaration to discover that this is in-memory, every time. + +See sample: `temporary-variable-temp-prefix.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al new file mode 100644 index 000000000..7fd03a1bd --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al @@ -0,0 +1,14 @@ +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/this-keyword-in-codeunits.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/this-keyword-in-codeunits.good.al new file mode 100644 index 000000000..392c0427b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/this-keyword-in-codeunits.good.al @@ -0,0 +1,14 @@ +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/this-keyword-in-codeunits.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/this-keyword-in-codeunits.md new file mode 100644 index 000000000..ffcf5a1db --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/this-keyword-in-codeunits.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al new file mode 100644 index 000000000..e6b356c68 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al @@ -0,0 +1,23 @@ +page 50251 "Sample Tooltip Bad" +{ + PageType = Card; + SourceTable = Customer; + layout + { + area(Content) + { + group(General) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + } + field(Amount; Rec."Balance (LCY)") + { + ApplicationArea = All; + ToolTip = ''; + } + } + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al new file mode 100644 index 000000000..1816de568 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al @@ -0,0 +1,24 @@ +page 50250 "Sample Tooltip Good" +{ + PageType = Card; + SourceTable = Customer; + layout + { + area(Content) + { + group(General) + { + field("No."; Rec."No.") + { + ApplicationArea = All; + ToolTip = 'Specifies the number that identifies the customer.'; + } + field(Amount; Rec."Balance (LCY)") + { + ApplicationArea = All; + ToolTip = 'Shows the total balance in local currency.'; + } + } + } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/tooltip-required-on-page-fields.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/tooltip-required-on-page-fields.md new file mode 100644 index 000000000..fc5a3cb65 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/tooltip-required-on-page-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [tooltip, page-field, aa0218, codecop, accessibility, specifies] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every page field needs a `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. + +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. + +## 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". + +See sample: `tooltip-required-on-page-fields.good.al`. + +## 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. + +See sample: `tooltip-required-on-page-fields.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al new file mode 100644 index 000000000..3131fa6aa --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al @@ -0,0 +1,13 @@ +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-declaration-order-by-type.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-declaration-order-by-type.good.al new file mode 100644 index 000000000..590ed25b8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-declaration-order-by-type.good.al @@ -0,0 +1,13 @@ +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-declaration-order-by-type.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-declaration-order-by-type.md new file mode 100644 index 000000000..3435726d1 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-declaration-order-by-type.md @@ -0,0 +1,26 @@ +--- +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al new file mode 100644 index 000000000..65c82238d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al @@ -0,0 +1,19 @@ +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-name-must-not-shadow.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-name-must-not-shadow.good.al new file mode 100644 index 000000000..f5391efd8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-name-must-not-shadow.good.al @@ -0,0 +1,19 @@ +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-name-must-not-shadow.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-name-must-not-shadow.md new file mode 100644 index 000000000..4fee2daaf --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/variable-name-must-not-shadow.md @@ -0,0 +1,26 @@ +--- +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/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md new file mode 100644 index 000000000..bffa5e814 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [xmldoc, summary, param, returns, public-procedure, documentation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Add XML documentation to public procedures on library/API codeunits + +## Description + +XML documentation comments (`/// `, `/// …`, `/// `) are expected on procedures that form the public surface of a library — codeunits intended to be called from outside the current app: System App modules, AppSource library codeunits, `Access = Public` codeunits exposed for extension. The supported tags are ``, ``, ``, ``, ``, and ``. Active wording is preferred — `'Sets…'`, `'Gets…'`, `'Specifies…'` — and the docs should list parameter preconditions and any exceptions the procedure may raise. + +XML docs are NOT required on internal procedures, event subscribers, trigger implementations, page-part procedures, test procedures, or the object declarations themselves (tables, pages, codeunits). The reviewer signal is a `procedure` (not `local procedure`, not `internal procedure`) declared inside a codeunit whose role is "library" — those need XML docs; everything else is optional. + +## Best Practice + +For every public procedure on a library codeunit, write a `` describing what the procedure does, one `` per parameter naming its role and preconditions, and `` describing the return when applicable. Avoid placeholder text — `Validates discount` is no better than no doc at all. + +## Anti Pattern + +A public procedure on a library codeunit with no XML doc, or a `` that restates the procedure name in three words. The first leaves consumers guessing at intent; the second wastes the slot a meaningful description should occupy. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al new file mode 100644 index 000000000..00ed5e383 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al @@ -0,0 +1,15 @@ +// A pre-existing table with millions of rows. Changing the primary key or +// widening a field type without an upgrade plan can fail at deployment. +tableextension 50233 "Cust Ledger Entry Ext" extends "Cust. Ledger Entry" +{ + fields + { + // Widening Integer to BigInteger on an existing column with persisted data + // requires an upgrade plan and value-range evidence; not safe as a bare edit. + modify("Entry No.") + { + // (hypothetical: field type change goes here) + } + } + // No accompanying upgrade codeunit, no upgrade tag, no overflow verification. +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al new file mode 100644 index 000000000..455477a59 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al @@ -0,0 +1,16 @@ +// New feature table introduced in the same change as the keys / field types. +// No existing data, so the layout is free to choose. +table 50232 "New Feature Table" +{ + fields + { + field(1; "Entry No."; BigInteger) { } + field(2; "Customer No."; Code[20]) { } + field(3; "Posting Date"; Date) { } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByCustomer; "Customer No.", "Posting Date") { } + } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md new file mode 100644 index 000000000..9ba7e8ac5 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [primary-key, field-type, breaking-change, integer-to-biginteger, existing-data] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Primary-key and field-type changes are safe only on tables without existing data + +## Description + +Primary-key changes and field-type changes (for example widening `Integer` to `BigInteger`) rewrite the on-disk layout of every row in the table. On a new feature table that ships in the same change as the modification, no rows exist and the change is free. On an existing table that already holds tenant data — base-app tables, ledger entries, anything that has been live across releases — the same change can fail outright (key uniqueness violations, value overflow on conversion) or require a full table rewrite during the upgrade window. Either way, the change needs an explicit migration design, not just a metadata edit. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al new file mode 100644 index 000000000..30c601a18 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al @@ -0,0 +1,23 @@ +codeunit 50219 "Upgrade Price List Source" +{ + Subtype = Upgrade; + + local procedure UpdatePriceSourceGroupInPriceListLines() + var + PriceListLine: Record "Price List Line"; + begin + // One round-trip per row across a potentially large table. + PriceListLine.SetRange("Source Group", "Price Source Group"::All); + if PriceListLine.FindSet(true) then + repeat + if PriceListLine."Source Type" in + ["Price Source Type"::"All Jobs", + "Price Source Type"::Job, + "Price Source Type"::"Job Task"] + then begin + PriceListLine."Source Group" := "Price Source Group"::Job; + PriceListLine.Modify(); + end; + until PriceListLine.Next() = 0; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al new file mode 100644 index 000000000..119e9f0c9 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al @@ -0,0 +1,23 @@ +codeunit 50218 "Upgrade Price List Source" +{ + Subtype = Upgrade; + + local procedure UpdatePriceSourceGroupInPriceListLines() + var + PriceListLine: Record "Price List Line"; + PriceListLineDataTransfer: DataTransfer; + begin + PriceListLineDataTransfer.SetTables(Database::"Price List Line", Database::"Price List Line"); + PriceListLineDataTransfer.AddSourceFilter( + PriceListLine.FieldNo("Source Group"), '=%1', "Price Source Group"::All); + PriceListLineDataTransfer.AddSourceFilter( + PriceListLine.FieldNo("Source Type"), '%1|%2|%3', + "Price Source Type"::"All Jobs", + "Price Source Type"::Job, + "Price Source Type"::"Job Task"); + PriceListLineDataTransfer.AddConstantValue( + "Price Source Group"::Job, PriceListLine.FieldNo("Source Group")); + PriceListLineDataTransfer.CopyFields(); + Clear(PriceListLineDataTransfer); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md new file mode 100644 index 000000000..3eeaa4608 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [datatransfer, large-dataset, bulk-update, modifyall, copyfields, new-field] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use `DataTransfer` for bulk updates on large tables + +## Description + +Tables that can contain more than 300,000 records, and any newly added field on an existing table, should be initialized with `DataTransfer` rather than a `repeat ... Modify ... until Next() = 0` loop. `DataTransfer` issues a single set-based statement to the database; the loop/modify pattern issues one round-trip per row and accumulates write locks for the duration of the upgrade. On the volumes that drive upgrade pain — ledger entries, item ledger entries, price list lines — the difference is the upgrade running for minutes instead of hours. + +## Best Practice + +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`. + +## 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 also + +- `datatransfer-skips-triggers-and-subscribers.md` — `DataTransfer` does not raise field validation triggers or event subscribers; if a row needs validation logic, `DataTransfer` is the wrong tool. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al new file mode 100644 index 000000000..800c8282c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al @@ -0,0 +1,16 @@ +codeunit 50221 "Upgrade Existing Field" +{ + Subtype = Upgrade; + + local procedure UpdateCustomerCreditLimit() + var + Customer: Record Customer; + DT: DataTransfer; + begin + // "Credit Limit (LCY)" has OnValidate logic that recalculates risk fields + // and notifies subscribers. DataTransfer skips both — derived data drifts. + DT.SetTables(Database::Customer, Database::Customer); + DT.AddConstantValue(50000, Customer.FieldNo("Credit Limit (LCY)")); + DT.CopyFields(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al new file mode 100644 index 000000000..0475079f8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al @@ -0,0 +1,16 @@ +codeunit 50220 "Upgrade New Field Init" +{ + Subtype = Upgrade; + + local procedure InitializeNewFlagOnMyTable() + var + MyTable: Record "My Table"; + DT: DataTransfer; + begin + // "New Flag" is added in the same change as this upgrade procedure. + // No existing validation logic depends on it, so DataTransfer is safe. + DT.SetTables(Database::"My Table", Database::"My Table"); + DT.AddConstantValue(true, MyTable.FieldNo("New Flag")); + DT.CopyFields(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md new file mode 100644 index 000000000..785684f53 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [datatransfer, validate-trigger, event-subscriber, side-effects, business-logic] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `DataTransfer` does not fire validation triggers or event subscribers + +## Description + +`DataTransfer` writes directly at the database layer. It does not invoke field `OnValidate` triggers, table `OnModify` triggers, or any `OnAfterModifyEvent` / `OnBeforeValidate...` event subscribers that a normal `Record.Modify(true)` would. This is precisely what makes it fast — and precisely what makes it a footgun when the field being updated has validation logic that other code relies on. The receiving code never gets the signal that a row changed, derived fields stay stale, audit hooks do not run. + +For *new fields and tables added in the same change* this is fine: nothing yet depends on the validation. For *pre-existing fields with validation logic*, `DataTransfer` quietly bypasses business logic that may be load-bearing for posting, calculation, or integration scenarios. + +## Best Practice + +Use `DataTransfer` only when the field or table is new in the same change — initial population is the canonical safe case. When updating a pre-existing field that has validation logic, either use `Modify(true)` to honour the triggers, or, if `DataTransfer` is still required for performance reasons, leave a comment that explicitly states "validation triggers and event subscribers are intentionally not raised" and verify with the field's owner that this is safe. + +See sample: `datatransfer-skips-triggers-and-subscribers.good.al`. + +## Anti Pattern + +Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` logic, without a comment and without confirming that subscribers can be skipped. The upgrade succeeds; runtime behaviour drifts silently. + +See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al new file mode 100644 index 000000000..2c200c69b --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al @@ -0,0 +1,17 @@ +codeunit 50207 "Upgrade Graceful" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeCustomerLink('C00010'); + end; + + local procedure UpgradeCustomerLink(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + // Throws if the record is missing — aborts the upgrade. + Customer.Get(CustomerNo); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al new file mode 100644 index 000000000..7a83df25e --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al @@ -0,0 +1,26 @@ +codeunit 50206 "Upgrade Graceful" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeCustomerLink('C00010'); + end; + + local procedure UpgradeCustomerLink(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then begin + Session.LogMessage( + '0000ABC', + 'Customer not found during upgrade', + Verbosity::Warning, + DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, + 'CustomerNo', CustomerNo); + exit; + end; + // Continue upgrade work using Customer ... + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md new file mode 100644 index 000000000..cf585eb24 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [error-handling, telemetry, session-logmessage, blocking, graceful] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Log telemetry; do not raise errors that block the upgrade + +## Description + +When upgrade code encounters unexpected data — a record it expected to find, a relationship it assumed to be intact — the response is to log telemetry and continue, not to raise an error. A runtime error inside an upgrade codeunit aborts the upgrade for the company or database, leaving the customer stuck on the old version. Customers should not be blocked from upgrading because of a data inconsistency that an upgrade routine could not have anticipated. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al new file mode 100644 index 000000000..ad1606668 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al @@ -0,0 +1,11 @@ +enum 50226 "My Enum" +{ + Extensible = true; + + value(0; "First") { } + value(1; "NewMiddleValue") { } // Inserted in the middle — shifts ordinals. + value(2; "Second") { } + value(3; "Third") { } + // Or: a previously declared value(1; "Second") removed without obsoletion — + // any persisted "1" now maps to whatever currently occupies ordinal 1. +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al new file mode 100644 index 000000000..a585a2c1c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al @@ -0,0 +1,9 @@ +enum 50225 "My Enum" +{ + Extensible = true; + + value(0; "First") { } + value(1; "Second") { } + value(2; "Third") { } + value(3; "NewValue") { } // Appended at the end — no existing ordinal shifts. +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/enum-values-additive-at-end.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/enum-values-additive-at-end.md new file mode 100644 index 000000000..4de929bfc --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/enum-values-additive-at-end.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [enum, ordinal, additive, append, backward-compatible, breaking-change] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Add new enum values only at the end + +## Description + +An AL `enum` is a fixed list of ordinal-named values. Persisted rows reference enum members by ordinal, not by name. The only enum mutation that preserves the meaning of every existing row is **appending a new value at the end** — every previously valid ordinal still maps to the same member. Inserting a new value in the middle, renumbering existing values, or removing a value without obsoletion all shift ordinals: rows written with the old layout silently take on the new member at their saved ordinal. + +## Best Practice + +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`. + +## 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 also + +- `obsoletion-requires-reason-and-tag.md` — how to retire an enum member correctly. +- `obsolete-pending-to-removed-staging.md` — the `Pending → Removed` lifecycle. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al new file mode 100644 index 000000000..e3381ad10 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al @@ -0,0 +1,13 @@ +codeunit 50211 "Install My Extension" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + begin + // No DataVersion() guard — this runs on every reinstall and upgrade + // path, duplicating seed rows. + SeedDefaultRows(); + end; + + local procedure SeedDefaultRows() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al new file mode 100644 index 000000000..9d6e92ee6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al @@ -0,0 +1,15 @@ +codeunit 50210 "Install My Extension" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + var + AppInfo: ModuleInfo; + begin + NavApp.GetCurrentModuleInfo(AppInfo); + if AppInfo.DataVersion() <> Version.Create('0.0.0.0') then + exit; + + // Install-only seed code goes here. + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md new file mode 100644 index 000000000..260b15b82 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [dataversion, first-install, on-install-app-per-company, moduleinfo, zero-version] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Detect first install with `DataVersion() = Version.Create('0.0.0.0')` + +## Description + +On the first install of an extension on a tenant the platform records a zero data version: `AppInfo.DataVersion()` returns `Version.Create('0.0.0.0')`. Subsequent upgrades record the actual previous version. The `OnInstallAppPerCompany` trigger uses this distinction to detect a brand-new install — for example, to seed default rows that should not be re-inserted on a normal upgrade. This is the one place where reading `DataVersion()` is the right tool; for everything else, use an upgrade tag. + +## Best Practice + +In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run install-only seed logic only when they match. On any non-zero data version, exit immediately — that path is an upgrade, not an install. + +See sample: `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, install-only seed code re-runs on every upgrade and duplicates rows. + +See sample: `first-install-dataversion-zero-check.bad.al`. + +## See also + +- `use-upgrade-tags-not-version-checks.md` — for upgrade steps after first install, use upgrade tags rather than `DataVersion`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/guard-database-reads.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/guard-database-reads.bad.al new file mode 100644 index 000000000..0c28fa681 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/guard-database-reads.bad.al @@ -0,0 +1,20 @@ +codeunit 50205 "Upgrade Guarded Reads" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + Item: Record Item; + Customer: Record Customer; + Vendor: Record Vendor; + begin + Item.Get('1000'); // Throws if missing; aborts upgrade. + Customer.FindSet(); // Throws if empty. + Vendor.FindLast(); // Throws if empty. + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/guard-database-reads.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/guard-database-reads.good.al new file mode 100644 index 000000000..868f2ae76 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/guard-database-reads.good.al @@ -0,0 +1,22 @@ +codeunit 50204 "Upgrade Guarded Reads" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + Item: Record Item; + Customer: Record Customer; + Vendor: Record Vendor; + begin + if Item.Get('1000') then + Item.Modify(); + if Customer.FindSet() then; + if not Vendor.FindLast() then + exit; + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/guard-database-reads.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/guard-database-reads.md new file mode 100644 index 000000000..c5bc206e8 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/guard-database-reads.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [get, findset, findlast, guard, if-then, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard every database read in upgrade code with `if` + +## Description + +Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgradePerCompany` / `OnUpgradePerDatabase`), an unguarded `Record.Get`, `Record.FindSet`, or `Record.FindLast` raises a runtime error when the row or set is missing. In upgrade context that error aborts the entire upgrade for the company or database — a far worse outcome than the missing data itself. Records the upgrade reasons about may legitimately not exist on every customer's tenant. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md new file mode 100644 index 000000000..2d047ac0a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [hybrid-migration, hybrid-bc14, hybrid-sl, hybrid-gp, hybrid-base-deployment, one-time-migration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Hybrid migration codeunits are not standard upgrade codeunits + +## Description + +Codeunits like `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` implement one-time migration paths from a specific source system into Business Central. They run in a different pipeline from the standard per-company / per-database upgrade triggers and follow patterns shaped by that source — staging tables, schema-mapped imports, and per-source post-processing. The rules that apply to standard upgrade codeunits — guarded reads, no external calls, `DataTransfer` for bulk init, `Subtype = Upgrade`, upgrade tags — are not the right yardstick for these migration codeunits. + +## Best Practice + +Treat a hybrid migration codeunit as a domain of its own. If you need to add or modify migration logic, follow the conventions of the surrounding hybrid migration codebase (which has its own dispatcher, its own way of recording progress, and its own error handling) rather than imposing standard upgrade conventions on it. Conversely, do not borrow hybrid-migration patterns into standard upgrade codeunits — the platform contract is different. + +When reviewing changes inside a hybrid migration codeunit, do not flag missing upgrade tags, missing `Subtype = Upgrade`, or missing `OnUpgradePerCompany` wiring. None of those apply. + +## Anti Pattern + +Reviewing a change inside `HybridBC14` / `HybridSL` / `HybridGP` / `HybridBaseDeployment` against standard upgrade rules and flagging the absence of `Subtype = Upgrade` or upgrade-tag plumbing. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al new file mode 100644 index 000000000..3ca0ab767 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al @@ -0,0 +1,15 @@ +tableextension 50224 "MyTable Ext" extends "My Table" +{ + fields + { + // InitValue only applies to rows inserted after deployment. + // Pre-existing rows silently carry the datatype default (false). + field(50200; "New Flag"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'New Flag'; + InitValue = true; + } + } + // No accompanying upgrade codeunit to back-fill existing rows. +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al new file mode 100644 index 000000000..c61284bcb --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al @@ -0,0 +1,43 @@ +tableextension 50222 "MyTable Ext" extends "My Table" +{ + fields + { + field(50200; "New Flag"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'New Flag'; + InitValue = true; + } + } +} + +codeunit 50223 "Upgrade MyTable NewFlag" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyTableNewFlag(); + end; + + local procedure UpgradeMyTableNewFlag() + var + MyTable: Record "My Table"; + UpgradeTag: Codeunit "Upgrade Tag"; + DT: DataTransfer; + begin + if UpgradeTag.HasUpgradeTag(MyTableNewFlagTag()) then + exit; + + DT.SetTables(Database::"My Table", Database::"My Table"); + DT.AddConstantValue(true, MyTable.FieldNo("New Flag")); + DT.CopyFields(); + + UpgradeTag.SetUpgradeTag(MyTableNewFlagTag()); + end; + + local procedure MyTableNewFlagTag(): Code[250] + begin + exit('MS-123456-MyTable-NewFlag-20240101'); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md new file mode 100644 index 000000000..4733ef25d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [initvalue, new-field, existing-rows, default-value, table-extension] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `InitValue` does not back-fill existing rows + +## Description + +`InitValue` on a field defines the value the platform assigns when a *new* record is inserted. It does not touch rows that already exist when the field is added. When a new field is added to an existing table — directly or via a table extension — every pre-existing row receives the datatype default (`false` for Boolean, `0` for numeric, empty for text), not the `InitValue`. If the intended semantics require existing rows to carry the `InitValue`, the change is incomplete without an upgrade routine that sets the field on those rows. + +Several legitimate cases do NOT need upgrade code: +- New fields on brand-new tables (no existing rows). +- New `Boolean` fields without `InitValue` where the datatype default `false` is the intended value. +- New fields on configuration / setup tables that have no meaningful "existing data". +- Informational or optional fields (logging, preferences, tracking) where `false` / empty is a valid state. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al new file mode 100644 index 000000000..69994e659 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al @@ -0,0 +1,13 @@ +codeunit 50235 "Upgrade With Validation" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + begin + // No skip logic and no written justification — full-table validation + // runs on every single upgrade pass. + ValidateAllCustomers(); + end; + + local procedure ValidateAllCustomers() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al new file mode 100644 index 000000000..9a5a83b71 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al @@ -0,0 +1,25 @@ +codeunit 50234 "Upgrade With Validation" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + // Justification: regulatory compliance requires a full-table scan once + // per tenant after this release. Tag prevents re-runs. + if UpgradeTag.HasUpgradeTag(MyValidationUpgradeTag()) then + exit; + + ValidateAllCustomers(); + + UpgradeTag.SetUpgradeTag(MyValidationUpgradeTag()); + end; + + local procedure ValidateAllCustomers() begin end; + + local procedure MyValidationUpgradeTag(): Code[250] + begin + exit('MS-123456-CustomerValidation-20240101'); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md new file mode 100644 index 000000000..2c02def35 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [on-validate-upgrade-per-company, performance-impact, skip-logic, justification, upgrade-tag] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Performance-impacting upgrade triggers need justification and skip logic + +## Description + +Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. When their body performs non-trivial work — full-table scans, cross-table validations — the cost is paid on every upgrade of every tenant, even when there is nothing to validate. That cost is acceptable only when the validation is critical (regulatory compliance, data-integrity guarantees the platform depends on) AND the trigger short-circuits once it has done its work. + +## Best Practice + +A performance-impacting upgrade trigger carries two things: a written comment that names the reason the work has to happen on every upgrade pass, and an early-exit guard backed by an upgrade tag so the work runs at most once per tenant. The `HasUpgradeTag` check at the top exits when the validation has already been recorded; the `SetUpgradeTag` call at the bottom records completion. + +See sample: `minimize-onvalidate-upgrade-triggers.good.al`. + +## Anti Pattern + +Doing real work in `OnValidateUpgradePerCompany` with no upgrade-tag guard. The same scan runs every upgrade, multiplying upgrade time by the number of releases the customer takes. + +See sample: `minimize-onvalidate-upgrade-triggers.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al new file mode 100644 index 000000000..2827b9686 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al @@ -0,0 +1,13 @@ +codeunit 50215 "Upgrade No External" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Client: HttpClient; + Response: HttpResponseMessage; + begin + // External call inside upgrade code — can hang or fail and abort the upgrade. + Client.Get('https://external-service.contoso.com/api/sync', Response); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al new file mode 100644 index 000000000..48f62b16f --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al @@ -0,0 +1,17 @@ +codeunit 50214 "Upgrade No External" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + ExternalSyncSetup: Record "External Sync Setup"; + begin + // Defer the external call: just set a flag the runtime path will pick up. + if not ExternalSyncSetup.Get() then begin + ExternalSyncSetup.Init(); + ExternalSyncSetup.Insert(); + end; + ExternalSyncSetup."Resync Required" := true; + ExternalSyncSetup.Modify(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md new file mode 100644 index 000000000..eb644b698 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [httpclient, dotnet, external-service, network-call, blocking, upgrade-rollback] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# No external calls inside upgrade codeunits + +## Description + +Upgrade code runs in a constrained execution window: the tenant is mid-upgrade, no users are signed in, and a failure aborts the entire transaction. An external HTTP call, DotNet interop call, or any other I/O to a system outside Business Central can hang or fail for reasons completely unrelated to the upgrade — DNS, expired credentials, a service that is itself being upgraded — and the upgrade fails with it. Rolling back from such a failure is hard because the upgrade pipeline assumes its work is deterministic. + +The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedure transitively invoked from `OnUpgrade...` triggers. The same calls in regular runtime code — pages, table triggers, normal codeunits, background jobs — are fine. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al new file mode 100644 index 000000000..e28ae943d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al @@ -0,0 +1,14 @@ +// Skipping the Pending stage and going straight to Removed leaves callers +// and persisted rows with no migration window. +enum 50231 "My Enum" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al new file mode 100644 index 000000000..be94807b9 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al @@ -0,0 +1,29 @@ +// Release N: deprecation announced. +enum 50229 "My Enum N" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } + value(3; "NewValue") { } +} + +// Release N+1 (or later): removal staged; upgrade code now migrates persisted rows. +enum 50230 "My Enum NPlus1" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } + value(3; "NewValue") { } +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md new file mode 100644 index 000000000..cb008ac2a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete-state, pending, removed, lifecycle, clean-flag, upgrade-code-timing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Stage obsoletion `Pending → Removed`; write upgrade code on removal + +## Description + +`ObsoleteState` has a deliberate two-step lifecycle. `Pending` keeps the element compilable and present — callers still find it but receive a deprecation warning. `Removed` marks the element as gone from the contract; the body may be empty or wrapped in `#if not CLEAN` so the symbol survives only for binary compatibility. Upgrade code that migrates persisted data away from the obsolete element is normally written when the element moves to `Removed`, not when it goes `Pending`. `ObsoleteState = Pending` without accompanying upgrade code is the expected steady state during the deprecation window; reviewers should not flag that combination as missing migration. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al new file mode 100644 index 000000000..22290a43d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al @@ -0,0 +1,8 @@ +codeunit 50228 "Old Method Holder" +{ + // ObsoleteState set without ObsoleteReason or ObsoleteTag. + [Obsolete('')] + procedure OldMethod() + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al new file mode 100644 index 000000000..8562b0c66 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al @@ -0,0 +1,12 @@ +codeunit 50227 "Old Method Holder" +{ + [Obsolete('Use NewMethod instead for better performance', '22.0')] + procedure OldMethod() + begin + // Body kept while ObsoleteState = Pending; warns at call sites. + end; + + procedure NewMethod() + begin + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md new file mode 100644 index 000000000..0f2e11e55 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete-state, obsolete-reason, obsolete-tag, deprecation, metadata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag` + +## Description + +When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation: + +- `ObsoleteState` — `Pending` while the element still exists but is being phased out, `Removed` once it should no longer be used. +- `ObsoleteReason` — a short human-readable string explaining what to use instead. Tooling and downstream consumers surface this when warning callers. +- `ObsoleteTag` — a stable version-like marker (typically the release version in which the deprecation was introduced, e.g. `'22.0'`). + +Omitting `ObsoleteReason` or `ObsoleteTag` leaves consumers with `ObsoleteState = Pending` but no guidance and no traceability. Declaring `ObsoleteState = Removed` without a reason or tag is the same failure with a stronger blast radius. + +## Best Practice + +Every obsoleted element carries all three properties together. The reason names the replacement explicitly; the tag is the version in which the deprecation was introduced and stays stable for the life of the deprecation. + +See sample: `obsoletion-requires-reason-and-tag.good.al`. + +## Anti Pattern + +Setting only `ObsoleteState = Pending;` (or `Removed`) without `ObsoleteReason` and `ObsoleteTag`. Callers see a warning with no explanation, and the deprecation cannot be tracked by version. + +See sample: `obsoletion-requires-reason-and-tag.bad.al`. + +## See also + +- `obsolete-pending-to-removed-staging.md` — when to advance `Pending` to `Removed` and write upgrade code. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al new file mode 100644 index 000000000..adf5cf547 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al @@ -0,0 +1,20 @@ +codeunit 50213 "Upgrade Tag Registration" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then + exit; + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); + end; + + local procedure MyUpgradeTag(): Code[250] + begin + exit('MS-123456-MyFeature-20240101'); + end; + + // No OnGetPerCompanyUpgradeTags subscriber — the tag is unknown to the platform. +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al new file mode 100644 index 000000000..02362c92c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al @@ -0,0 +1,25 @@ +codeunit 50212 "Upgrade Tag Registration" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then + exit; + // Upgrade work ... + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); + end; + + local procedure MyUpgradeTag(): Code[250] + begin + exit('MS-123456-MyFeature-20240101'); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)] + local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]]) + begin + PerCompanyUpgradeTags.Add(MyUpgradeTag()); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md new file mode 100644 index 000000000..a413520e7 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-tag, event-subscriber, on-get-per-company-upgrade-tags, on-get-per-database-upgrade-tags, registration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register every upgrade tag with the platform via an event subscriber + +## Description + +The `Upgrade Tag` codeunit only recognizes a tag if the tag was published to the platform through one of two events on that codeunit: `OnGetPerCompanyUpgradeTags` for tags set inside `OnUpgradePerCompany`, and `OnGetPerDatabaseUpgradeTags` for tags set inside `OnUpgradePerDatabase`. A tag that is `Set` and `Has`-checked in code but never added to one of these lists is unknown to the platform — its semantics around skip-on-reinstall, telemetry, and operator queries do not apply. + +The registration scope must match where the tag is set: a tag used from `OnUpgradePerCompany` registers in `OnGetPerCompanyUpgradeTags`; a tag used from `OnUpgradePerDatabase` registers in `OnGetPerDatabaseUpgradeTags`. Crossing the scopes silently breaks the tag. + +## Best Practice + +For every new upgrade tag, add one line to the matching subscriber: `PerCompanyUpgradeTags.Add(MyUpgradeTag());` or `PerDatabaseUpgradeTags.Add(MyUpgradeTag());`. Place the subscribers in the same codeunit (or a dedicated "Upgrade Tag Definitions" codeunit) so the tag string and its registration stay together. + +See sample: `register-upgrade-tags-with-subscribers.good.al`. + +## Anti Pattern + +Calling `UpgradeTag.SetUpgradeTag(MyUpgradeTag())` without ever adding `MyUpgradeTag()` to the corresponding `OnGetPerCompany...` / `OnGetPerDatabase...` subscriber. + +See sample: `register-upgrade-tags-with-subscribers.bad.al`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al new file mode 100644 index 000000000..db0df6f96 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al @@ -0,0 +1,12 @@ +codeunit 50217 "Report Selection Seeder" +{ + procedure AddReportSelectionEntries() + var + ReportSelections: Record "Report Selections"; + begin + // No context check — fires during upgrade and silently inserts rows + // the upgrade pipeline never asked for. + ReportSelections.Init(); + ReportSelections.Insert(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al new file mode 100644 index 000000000..61dfeda18 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al @@ -0,0 +1,15 @@ +codeunit 50216 "Report Selection Seeder" +{ + procedure AddReportSelectionEntries() + var + ReportSelections: Record "Report Selections"; + begin + // Do not add report-selection entries during upgrade; the upgrade pipeline + // does not need them and re-running this on every upgrade is wasteful. + if GetExecutionContext() = ExecutionContext::Upgrade then + exit; + + ReportSelections.Init(); + ReportSelections.Insert(); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md new file mode 100644 index 000000000..0b441e66d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [get-execution-context, execution-context-upgrade, skip, report-selection, runtime-trigger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Skip non-essential runtime work when `GetExecutionContext() = ExecutionContext::Upgrade` + +## Description + +Runtime procedures (table triggers, install routines, helpers called from many places) sometimes fire during the upgrade window because the upgrade itself touches the data they react to. When the work those procedures do is not strictly required for the upgrade to succeed — inserting report-selection entries, seeding optional configuration, sending welcome notifications — they should detect upgrade context with `GetExecutionContext() = ExecutionContext::Upgrade` and exit. This keeps upgrade transactions tight and avoids side effects that the upgrade pipeline did not ask for. + +This is the opposite of a load-bearing concern: code that MUST run during the upgrade does not consult execution context. The check is for *optional* side effects that happen to be wired into runtime code paths. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al new file mode 100644 index 000000000..e409ea891 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al @@ -0,0 +1,12 @@ +codeunit 50203 "Upgrade Orchestrator" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Customer: Record Customer; + begin + // Direct implementation in the trigger body — wrong. + Customer.ModifyAll("Some Field", true); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al new file mode 100644 index 000000000..d03fa4adf --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al @@ -0,0 +1,19 @@ +codeunit 50202 "Upgrade Orchestrator" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + UpgradeSecondFeature(); + end; + + local procedure UpgradeMyFeature() + var + Customer: Record Customer; + begin + Customer.ModifyAll("Some Field", true); + end; + + local procedure UpgradeSecondFeature() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md new file mode 100644 index 000000000..dcc21e3db --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [on-upgrade-per-company, on-upgrade-per-database, trigger-body, helper-procedure, structure] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `OnUpgradePerCompany` / `OnUpgradePerDatabase` should call helpers, not inline logic + +## Description + +The `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on an upgrade codeunit are dispatch points, not implementation slots. They should contain only calls to named local procedures — one call per feature being upgraded. Putting `ModifyAll`, record loops, or any business logic directly inside the trigger body makes the upgrade impossible to read, impossible to selectively skip via upgrade tags per feature, and impossible to extend without touching the trigger itself. + +Empty `OnUpgradePerCompany` / `OnUpgradePerDatabase` triggers are acceptable — they may be placeholders for future use or artifacts from cleanup. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al new file mode 100644 index 000000000..024443c80 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al @@ -0,0 +1,10 @@ +codeunit 50201 "Upgrade My Feature" +{ + // Missing Subtype = Upgrade; the OnUpgrade trigger is never dispatched. + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al new file mode 100644 index 000000000..5ef4e88fa --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al @@ -0,0 +1,17 @@ +codeunit 50200 "Upgrade My Feature" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + trigger OnUpgradePerDatabase() + begin + UpgradeMyGlobalSetup(); + end; + + local procedure UpgradeMyFeature() begin end; + local procedure UpgradeMyGlobalSetup() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md new file mode 100644 index 000000000..2dba21a0c --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-codeunit, subtype, on-upgrade-per-company, on-upgrade-per-database, trigger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Upgrade logic must live in a codeunit with `Subtype = Upgrade` + +## Description + +A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A codeunit without `Subtype = Upgrade` — even one that declares an `OnUpgradePerCompany` trigger — is not an upgrade codeunit, and reviewers ignore it for upgrade concerns. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit IS upgrade code regardless of where it lives, and the upgrade rules apply to it. + +## Best Practice + +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`. + +## 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`. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al new file mode 100644 index 000000000..f16946ed6 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al @@ -0,0 +1,25 @@ +codeunit 50209 "Upgrade Tag Driven" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + AppInfo: ModuleInfo; + begin + NavApp.GetCurrentModuleInfo(AppInfo); + + // Version-coupled branching — breaks when a tenant skips a version. + if AppInfo.DataVersion().Major > 14 then + exit; + + if AppInfo.DataVersion().Major < 14 then + UpgradeFeatureA() + else if AppInfo.DataVersion().Major < 17 then + UpgradeFeatureB() + else + exit; + end; + + local procedure UpgradeFeatureA() begin end; + local procedure UpgradeFeatureB() begin end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al new file mode 100644 index 000000000..958e3b691 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al @@ -0,0 +1,26 @@ +codeunit 50208 "Upgrade Tag Driven" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then + exit; + + // Upgrade work goes here. + + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); + end; + + local procedure MyUpgradeTag(): Code[250] + begin + exit('MS-123456-MyFeatureUpgrade-20240101'); + end; +} diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md new file mode 100644 index 000000000..62347d161 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-tag, version-check, dataversion, has-upgrade-tag, set-upgrade-tag, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Control upgrade execution with upgrade tags, not version checks + +## Description + +Each piece of upgrade logic must run exactly once per company (or database) across the lifetime of an extension. The platform mechanism for that is the `Upgrade Tag` codeunit: a procedure asks `HasUpgradeTag(MyTag())` at entry, performs its work, then calls `SetUpgradeTag(MyTag())` to record completion. Subsequent upgrades on the same tenant see the tag and skip the work. Hand-rolled `if MyApp.DataVersion().Major < N then ...` chains are the wrong tool: they are version-coupled, accumulate stale branches over time, and break when a tenant skips a version. + +## Best Practice + +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`. + +## 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 also + +- `first-install-dataversion-zero-check.md` — the one situation where reading `DataVersion()` is the right call. +- `register-upgrade-tags-with-subscribers.md` — how to make a tag known to the platform. diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-code-review.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-code-review.md new file mode 100644 index 000000000..952e33550 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-code-review.md @@ -0,0 +1,315 @@ +--- +kind: action-skill +id: al-code-review +version: 1 +title: AL code review +description: Reviews AL source changes by composing the AL review leaf skills (performance, security, privacy, upgrade, style). +inputs: [pr-diff, file-path] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +sub-skills: + - microsoft/skills/review/al-performance-review.md + - microsoft/skills/review/al-security-review.md + - microsoft/skills/review/al-privacy-review.md + - microsoft/skills/review/al-upgrade-review.md + - microsoft/skills/review/al-style-review.md +--- + +# AL code review + +Reviews AL source changes by composing the leaf AL review skills. This is the canonical reference implementation of a **super-skill** — skill authors writing composed reviews should copy its structure. + +`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`. + +## Source + +The sub-skills invoked by this skill are those listed in frontmatter `sub-skills`: + +- `microsoft/skills/review/al-performance-review.md` +- `microsoft/skills/review/al-security-review.md` +- `microsoft/skills/review/al-privacy-review.md` +- `microsoft/skills/review/al-upgrade-review.md` +- `microsoft/skills/review/al-style-review.md` + +Additional leaf skills (for example, telemetry, testing) are added by updating the `sub-skills` list. The skill does not discover sub-skills implicitly. + +## Relevance + +A sub-skill is relevant when both of the following hold: + +- The orchestrator has supplied inputs that satisfy the sub-skill's declared `inputs`. +- The orchestrator has not disabled the sub-skill via configuration. + +Per the DO contract, the super-skill MUST NOT filter sub-skills by task content. `al-code-review` does not inspect the PR diff to predict whether, for example, there is anything for `al-security-review` to find. Each leaf is responsible for its own task-level applicability decision; leaves signal non-applicability by returning `outcome: "not-applicable"` or `outcome: "no-knowledge"`. + +Sub-skills that fail either check are not invoked and are recorded in `skipped-sub-skills`: + +- `reason: "configuration"` when the orchestrator disabled the sub-skill. +- `reason: "not-applicable"` when the orchestrator's inputs do not satisfy the sub-skill's declared `inputs`. + +## Worklist + +The worklist is the list of sub-skills judged relevant by the previous step. Every sub-skill in the worklist will be invoked in the Action step. + +## Action + +### 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: + +- 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. +- 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. + +### Roll up sub-skill findings + +For each sub-skill in the worklist, executed one at a time per the discipline above: + +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`. +4. Otherwise, append each entry from the sub-skill's `findings[]` to the super-skill's top-level `findings[]`, setting `from-sub-skill` to the sub-skill's `skill.id`. 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. + +### Agent self-review pass + +Each leaf sub-skill emits both knowledge-backed findings and, per its own contract, agent findings within its own domain. After every sub-skill has produced its sub-result, perform a super-skill self-review pass against the same task input. The goal of this pass is to surface defects the agent recognises on its own that **no single leaf could have surfaced** because they cross domain boundaries — architecture-level issues that touch performance and reliability at once, error-handling gaps that span security and UX, resource-lifecycle patterns that affect both correctness and performance, and similar cross-cutting concerns. BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. + +The self-review *reasoning* is mandatory — you MUST actually perform the cross-cutting analysis on every real-size PR, not skip it. But the pass need not produce any output: emitting **zero** agent findings is a valid, expected outcome whenever no candidate clears the precision bar in `skills/do.md` (*Agent findings*). Do not invent or pad findings to prove the pass ran. Always reason; emit only what survives the bar. + +Frame the pass by cross-cutting concerns — architecture, error handling, resource lifecycle — and by the seams between leaf domains. Do not duplicate domain-specific reasoning that belongs in a leaf: a security-only concern is the security leaf's responsibility, not the super-skill's. The super-skill pass adds value where no individual leaf has the right scope. + +For every candidate the agent identifies in this pass: + +1. **Validate against BCQuality knowledge.** Check the candidate against the knowledge files the sub-skills have already loaded for this task (visible via their `references` and `suppressed` lists in `sub-results`). + - If a BCQuality knowledge file matches the candidate, upgrade it to a knowledge-backed finding: cite the file in `references`, set `id` to the file's path, set `from-sub-skill` to the sub-skill that owns that knowledge domain, and merge with or deduplicate against any sub-skill finding that already covers the same concern at the same location. + - If a BCQuality knowledge file **explicitly contradicts** the candidate (its `## Best Practice` or `## Anti Pattern` says the opposite of what the agent flagged), suppress the candidate and do not surface it. + - Otherwise the candidate has no BCQuality coverage; emit it as a super-skill agent finding. +2. **Emit agent finding.** Per DO's *Agent findings* rules: + - `from-sub-skill: "agent"` (the super-skill itself produced it) + - `references: []` + - `id` is a skill-defined slug prefixed with `agent:` (for example, `agent:missing-error-handling-on-http-call`). + - `confidence` capped at `medium`. + - `severity` capped at `minor` — agent findings are advisory and non-gating per `skills/do.md`; never assign `major` or `blocker` to a finding with no knowledge file behind it. + - `message` is non-empty and self-contained, describing both the issue and a concrete recommendation. A consumer rendering the finding has no knowledge-file footer to fall back on. + - `suggested-code` MUST be set when the fix is small, local, and mechanical. If a mechanical-looking finding omits it, set `suggested-code-omission-reason` with the reason (for example, the fix spans non-contiguous code or requires choosing a real production value). + +Leaf-level agent findings (those with `references: []` inside a sub-skill's report) are rolled up into the super-skill's top-level `findings[]` like any other sub-skill finding — they keep their `from-sub-skill: ` attribution and are not rewritten. They are not subject to the "MUST validate against knowledge" step above, because each leaf has already validated within its own domain. + +### Suggested-code guidance + +For both knowledge-backed findings rolled up from sub-skills and agent findings emitted in the self-review pass, populate `findings[].suggested-code` whenever a concrete code replacement is unambiguous from the diff context. This is a MUST for small, local, mechanical fixes. The payload MUST be a literal replacement for the source lines covered by `location` (typically a single line, or the line range in `location.range`) — no diff markers, fences, or commentary. Examples of good candidates: deleting dead code after `exit`, replacing `Count() > 0` with `not IsEmpty()`, moving an inline `Label` declaration to a codeunit-level `var` block, adding a missing property, replacing string-concatenated `Error`, changing an over-broad permission token, fixing whitespace or keyword casing. Skip `suggested-code` only when the fix requires choosing between multiple defensible alternatives, when the fix spans non-contiguous code, or when the surrounding context the agent cannot see could change the answer. If a mechanical-looking finding omits `suggested-code`, set `suggested-code-omission-reason`. + +Sub-skills MAY also emit `suggested-code` when their knowledge file unambiguously implies the replacement (the `.good.al` and `.bad.al` companion examples are useful here). The super-skill copies the field through unchanged. + +### 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). + +`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."* + +## Output + +Output conforms to the DO output contract, extended with `sub-results` and `skipped-sub-skills`. A populated example — both leaves ran, each produced findings: + +```json +{ + "skill": { "id": "al-code-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 1, "major": 1, "minor": 3, "info": 0 }, + "coverage": { "worklist-size": 4, "items-evaluated": 4 } + }, + "findings": [ + { + "id": "microsoft/knowledge/performance/filter-before-find.md", + "severity": "major", + "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 140, + "range": { "start-line": 140, "end-line": 144 } + }, + "references": [ + { "path": "microsoft/knowledge/performance/filter-before-find.md" } + ], + "confidence": "high", + "from-sub-skill": "al-performance-review" + }, + { + "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "severity": "minor", + "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 152 + }, + "references": [ + { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } + ], + "confidence": "high", + "from-sub-skill": "al-performance-review" + }, + { + "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", + "severity": "blocker", + "message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.", + "location": { + "file": "src/Integration/ApiClient.Codeunit.al", + "line": 85, + "range": { "start-line": 85, "end-line": 89 } + }, + "references": [ + { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } + ], + "confidence": "high", + "from-sub-skill": "al-security-review" + }, + { + "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", + "severity": "minor", + "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", + "location": { + "file": "src/Integration/ApiClient.Codeunit.al", + "line": 201 + }, + "references": [ + { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } + ], + "confidence": "medium", + "from-sub-skill": "al-security-review" + }, + { + "id": "agent:missing-error-handling-on-http-client", + "severity": "minor", + "message": "HttpClient.Send is called without inspecting the response status or wrapping the call in a TryFunction. Network or remote-server failures will surface as runtime errors to the user. Recommendation: branch on the HttpResponseMessage.IsSuccessStatusCode and either retry, surface a controlled error, or fall back, depending on the integration's contract.", + "location": { + "file": "src/Integration/ApiClient.Codeunit.al", + "line": 60, + "range": { "start-line": 60, "end-line": 64 } + }, + "references": [], + "confidence": "medium", + "from-sub-skill": "agent" + } + ], + "suppressed": [], + "sub-results": [ + { + "skill": { "id": "al-performance-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 }, + "coverage": { "worklist-size": 2, "items-evaluated": 2 } + }, + "findings": [ + { + "id": "microsoft/knowledge/performance/filter-before-find.md", + "severity": "major", + "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 140, + "range": { "start-line": 140, "end-line": 144 } + }, + "references": [ + { "path": "microsoft/knowledge/performance/filter-before-find.md" } + ], + "confidence": "high" + }, + { + "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "severity": "minor", + "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 152 + }, + "references": [ + { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] + }, + { + "skill": { "id": "al-security-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 1, "major": 0, "minor": 1, "info": 0 }, + "coverage": { "worklist-size": 2, "items-evaluated": 2 } + }, + "findings": [ + { + "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", + "severity": "blocker", + "message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.", + "location": { + "file": "src/Integration/ApiClient.Codeunit.al", + "line": 85, + "range": { "start-line": 85, "end-line": 89 } + }, + "references": [ + { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } + ], + "confidence": "high" + }, + { + "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", + "severity": "minor", + "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", + "location": { + "file": "src/Integration/ApiClient.Codeunit.al", + "line": 201 + }, + "references": [ + { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } + ], + "confidence": "medium" + } + ], + "suppressed": [] + } + ] +} +``` + +The empty-corpus case — BCQuality's state until knowledge files land — rolls up to `no-knowledge`: + +```json +{ + "skill": { "id": "al-code-review", "version": 1 }, + "outcome": "no-knowledge", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 0, "items-evaluated": 0 } + }, + "findings": [], + "suppressed": [], + "sub-results": [ + { + "skill": { "id": "al-performance-review", "version": 1 }, + "outcome": "no-knowledge", + "summary": { "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, "coverage": { "worklist-size": 0, "items-evaluated": 0 } }, + "findings": [], + "suppressed": [] + }, + { + "skill": { "id": "al-security-review", "version": 1 }, + "outcome": "no-knowledge", + "summary": { "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, "coverage": { "worklist-size": 0, "items-evaluated": 0 } }, + "findings": [], + "suppressed": [] + } + ] +} +``` + diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-performance-review.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-performance-review.md new file mode 100644 index 000000000..09bef270d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-performance-review.md @@ -0,0 +1,137 @@ +--- +kind: action-skill +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] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL performance review + +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. + +## 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/**`. + +## Relevance + +Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: + +- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. +- `technologies` — `[al]`. +- `countries` — the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`. +- `application-area` — the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`. + +Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: + +- The changed AL object names and types — especially tables, pages with SourceTable bindings, reports, queries, and codeunits performing record iteration. +- The changed procedures and triggers, weighted toward those that perform loops, Find/FindSet/FindFirst calls, CalcFields, CalcSums, FlowField access, or cross-table navigation. +- Tokens extracted from the diff that relate to data access and hot-path costs (`SetRange`, `SetFilter`, `SetLoadFields`, `SetCurrentKey`, `FindSet`, `ReadIsolation`, `LockTable`, `ModifyAll`, `DeleteAll`, `TextBuilder`, `Dictionary`, `temporary`, `repeat`, `until`, `CalcFields`, `CalcSums`). + +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. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. + +When the post-conflict worklist is empty because no applicable performance knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable performance knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows: + +- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee (for example, documented query timeouts or transaction size limits). When the file does not make such a claim, the ceiling is `major`. +- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. +- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match (identifier, syntax, object type). +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +After evaluating each worklist entry, also consider whether the diff exhibits a performance 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 performance 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 performance; 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: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; 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`. + +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. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item; default when the skill finishes normally, including when the resulting `findings` array is empty. +- `no-knowledge` — no applicable performance knowledge survived Source, Relevance, configuration filtering, and conflict resolution. `findings` is empty. +- `not-applicable` — the task context lacks an AL dimension (no AL changes in the diff, or `technologies` filter rejected the task). +- `partial` — a time or token budget was hit before the worklist was exhausted. `summary.coverage` reflects the evaluated subset; `outcome-reason` explains the cause. +- `failed` — an unrecoverable error occurred. `outcome-reason` is required. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-performance-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 1, "minor": 1, "info": 0 }, + "coverage": { "worklist-size": 2, "items-evaluated": 2 } + }, + "findings": [ + { + "id": "microsoft/knowledge/performance/filter-before-find.md", + "severity": "major", + "message": "FindSet is called on a record variable without any prior SetRange/SetFilter. This forces a full-table scan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 140, + "range": { "start-line": 140, "end-line": 144 } + }, + "references": [ + { "path": "microsoft/knowledge/performance/filter-before-find.md" } + ], + "confidence": "high" + }, + { + "id": "community/knowledge/performance/call-setloadfields-before-filters.md", + "severity": "minor", + "message": "SetLoadFields is called after SetRange. Per the referenced guidance the call must come before filters to be folded into the query plan.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 152 + }, + "references": [ + { "path": "community/knowledge/performance/call-setloadfields-before-filters.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` + +The empty-corpus case — BCQuality's state until performance knowledge files land — produces: + +```json +{ + "skill": { "id": "al-performance-review", "version": 1 }, + "outcome": "no-knowledge", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 0, "items-evaluated": 0 } + }, + "findings": [], + "suppressed": [] +} +``` + diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-privacy-review.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-privacy-review.md new file mode 100644 index 000000000..4ef87e3a2 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-privacy-review.md @@ -0,0 +1,109 @@ +--- +kind: action-skill +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] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL privacy review + +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. + +## 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/**`. + +## Relevance + +Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: + +- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. +- `technologies` — `[al]`. +- `countries` — the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`. +- `application-area` — the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`. + +Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. Exclude test codeunits, test libraries, test helper code, files under test/Test/Tests paths, and objects with `Subtype = Test`; test data is synthetic and does not ship to customers. For each relevant file, compute overlap against: + +- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, and objects reading or writing `IsolatedStorage`. +- The changed procedures and triggers, weighted toward those that call `Error`, `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`, `FeatureTelemetry.LogUsage`/`LogUptake`/`LogError`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`. +- Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `GetLastErrorText`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `HybridSL`, `HybridGP`, `HybridBC`). + +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. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. + +When the post-conflict worklist is empty because no applicable privacy knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable privacy knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows: + +- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee (for example, documented telemetry-classification rules or GDPR-adjacent data-handling requirements). When the file does not make such a claim, the ceiling is `major`. +- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. +- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match (identifier, syntax, object type). +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +After evaluating each worklist entry, also consider whether the diff exhibits a privacy 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 privacy 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 privacy; 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: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; 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`. + +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. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item; default when the skill finishes normally, including when the resulting `findings` array is empty. +- `no-knowledge` — no applicable privacy knowledge survived Source, Relevance, configuration filtering, and conflict resolution. `findings` is empty. +- `not-applicable` — the task context lacks an AL dimension (no AL changes in the diff, or `technologies` filter rejected the task). +- `partial` — a time or token budget was hit before the worklist was exhausted. `summary.coverage` reflects the evaluated subset; `outcome-reason` explains the cause. +- `failed` — an unrecoverable error occurred. `outcome-reason` is required. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-privacy-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 1, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md", + "severity": "major", + "message": "Error receives a pre-built Text produced by StrSubstNo with customer name and email as arguments. Per the referenced guidance the platform cannot classify or strip PII from an opaque Text and will export the full message to telemetry.", + "location": { + "file": "src/Sales/CustomerValidation.Codeunit.al", + "line": 64, + "range": { "start-line": 60, "end-line": 64 } + }, + "references": [ + { "path": "microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` + diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-security-review.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-security-review.md new file mode 100644 index 000000000..1e72447e1 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-security-review.md @@ -0,0 +1,137 @@ +--- +kind: action-skill +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] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL security review + +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. + +## 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/**`. + +## Relevance + +Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: + +- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. +- `technologies` — `[al]`. +- `countries` — the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`. +- `application-area` — the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`. + +Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: + +- The changed AL object names and types — especially permission sets, codeunits handling authentication or authorization, objects touching `Isolated Storage`, `OAuth2` flows, web service endpoints, API pages, event publishers, and RecordRef helpers. +- The changed procedures and triggers, weighted toward those that call `HttpClient`, validate or compose URLs, write to telemetry, read or write secrets, unwrap SecretText, manipulate record-level security, expose var Boolean guard parameters, or bypass the permission model (for example, `RecordRef.Open`, `Record.WritePermission`, direct table access from a non-owning app). +- Tokens extracted from the diff that relate to security concerns (`IsolatedStorage`, `SetEncrypted`, `OAuth2`, `SecretText`, `Unwrap`, `NonDebuggable`, `Password`, `Token`, `HttpClient`, `Uri`, `AreURIsHaveSameHost`, `IsValidURIPattern`, `RecordRef`, `RecordId`, `Open`, `IntegrationEvent`, `SkipValidation`, `HasAccess`, `Permission`, `UserSecurityId`, `Commit`). + +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. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`. + +When the post-conflict worklist is empty because no applicable security knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable security knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows: + +- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` only when the knowledge file states the anti-pattern violates a platform-level guarantee (for example, documented secret-handling rules, permission-model invariants, or data-protection requirements). When the file does not make such a claim, the ceiling is `major`. +- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. +- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. Repository-wide observations MAY omit `location`. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match (identifier, syntax, object type). +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +After evaluating each worklist entry, also consider whether the diff exhibits a security 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 security 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 security; 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: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; 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`. + +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. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item; default when the skill finishes normally, including when the resulting `findings` array is empty. +- `no-knowledge` — no applicable security knowledge survived Source, Relevance, configuration filtering, and conflict resolution. `findings` is empty. +- `not-applicable` — the task context lacks an AL dimension (no AL changes in the diff, or `technologies` filter rejected the task). +- `partial` — a time or token budget was hit before the worklist was exhausted. `summary.coverage` reflects the evaluated subset; `outcome-reason` explains the cause. +- `failed` — an unrecoverable error occurred. `outcome-reason` is required. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-security-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 1, "major": 0, "minor": 1, "info": 0 }, + "coverage": { "worklist-size": 2, "items-evaluated": 2 } + }, + "findings": [ + { + "id": "microsoft/knowledge/security/use-secrettext-for-credentials.md", + "severity": "blocker", + "message": "A bearer token is declared as a Text parameter and passed through the HTTP request path as plain text. The referenced guidance requires credentials to flow as SecretText end-to-end.", + "location": { + "file": "src/Integration/ApiClient.Codeunit.al", + "line": 85, + "range": { "start-line": 85, "end-line": 89 } + }, + "references": [ + { "path": "microsoft/knowledge/security/use-secrettext-for-credentials.md" } + ], + "confidence": "high" + }, + { + "id": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md", + "severity": "minor", + "message": "An API key is assigned from a string literal rather than retrieved from IsolatedStorage or Key Vault at runtime.", + "location": { + "file": "src/Integration/ApiClient.Codeunit.al", + "line": 201 + }, + "references": [ + { "path": "microsoft/knowledge/security/never-hardcode-secrets-in-al.md" } + ], + "confidence": "medium" + } + ], + "suppressed": [] +} +``` + +The empty-corpus case — BCQuality's state until security knowledge files land — produces: + +```json +{ + "skill": { "id": "al-security-review", "version": 1 }, + "outcome": "no-knowledge", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 0, "items-evaluated": 0 } + }, + "findings": [], + "suppressed": [] +} +``` + diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-style-review.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-style-review.md new file mode 100644 index 000000000..d926df3bb --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-style-review.md @@ -0,0 +1,106 @@ +--- +kind: action-skill +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] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL style review + +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. + +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. + +## 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/**`. + +## Relevance + +Apply the frontmatter matching rules defined in READ against the task context: + +- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. +- `technologies` — `[al]`. +- `countries` — the countries declared in the consuming app's `app.json`. If absent, `unknown`. +- `application-area` — pass the actual set declared by the changed objects; do not substitute `[all]`. + +Discard files that are not applicable. Retain conditionally applicable files only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium` and MUST name the unknown dimensions in `message`. + +## Worklist + +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`). + +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. + +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. + +## Action + +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`. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match. +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +After evaluating each worklist entry, also consider whether the diff exhibits a 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; 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: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; 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`. + +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. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable style knowledge survived filtering. +- `not-applicable` — no AL changes in the diff. +- `partial` — a budget was hit before the worklist was exhausted. +- `failed` — an unrecoverable error occurred. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-style-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 1, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/style/apply-approved-label-suffixes.md", + "severity": "minor", + "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.", + "location": { + "file": "src/Sales/PostingRoutines.Codeunit.al", + "line": 42 + }, + "references": [ + { "path": "microsoft/knowledge/style/apply-approved-label-suffixes.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` + diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-upgrade-review.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-upgrade-review.md new file mode 100644 index 000000000..7ccfa4a8d --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/microsoft/skills/review/al-upgrade-review.md @@ -0,0 +1,108 @@ +--- +kind: action-skill +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] +outputs: [findings-report] +bc-version: [all] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL upgrade review + +Reviews AL source changes against the `upgrade` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. + +An orchestrator invokes this skill with 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. + +## 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/**`. + +## Relevance + +Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context: + +- `bc-version` — the target BC version from the PR branch's `app.json` or the orchestrator-supplied version. If unavailable, the dimension is `unknown`. +- `technologies` — `[al]`. +- `countries` — the countries declared in the consuming app's `app.json`. Default to the orchestrator's configured context; if absent, `unknown`. +- `application-area` — the union of application areas declared by the changed objects. Pass the actual set; do not substitute `[all]`. If the area cannot be determined from the changes, the dimension is `unknown`. + +Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the finding's `message` MUST name the dimension or dimensions that were unknown. + +## Worklist + +Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: + +- The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces. +- The changed triggers and procedures, weighted toward `OnUpgradePerCompany`, `OnUpgradePerDatabase`, `OnValidateUpgradePerCompany`, `OnValidateUpgradePerDatabase`, `OnInstallAppPerCompany`, and the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers. +- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnValidateUpgrade`, `DataTransfer`, `CopyFields`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`). + +A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. + +Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files suppressed by configuration are recorded with `reason: "configuration"`. + +When the post-conflict worklist is empty because no applicable upgrade knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable upgrade knowledge matched the changes, emit `outcome: "completed"` with an empty `findings` array. + +## Action + +For each worklist entry, evaluate the diff against the file's `## Best Practice` and `## Anti Pattern` sections. Emit findings as follows: + +- When the diff contains a clear match for an Anti Pattern, emit a finding with severity `major` or `blocker`, a message summarizing the anti-pattern, `location` pointing to the offending line or range, and a `references` entry pointing to the knowledge file. Use `blocker` for irreversible data corruption (enum-ordinal shift, unguarded reads that abort the upgrade) and for changes that would ship to customers without a migration path (new InitValue on an existing table without upgrade code). +- When the diff contains code that contradicts a Best Practice without being a full anti-pattern, emit `minor` with the same reference shape. +- When the skill cannot detect a violation but the file is clearly applicable to the change, emit `info` citing the file. + +Set `confidence` to: + +- `high` when the detection is based on an unambiguous pattern match. +- `medium` when detection relies on heuristics or when any frontmatter dimension was `unknown`. +- `low` when the finding is an advisory derived only from applicability. + +After evaluating each worklist entry, also consider whether the diff exhibits a upgrade 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 upgrade or breaking-change 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 upgrade; 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: delete unreachable lines; replace `Count() > 0` with `not IsEmpty()`; move a local `Label` to object scope; 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`. + +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. + +Outcome selection: + +- `completed` — the skill evaluated every worklist item. +- `no-knowledge` — no applicable upgrade knowledge survived filtering. +- `not-applicable` — the diff touches no upgrade, install, schema, or enum surface. +- `partial` — a budget was hit before the worklist was exhausted. +- `failed` — an unrecoverable error occurred. + +## Output + +Output conforms to the DO output contract. A populated example: + +```json +{ + "skill": { "id": "al-upgrade-review", "version": 1 }, + "outcome": "completed", + "summary": { + "counts": { "blocker": 1, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 1, "items-evaluated": 1 } + }, + "findings": [ + { + "id": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md", + "severity": "blocker", + "message": "A new enum value was inserted at ordinal 1, shifting every subsequent value by one. Rows that store the old ordinal 1 will silently resolve to the new value. Per the referenced guidance, enum values must be appended at the end.", + "location": { + "file": "src/Shared/OrderStatus.Enum.al", + "line": 7 + }, + "references": [ + { "path": "microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md" } + ], + "confidence": "high" + } + ], + "suppressed": [] +} +``` + diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/skills/do.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/skills/do.md new file mode 100644 index 000000000..317750d9a --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/skills/do.md @@ -0,0 +1,286 @@ +--- +kind: meta-skill +id: do +version: 1 +title: Action Skill — the template every action skill follows +--- + +# DO + +An action skill is a markdown file that tells an agent how to do one concrete job — review a pull request, audit telemetry usage, generate a skeleton — using knowledge files from BCQuality. This document is the template every action skill follows. Orchestrators rely on the template to consume any skill without skill-specific parsing. + +This contract is stable. Changes require a PR approved by both maintainers. + +## What an action skill is + +An action skill is a single markdown file with YAML frontmatter. It lives inside a layer: + +- `/microsoft/skills/` — platform-endorsed action skills. +- `/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. + +## Frontmatter schema + +```yaml +--- +kind: action-skill +id: al-code-review +version: 1 +title: AL code review +description: Reviews AL source changes against performance and security guidance. +inputs: [pr-diff, object-list] +outputs: [findings-report] +bc-version: [26..28] +technologies: [al] +countries: [w1] +application-area: [all] +--- +``` + +`kind`, `id`, `version`, `title`, `description`, `inputs`, `outputs` are required and specific to action skills. + +`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. + +`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. + +## Required sections + +Every action skill MUST contain these five sections, in order: + +- `## Source` — declares which folders and tags to search for knowledge. +- `## Relevance` — declares how to filter the candidates. +- `## Worklist` — declares how to narrow filtered candidates to the set that applies to this task. +- `## Action` — declares what the skill does with the narrowed set. +- `## Output` — declares the shape of the produced output; typically a reference to the contract below. + +## The four-step pattern + +**Source.** List the folders and tag filters to collect candidates from. Sources span layers: an action skill sources from the same `domain` subfolder across every enabled layer. Example: *"Source from `/*/knowledge/performance/` and `/*/knowledge/security/`."* + +**Relevance.** Apply frontmatter filters to the candidates. Typical filters: match `bc-version` against the target environment, match `technologies` against the languages in scope, match `countries` and `application-area` against the consuming codebase's context. The exact matching rules are defined in READ (*Frontmatter matching semantics*). Files that do not match are discarded. + +**Worklist.** Narrow the relevant candidates to the subset that applies to the current task. This is where the task-specific signal enters: the objects changed in the PR, the queries being audited, the skeleton being generated. Typical moves: match `keywords` against task vocabulary, match file topics against changed objects, deduplicate by concern. + +**Action.** Execute the skill's work against the worklist. Evaluate each item in the worklist against the task input and emit findings. The action step is where skill behavior differs; the preceding three steps are uniform. + +## Output contract + +Every action skill emits a single JSON document that conforms to this schema: + +```json +{ + "skill": { "id": "string", "version": 1 }, + "outcome": "completed | not-applicable | no-knowledge | partial | failed", + "outcome-reason": "string", + "summary": { + "counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 }, + "coverage": { "worklist-size": 0, "items-evaluated": 0 } + }, + "findings": [ + { + "id": "string", + "severity": "blocker | major | minor | info", + "message": "string", + "location": { + "file": "string", + "line": 0, + "range": { "start-line": 0, "end-line": 0 } + }, + "references": [ + { "path": "string", "sha": "string" } + ], + "confidence": "high | medium | low", + "from-sub-skill": "string", + "suggested-code": "string", + "suggested-code-omission-reason": "string" + } + ], + "suppressed": [ + { + "reference": { "path": "string", "sha": "string" }, + "reason": "layer-precedence | configuration" + } + ], + "sub-results": [ + { "...full nested findings-report..." : null } + ], + "skipped-sub-skills": [ + { + "skill": { "id": "string", "version": 1 }, + "reason": "configuration | not-applicable" + } + ] +} +``` + +### Field semantics + +**`outcome`** (required) — + +- `completed` — the skill ran end-to-end; `findings` reflects the full result (including the empty set). +- `not-applicable` — the skill's frontmatter filters did not match the task context; the skill declined to run. +- `no-knowledge` — the skill ran but found no applicable knowledge files; `findings` MUST be empty. +- `partial` — the skill evaluated part of its worklist but did not finish. `summary.coverage` reflects the evaluated subset. Set `outcome-reason` to explain. +- `failed` — the skill encountered an error and produced no reliable findings. Set `outcome-reason`. Consumers SHOULD ignore `findings` on a failed outcome. + +`outcome-reason` is optional for `completed`, `not-applicable`, and `no-knowledge`; required for `partial` and `failed`. + +An empty `findings` array with `outcome: completed` means the skill ran and found nothing to flag. Orchestrators MUST NOT conflate this with `not-applicable` or `no-knowledge`. + +**`findings[].id`** — a stable identifier for the rule or concern that produced the finding. For citation-based findings (any finding with a non-empty `references`), `id` MUST equal `references[0].path` — the primary knowledge file's repo-relative path. For skills that detect concerns without a direct citation, `id` is a skill-defined slug (kebab-case, stable across versions of the skill). The same `id` produced in two runs MUST refer to the same concern; consumers MAY deduplicate findings by `id`. + +When a super-skill rolls up a non-citation finding from a sub-skill (an `id` that is a slug, not a path), the super-skill MUST prefix the `id` with `:` to avoid collisions across sub-skills (for example, a slug `missing-test` from `al-security-review` becomes `al-security-review:missing-test`). Citation-based findings are already globally unique through their repo-relative path and MUST NOT be rewritten. + +**Agent findings.** A skill MAY emit findings that the agent identified through its own reasoning rather than from a BCQuality knowledge file. BCQuality is an **additive** knowledge layer: it augments the agent's pre-existing review judgement, it does not replace it. An agent finding is encoded by: + +- `references: []` — required. An agent finding has no knowledge-file citation by definition; if a citation existed, the finding would be a knowledge-backed finding instead. +- `id` — a skill-defined slug, prefixed with `agent:` (mirroring the `:` rule). For example, `agent:obsolete-find-signature`. +- `confidence` — capped at `medium`. Without a knowledge-file citation there is no authoritative basis for `high` confidence. +- `severity` — capped at `minor`. Agent findings are advisory and non-gating: without a curated rule behind them they MUST NOT carry `major` or `blocker` weight, which the severity taxonomy reserves for gating defects. A genuinely severe issue the agent is confident about almost always matches an existing knowledge file (upgrading it to a knowledge-backed finding) or warrants authoring a new one — not a high-severity agent finding. When the underlying impact would otherwise be `major` or `blocker`, keep the emitted `severity` at `minor` but say so plainly in the `message`, and flag that the concern should be promoted to a knowledge-backed rule before it can gate. +- `message` — non-empty and self-contained. It MUST describe the issue and a concrete recommendation, since a consumer rendering the finding has no knowledge-file footer to fall back on. +- `from-sub-skill` — set by super-skills only. The literal string `"agent"` when the super-skill itself produced the finding from its own cross-cutting reasoning; or the producing leaf's `skill.id` when the super-skill is rolling up a leaf's agent finding. Absent on findings emitted directly by a leaf (the leaf's own report carries the finding under its `skill.id` already). + +**Precision bar — emit agent findings conservatively.** Agent findings are the lowest-precision output BCQuality produces: there is no curated rule behind them, so a false positive costs reviewer trust with nothing to point back to. Hold them to a deliberately high bar: + +- Emit only a **concrete, demonstrable defect with material impact** that a knowledgeable BC reviewer would agree is wrong — not merely different, suboptimal in theory, or not-how-I-would-write-it. +- **Steelman before emitting.** State the strongest case that the code is correct as written: a deliberate choice, a valid alternative, or behaviour that depends on code outside the diff. If that case is plausible, do not emit. +- **Never emit** as agent findings: stylistic or formatting preferences (outside a dedicated style skill's own domain); speculative or hypothetical concerns — anything you would phrase with "could", "might", or "consider"; issues that depend on code not visible in the diff; valid alternative approaches; or generic software-engineering advice a competent model already applies without prompting (the same exclusion the knowledge-file admission test enforces). +- **When in doubt, omit.** Recall is the knowledge files' responsibility; the agent channel exists only for the high-confidence, concrete defect the corpus has not captured yet. A missed low-severity observation is cheaper than a false positive. + +Agent findings may be emitted by both leaf sub-skills and super-skills, with different scope boundaries: + +- A **leaf sub-skill** MAY emit agent findings strictly within its declared `domain`. al-security-review MAY surface an agent security finding that no knowledge file covers (for example, a `case` over a security-relevant enum with no `else` arm), but MUST NOT emit a style or performance agent finding — those are out of scope for the leaf and belong to other leaves or to the super-skill. The leaf's domain is the bounding box. +- A **super-skill** MAY emit agent findings of any kind, but its self-review pass is most useful for **cross-cutting** concerns that span multiple leaf domains (architecture-level smells, error-handling gaps that touch security and reliability, resource lifecycle issues). Domain-specific agent reasoning is the leaves' job; the super-skill should not duplicate it. + +Before emitting an agent finding, a skill MUST validate the candidate against the BCQuality knowledge it has already loaded for the task — if a knowledge file matches, the candidate is upgraded to a knowledge-backed finding (and merged or deduplicated against any existing finding that already covers the same concern); if a knowledge file explicitly contradicts the candidate, it is suppressed. For a super-skill rolling up leaf reports, this validation is done against the union of knowledge files all leaves loaded, not just one leaf's set. + +Consumers that render output MAY treat agent findings differently from knowledge-backed findings (for example, by labelling them and routing them to a separate review domain). The `references: []` marker, together with the `agent:` `id` prefix, is the contract they rely on; `from-sub-skill: "agent"` is the additional marker for super-skill-emitted agent findings. + +**`findings[].severity`** — see the taxonomy below. + +**`findings[].message`** — human-readable explanation of the finding. Single short paragraph. No markdown formatting assumptions. + +**`findings[].location`** — optional. When present: + +- `file` MUST be a repo-relative path using forward slashes. +- `line` is the primary line number, 1-based. +- `range` is optional and describes a contiguous line span; `start-line` and `end-line` are 1-based and inclusive. `start-line` MUST equal `line` when both are present. + +Findings without a `location` are permitted (for example, repository-wide observations). + +**`findings[].references`** — array of knowledge-file references. Each reference is an object: + +- `path` (required) — repo-relative path to the knowledge file, forward slashes. +- `sha` (optional) — commit SHA the skill read when producing the finding. Consumers SHOULD include `sha` when the skill was invoked with a specific repo state. + +The first reference is the **primary** reference: the knowledge file the finding most directly cites. Additional references provide supporting context and are not ranked. `references` MAY be empty only for **agent findings** (see the `findings[].id` section above for the full encoding); any other finding MUST have at least one reference. + +**`findings[].confidence`** — the skill's confidence that the finding is a true positive, given the evidence it evaluated. Not applicability confidence, not severity confidence. Values: `high`, `medium`, `low`. + +**`findings[].from-sub-skill`** — optional. Set only by super-skills. The `skill.id` of the sub-skill that produced the finding, or the literal string `"agent"` for an agent finding the super-skill produced from its own cross-cutting reasoning. Absent on findings emitted directly by a leaf skill — including agent findings the leaf emits within its own domain, which appear in the leaf's own report without this field. + +**`findings[].suggested-code`** — optional in the schema but **expected for mechanical findings**. It is a concrete code-replacement payload for the lines indicated by `location`. When present, the string MUST be a literal replacement for the source lines covered by `location.line` (or `location.range` if set) — i.e., what the file would contain after the fix, with no surrounding diff markers, fences, or commentary. Consumers MAY render it as a one-click suggestion in the delivery surface (for example, a GitHub ```` ```suggestion ```` block). + +Emit `suggested-code` whenever the fix is small, local, and mechanical: deleting unreachable code; replacing one expression (`Count() > 0` → `not IsEmpty()`); moving a local `Label` to object scope; adding a missing property such as `ToolTip`, `OptionCaption`, or `DataClassification`; replacing a string-concatenated `Error` with a Label-backed call; changing a permission token; or adding a missing `else`/guard branch whose replacement is unambiguous from the surrounding diff. When a `.good.al` companion exists and the diff context matches the `.bad.al` shape, prefer adapting 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 `suggested-code` is omitted, set `findings[].suggested-code-omission-reason` to a short explanation (for example, `requires choosing a real event id` or `fix spans multiple non-contiguous locations`). The `suggested-code` payload supplements `message`; it does not replace the explanation in `message`. + +**`findings[].suggested-code-omission-reason`** — optional. Required when a finding is mechanical-looking but `suggested-code` is omitted. Short, human-readable reason explaining why no safe one-click replacement was emitted. Consumers MAY use this for telemetry or diagnostics; they do not have to render it in review comments. + +**`suppressed`** — MUST list every knowledge file that was discarded due to layer precedence or consumer configuration, whenever that file would otherwise have contributed to the worklist. Each entry contains: + +- `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`. + +**`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. + +Severity taxonomy: + +- `blocker` — violates platform-level guarantees; the work cannot proceed as-is. +- `major` — significant defect; should be fixed before merge. +- `minor` — quality concern; worth flagging but not a gate. +- `info` — observation or context; not actionable on its own. + +## Composition (super-skills) + +A **super-skill** is an action skill whose frontmatter declares a non-empty `sub-skills: [...]`. A super-skill does not evaluate knowledge files directly; it invokes other action skills and composes their output. + +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. + +### Section interpretation for super-skills + +The five required sections still apply. Their meaning shifts from knowledge files to sub-skills: + +- `## Source` — names the sub-skills invoked (mirrors `sub-skills` in frontmatter). +- `## Relevance` — rules for deciding which sub-skills apply to the current task. A sub-skill is relevant when its declared `inputs` are satisfied by the orchestrator's provided inputs and the orchestrator has not disabled it via configuration. The super-skill MUST NOT filter sub-skills by task content (for example, by inspecting the diff or the file). Task-level applicability is the sub-skill's own responsibility; sub-skills signal non-applicability by returning `outcome: "not-applicable"` or `outcome: "no-knowledge"`. +- `## Worklist` — the final list of sub-skills to invoke; the rest go to `skipped-sub-skills`. +- `## Action` — invoke each worklisted sub-skill with the appropriate subset of inputs, collect its findings-report verbatim into `sub-results`, and copy its `findings[]` into the super-skill's top-level `findings[]` with `from-sub-skill` set. Findings from a sub-skill with `outcome: "failed"` MUST NOT be copied into the super-skill's top-level `findings[]` and MUST NOT contribute to the super-skill's `summary.counts` (their report is still preserved in `sub-results` for traceability, consistent with DO's rule that consumers ignore a failed skill's findings). +- `## Output` — the super-skill's output contract, including `sub-results` and, if any, `skipped-sub-skills`. + +### Outcome rollup + +A super-skill's `outcome` is derived from its sub-skills' outcomes. Let S be the multiset of sub-skill outcomes for sub-skills in the worklist (skipped sub-skills do not contribute): + +- `failed` — every element of S is `failed`. +- `partial` — S contains at least one `partial`, OR S contains at least one `failed` alongside at least one non-`failed` outcome. +- `not-applicable` — every element of S is `not-applicable`. +- `no-knowledge` — every element of S is `no-knowledge` or `not-applicable`, and at least one is `no-knowledge`. +- `completed` — otherwise (every element of S is `completed`, `no-knowledge`, or `not-applicable`, with at least one `completed`). + +When the worklist is empty (every sub-skill was skipped), `outcome` is `not-applicable`; `outcome-reason` SHOULD describe the skip reasons, for example *"all sub-skills disabled by configuration"* or *"no sub-skill accepted the supplied inputs"*. + +`outcome-reason` is required for `partial` and `failed` and SHOULD summarize per-sub-skill state. + +### 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. + +### Suppression scope + +A super-skill's top-level `suppressed[]` remains knowledge-file-only and is typically empty. Knowledge-file suppression is reported by the leaf sub-skill inside its own entry in `sub-results`. Sub-skills the super-skill chose not to invoke belong in `skipped-sub-skills`, never in `suppressed`. + +## Worked example + +A minimal action skill that cites applicable guidance for a changed AL file, without generating findings of its own: + +```yaml +--- +kind: action-skill +id: cite-applicable-guidance +version: 1 +title: Cite applicable guidance +description: Lists knowledge files relevant to a changed AL file. +inputs: [file-path] +outputs: [findings-report] +technologies: [al] +--- +``` + +```markdown +## Source +All files under `/*/knowledge/` across enabled layers. + +## Relevance +Filter by `technologies: [al]` and `bc-version` matching the target environment. + +## Worklist +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. + +## Output +Conforms to the DO output contract. +``` + +## How orchestrators consume output + +An orchestrator invokes an action skill with an input appropriate to the skill's declared `inputs`, receives the JSON output, and maps findings to its delivery surface (PR comments, build gates, IDE diagnostics). The orchestrator MUST NOT interpret skill-specific fields beyond the schema above. Skills that need richer semantics MUST encode them within the schema (for example, by adding structured `message` text) rather than extending the output shape. + + diff --git a/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/skills/read.md b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/skills/read.md new file mode 100644 index 000000000..dbeaa63e2 --- /dev/null +++ b/src/bcbench/agent/shared/instructions/microsoft-BCApps/skills/al-code-review/skills/read.md @@ -0,0 +1,148 @@ +--- +kind: meta-skill +id: read +version: 1 +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. + +This contract is stable. Changes require a PR approved by both maintainers. + +## What a knowledge file is + +A knowledge file is a single markdown file that covers **one concern** in Business Central development. It has: + +- A YAML frontmatter block with the fields below. All fields are required. +- A `## Description` section. Required. +- Optional sections — typically `## Best Practice` and `## Anti Pattern`, but any `##` section is permitted. +- No fenced code blocks. Sample code lives in **sibling files** next to the article (see [Sample files](#sample-files) below). + +A file that violates any of these rules is invalid and MUST be skipped by consumers. Do not attempt to partially parse invalid files. + +## Frontmatter schema (v1) + +```yaml +--- +bc-version: [all] # or [26, 27, 28] or the range shorthand [26..28] +domain: performance +keywords: [query, filtering, partial] +technologies: [al] +countries: [w1] +application-area: [all] +--- +``` + +All six fields are required. Missing or empty fields invalidate the file. + +### Fields + +**`bc-version`** — Array. The Business Central major versions this file applies to. Three forms are accepted: + +- Universal sentinel: `[all]` means the guidance applies to every BC version and matches any target. +- Explicit list: `[26, 27, 28]`. +- Range shorthand: `[26..28]` means every integer from 26 through 28 inclusive. + +`[all]` is mutually exclusive with explicit versions; do not combine. Consumers MUST expand ranges to the full set before comparison. + +**`domain`** — String. A single domain tag that places the file within a broader area of concern. Standard values include `performance`, `security`, `ux`, `telemetry`, `testing`, `api`, `pipelines`, `finance`, `supply-chain`, `manufacturing`, `jobs`. New domains may be introduced by contributors; no closed enumeration is enforced at the schema level. Consumers MUST treat unknown domains as valid. + +**`keywords`** — Array of strings. Free-text tags used for retrieval. Between 3 and 10 tags is typical. Tags are lowercase, kebab-case, and describe the concern in the vocabulary an engineer or agent would search for. + +**`technologies`** — Array of strings. The technologies the file applies to. Examples: `al`, `javascript`, `powershell`, `kql`, `azure-devops`, `github-actions`. A file that applies across technologies lists all of them explicitly. The sentinel `all` is not permitted for this field. + +**`countries`** — Array of strings. ISO 3166-1 alpha-2 country codes (lowercase: `us`, `de`, `dk`) for localization-specific guidance. Use the sentinel `[w1]` for guidance that applies worldwide. `[w1]` is mutually exclusive with country codes; do not combine. + +**`application-area`** — Array of strings. The BC application areas the file applies to. Examples: `finance`, `manufacturing`, `jobs`, `warehousing`, `service`. Use the sentinel `[all]` for guidance that applies regardless of application area. `[all]` is mutually exclusive with specific areas. + +## Sections + +**`## Description`** is required. It states the concern: what the topic is and why it matters. It is the primary retrieval target when a consumer decides whether a file is relevant. + +Two further sections are recognized as **normative** — consumers MAY rely on their content for conflict detection and guidance extraction: + +- **`## Best Practice`** — the recommended approach. +- **`## Anti Pattern`** — what to avoid and the reasoning. + +Any other `##` section is permitted and is **non-normative**: consumers MUST NOT treat its contents as binding guidance. Non-normative sections (for example `## See also` or `## Applies to`) are for human context; they are ignored by conflict detection and by the filtering rules below. Consumers MUST NOT fail on unknown sections. + +## Layer precedence + +A knowledge file lives in one of three layers, determined by its path: + +- `/microsoft/knowledge/**` — platform-endorsed. +- `/community/knowledge/**` — community-curated. +- `/custom/knowledge/**` — partner or customer overrides (typically in a consumer repo, not in BCQuality). + +The default consumption model is **additive**: an action skill sees files from every enabled layer and may surface findings from all of them. A consumer MAY be configured to disable a layer; in that case, files in the disabled layer are invisible to the consumer. + +When two files give **directly contradictory normative guidance**, the conflict is resolved by layer precedence: + +1. `/custom/` wins over `/community/` and `/microsoft/`. +2. `/community/` wins over `/microsoft/`. + +A conflict exists when both of the following are true: + +- **Applicability overlaps.** The files' frontmatter filters (`bc-version`, `technologies`, `countries`, `application-area`) have a non-empty intersection under the matching rules below. `domain` is a retrieval aid; it is not part of the applicability test. +- **Normative guidance contradicts.** Content in the `## Best Practice` or `## Anti Pattern` sections is logically incompatible (one recommends what the other forbids, or vice versa). Non-normative sections are not considered. + +Conflict detection is the consumer's responsibility; BCQuality does not enforce conflict-free content. When a consumer suppresses a losing file due to precedence or configuration, it **MUST** record the suppression in its output (see DO) so reviewers can see what was overridden. + +## Frontmatter matching semantics + +When a consumer filters or matches files against a task context, these rules apply: + +- **`bc-version`** — the file matches if its set is `[all]`, or if the target BC version is an element of the file's expanded `bc-version` set. Range shorthand (`[26..28]`) MUST be expanded before comparison. +- **`technologies`** — non-empty intersection between the task's technologies and the file's technologies. There is no sentinel for this field. +- **`countries`** — the file matches if its set contains `w1`, or if there is a non-empty intersection with the task's countries. +- **`application-area`** — the file matches if its set contains `all`, or if there is a non-empty intersection with the task's application areas. + +A file is **applicable** to a task when all four rules match. Applicability is also the basis for conflict detection above. + +### When the task context is partial + +A task context may omit one or more dimensions (for example, a skill invoked against a raw file path with no known target BC version). For any omitted dimension: + +- If the file's value for that dimension is a universal sentinel (`all` for bc-version, `w1` for countries, `all` for application-area), the rule matches. +- Otherwise the rule is treated as **unknown**, not as a match and not as a failure. + +A file with any `unknown` rule is **conditionally applicable**. A consumer MAY include conditionally applicable files in the worklist; if it does, every finding derived from such a file MUST have `confidence` no higher than `medium` and MUST record the unknown dimensions in the finding's `message`. A consumer MAY be configured to exclude conditionally applicable files entirely. + +Consumers MUST NOT silently treat missing context as a match. + +## Citing a knowledge file + +A consumer that produces output referencing a knowledge file MUST cite it by its repo-relative path (for example, `microsoft/knowledge/performance/filter-before-find.md`). Line numbers are not stable references; use the file path only. If a commit SHA is available to the consumer, it SHOULD be included alongside the path. + +## Sample files + +Knowledge files MUST NOT contain fenced code blocks. When an article needs to show code, it ships the code as one or more **sibling files** next to the article, using this naming convention: + +``` +/knowledge//.md # the article +/knowledge//.good.al # best-practice demonstration +/knowledge//.bad.al # anti-pattern demonstration +``` + +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` ``. +- 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. + +Consumers that surface sample code to an end user or agent SHOULD cite the sample file by its repo-relative path, in the same format as article citations. + +## Retrieval workflow + +The standard workflow for finding applicable files: + +1. Collect candidates from the knowledge index (`knowledge-index.json`). BCQuality maintains it: Entry's preparation step (see [entry.md](entry.md)) regenerates it over the live, already-filtered clone, so it lists exactly the articles that survived the consumer's layer/allow-deny pruning, each with the frontmatter, `keywords`, `title`, and one-line `description` that steps 2-3 need — candidates are enumerated without opening each file. The index is **discovery metadata only**: it tells you *which* files to open, it does not substitute for them. A finding MUST cite only an article that was opened and read in full; an index row whose file is absent from the clone MUST be discarded *before* ranking or worklisting, and its metadata MUST NOT seed a finding. Absent an index, collect candidates by path (typically by `domain` subfolder, across enabled layers). +2. Filter by frontmatter using the matching rules above. Files that are not applicable are discarded. +3. Rank or narrow by `keywords` relevance to the task. +4. Resolve conflicts via layer precedence. + +Steps 1–3 are deterministic; step 4 is applied only when conflicts are detected. diff --git a/src/bcbench/commands/dataset.py b/src/bcbench/commands/dataset.py index cd34be0ee..0990027f7 100644 --- a/src/bcbench/commands/dataset.py +++ b/src/bcbench/commands/dataset.py @@ -6,7 +6,7 @@ from typing_extensions import Annotated from bcbench.cli_options import EvaluationCategoryOption -from bcbench.dataset import BaseDatasetEntry +from bcbench.dataset import BaseDatasetEntry, CodeReviewEntry from bcbench.dataset.dataset_entry import _BugFixTestGenBase from bcbench.github_actions import write_step_outputs from bcbench.logger import get_logger @@ -134,6 +134,23 @@ def view_entry( else: console.print("[dim]No PASS_TO_PASS tests[/dim]") + elif isinstance(entry, CodeReviewEntry): + console.print("\n[bold cyan]Expected Review Comments:[/bold cyan]") + if entry.expected_comments: + comment_table = Table() + comment_table.add_column("File", style="magenta") + comment_table.add_column("Lines", style="yellow") + comment_table.add_column("Severity", style="red") + comment_table.add_column("Comment", style="white") + for comment in entry.expected_comments: + lines = str(comment.line_start) + if comment.line_end and comment.line_end != comment.line_start: + lines += f"-{comment.line_end}" + comment_table.add_row(comment.file, lines, comment.severity, comment.body) + console.print(comment_table) + else: + console.print("[dim]No expected comments[/dim]") + def _modified_instance_ids_from_diff(diff_output: str) -> list[str]: instance_ids = [] diff --git a/src/bcbench/commands/evaluate.py b/src/bcbench/commands/evaluate.py index 2bf912277..3f79d0ab9 100644 --- a/src/bcbench/commands/evaluate.py +++ b/src/bcbench/commands/evaluate.py @@ -22,8 +22,8 @@ from bcbench.dataset import BaseDatasetEntry from bcbench.evaluate import EvaluationPipeline from bcbench.logger import get_logger -from bcbench.results import BaseEvaluationResult, ExecutionBasedEvaluationResult -from bcbench.types import AgentMetrics, ContainerConfig, EvaluationContext, ExperimentConfiguration +from bcbench.results import BaseEvaluationResult, CodeReviewResult, ExecutionBasedEvaluationResult +from bcbench.types import AgentMetrics, ContainerConfig, EvaluationCategory, EvaluationContext, ExperimentConfiguration logger = get_logger(__name__) _config = get_config() @@ -226,8 +226,15 @@ def evaluate(self, context: EvaluationContext[BaseDatasetEntry]) -> None: """Create random evaluation result to test different outcome scenarios.""" logger.info("Mock pipeline: Generating random evaluation result") - # Randomly choose success or build failure - scenario = random.choice(["success", "build-fail"]) + match context.category: + case EvaluationCategory.BUG_FIX | EvaluationCategory.TEST_GENERATION: + scenarios = ["success", "build-fail"] + case EvaluationCategory.CODE_REVIEW: + scenarios = ["invalid", "valid"] + case _: + raise ValueError(f"Unsupported category for mock evaluation: {context.category}") + + scenario = random.choice(scenarios) logger.info(f"Mock pipeline: Selected scenario: {scenario}") result: BaseEvaluationResult @@ -236,6 +243,10 @@ def evaluate(self, context: EvaluationContext[BaseDatasetEntry]) -> None: result = ExecutionBasedEvaluationResult.create_success(context, "MOCK_PATCH_CONTENT") case "build-fail": result = ExecutionBasedEvaluationResult.create_build_failure(context, "MOCK_PATCH_CONTENT", "Mock build failure") + case "invalid": + result = CodeReviewResult.create_invalid(context, output="MOCK_REVIEW_OUTPUT", expected_comments=[]) + case "valid": + result = CodeReviewResult.create(context, "MOCK_INVALID_REVIEW_OUTPUT", [], [], 0) case _: raise ValueError("Invalid mock scenario, this should not happen") diff --git a/src/bcbench/config.py b/src/bcbench/config.py index 502456944..0c45dd446 100644 --- a/src/bcbench/config.py +++ b/src/bcbench/config.py @@ -41,6 +41,7 @@ class PathConfig: leaderboard_dir: Path agent_share_dir: Path hook_script_path: Path + hook_script_path_py: Path @classmethod def from_root(cls, root: Path) -> PathConfig: @@ -56,6 +57,7 @@ def from_root(cls, root: Path) -> PathConfig: leaderboard_dir=root / "docs" / "_data", agent_share_dir=agent_share_dir, hook_script_path=agent_share_dir / "hooks" / "log-tool-usage.ps1", + hook_script_path_py=agent_share_dir / "hooks" / "log_tool_usage.py", ) diff --git a/src/bcbench/dataset/__init__.py b/src/bcbench/dataset/__init__.py index 4e6e205fa..bb6e49ab3 100644 --- a/src/bcbench/dataset/__init__.py +++ b/src/bcbench/dataset/__init__.py @@ -1,10 +1,14 @@ """Dataset module for querying, validating and analyze dataset entries.""" +from bcbench.dataset.codereview import CodeReviewEntry, ReviewComment, Severity from bcbench.dataset.dataset_entry import BaseDatasetEntry, BugFixEntry, TestEntry, TestGenEntry __all__ = [ "BaseDatasetEntry", "BugFixEntry", + "CodeReviewEntry", + "ReviewComment", + "Severity", "TestEntry", "TestGenEntry", ] diff --git a/src/bcbench/dataset/codereview.py b/src/bcbench/dataset/codereview.py new file mode 100644 index 000000000..b249d1bc4 --- /dev/null +++ b/src/bcbench/dataset/codereview.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from bcbench.dataset.dataset_entry import BaseDatasetEntry + + +class Severity(StrEnum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + @property + def level(self) -> int: + return _SEVERITY_LEVELS[self] + + @classmethod + def from_input(cls, value: str) -> Severity: + normalized = value.strip().lower() + try: + return cls(normalized) + except ValueError: + return _SEVERITY_ALIASES.get(normalized, cls.MEDIUM) + + +_SEVERITY_LEVELS: dict[Severity, int] = { + Severity.CRITICAL: 4, + Severity.HIGH: 3, + Severity.MEDIUM: 2, + Severity.LOW: 1, +} + +_SEVERITY_ALIASES: dict[str, Severity] = { + "error": Severity.HIGH, + "warning": Severity.MEDIUM, + "suggestion": Severity.LOW, + "info": Severity.LOW, +} + + +class ReviewComment(BaseModel): + model_config = ConfigDict(frozen=True) + + file: str + line_start: int + line_end: int | None = None + domain: str | None = None + body: str + severity: Severity + + @field_validator("severity", mode="before") + @classmethod + def _coerce_severity(cls, value: object) -> Severity: + if isinstance(value, Severity): + return value + return Severity.from_input(str(value)) + + def __str__(self) -> str: + loc = f"{self.file}:{self.line_start}" + if self.line_end and self.line_end != self.line_start: + loc += f"-{self.line_end}" + return f"[{self.severity}] {loc}: {self.body}" + + +class CodeReviewEntry(BaseDatasetEntry): + """Dataset entry for the code-review category.""" + + domain: str | None = None + expected_comments: list[ReviewComment] = Field(default_factory=list) + match_line_tolerance: int = Field(default=5, ge=0) + + def get_task(self) -> str: + return self.patch + + def get_expected_output(self) -> str: + return "\n".join(str(c) for c in self.expected_comments) diff --git a/src/bcbench/evaluate/__init__.py b/src/bcbench/evaluate/__init__.py index 9a29b5f09..3c8af1c4e 100644 --- a/src/bcbench/evaluate/__init__.py +++ b/src/bcbench/evaluate/__init__.py @@ -2,6 +2,7 @@ from bcbench.evaluate.base import EvaluationPipeline from bcbench.evaluate.bugfix import BugFixPipeline +from bcbench.evaluate.codereview import CodeReviewPipeline from bcbench.evaluate.testgeneration import TestGenerationPipeline -__all__ = ["BugFixPipeline", "EvaluationPipeline", "TestGenerationPipeline"] +__all__ = ["BugFixPipeline", "CodeReviewPipeline", "EvaluationPipeline", "TestGenerationPipeline"] diff --git a/src/bcbench/evaluate/codereview.py b/src/bcbench/evaluate/codereview.py new file mode 100644 index 000000000..98d61c705 --- /dev/null +++ b/src/bcbench/evaluate/codereview.py @@ -0,0 +1,155 @@ +import re +import subprocess +from collections.abc import Callable +from pathlib import Path + +from bcbench.dataset.codereview import CodeReviewEntry, ReviewComment +from bcbench.evaluate.base import EvaluationPipeline +from bcbench.evaluate.codereview_judge import judge_comment_matches +from bcbench.evaluate.review_parsing import parse_review_output +from bcbench.exceptions import PatchApplicationError +from bcbench.github_actions import github_log_group +from bcbench.logger import get_logger +from bcbench.operations import apply_patch, setup_repo_prebuild +from bcbench.results.codereview import CodeReviewResult, match_comments +from bcbench.types import EvaluationContext + +logger = get_logger(__name__) + +REVIEW_OUTPUT_FILE = "review.json" + +__all__ = ["CodeReviewPipeline"] + + +def _looks_like_full_file_patch(patch: str) -> bool: + return "@@" not in patch and "\n--- " in f"\n{patch}" and "\n+++ " in f"\n{patch}" + + +def _materialize_full_file_patch(repo_path: Path, patch: str) -> list[str]: + file_count = 0 + current_path: Path | None = None + current_content: list[str] = [] + materialized_paths: list[str] = [] + + def flush_current() -> None: + nonlocal file_count, current_path, current_content + if current_path is None: + return + current_path.parent.mkdir(parents=True, exist_ok=True) + current_path.write_text("\n".join(current_content) + "\n", encoding="utf-8") + materialized_paths.append(current_path.relative_to(repo_path).as_posix()) + file_count += 1 + current_path = None + current_content = [] + + for line in patch.splitlines(): + if line.startswith("--- "): + flush_current() + continue + + if line.startswith("+++ "): + relative_path = re.sub(r"^[ab]/", "", line[4:].strip()) + current_path = repo_path / relative_path + current_content = [] + continue + + if current_path is None: + continue + + if line.startswith("+"): + current_content.append(line[1:]) + continue + + if line.startswith("\\ No newline at end of file"): + continue + + flush_current() + return materialized_paths + + +def _patched_paths(patch: str) -> list[str]: + return [line[6:].strip() for line in patch.splitlines() if line.startswith("+++ b/")] + + +class CodeReviewPipeline(EvaluationPipeline[CodeReviewEntry]): + """Pipeline for code-review evaluation category. + + Code review does not require a BC container. We materialize the dataset patch + as local git changes so the agent can review the branch diff directly. + """ + + def setup_workspace(self, entry: CodeReviewEntry, repo_path: Path) -> None: + """Setup workspace for code review by applying the entry patch as local changes.""" + setup_repo_prebuild(entry, repo_path) + if entry.patch.strip(): + try: + apply_patch(repo_path, entry.patch, f"{entry.instance_id} review patch") + except PatchApplicationError: + if not _looks_like_full_file_patch(entry.patch): + raise + materialized_paths = _materialize_full_file_patch(repo_path, entry.patch) + if not materialized_paths: + raise + # Mark untracked files as intent-to-add so `git diff HEAD` includes them. + subprocess.run(["git", "add", "-N", "--", *materialized_paths], cwd=repo_path, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=True) + logger.info(f"Materialized {len(materialized_paths)} file(s) from simplified review patch for {entry.instance_id}") + + if paths := _patched_paths(entry.patch): + subprocess.run( + ["git", "add", "-N", "--", *paths], + cwd=repo_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + check=True, + ) + else: + logger.warning(f"Entry {entry.instance_id} has empty patch; review will run on clean workspace") + + def setup(self, context: EvaluationContext[CodeReviewEntry]) -> None: + self.setup_workspace(context.entry, context.repo_path) + + def run_agent(self, context: EvaluationContext[CodeReviewEntry], agent_runner: Callable) -> None: + with github_log_group(f"{context.agent_name} -- Entry: {context.entry.instance_id}"): + context.metrics, context.experiment = agent_runner(context) + + def evaluate(self, context: EvaluationContext[CodeReviewEntry]) -> None: + review_output_file: Path = context.repo_path / REVIEW_OUTPUT_FILE + + if not review_output_file.exists(): + logger.error(f"No review generated for {context.entry.instance_id}") + raise RuntimeError(f"No review generated for {context.entry.instance_id}") + output: str = review_output_file.read_text(encoding="utf-8") + + generated_comments: list[ReviewComment] | None = parse_review_output(output) + + if generated_comments is None: + logger.warning(f"Invalid review output for {context.entry.instance_id}") + result = CodeReviewResult.create_invalid(context, output, context.entry.expected_comments) + else: + structural_matches = match_comments( + context.entry.expected_comments, + generated_comments, + context.entry.match_line_tolerance, + ) + validated_matches = judge_comment_matches( + structural_matches, + model=context.model, + work_dir=context.repo_path, + ) + result = CodeReviewResult.create( + context, + output=output, + expected_comments=context.entry.expected_comments, + generated_comments=generated_comments, + line_tolerance=context.entry.match_line_tolerance, + matched_pairs=validated_matches, + ) + logger.info(f"Parsed {len(result.generated_comments)} comments from {REVIEW_OUTPUT_FILE}") + logger.info( + f"Code review metrics: matched={result.matched_comment_count}, " + f"incorrect={result.incorrect_comment_count}, missed={result.missed_comment_count}, " + f"precision={result.precision:.3f}, recall={result.recall:.3f}, f1={result.f1:.3f}" + ) + for comment in result.generated_comments: + logger.debug(f" {comment}") + self.save_result(context, result) diff --git a/src/bcbench/evaluate/codereview_judge.py b/src/bcbench/evaluate/codereview_judge.py new file mode 100644 index 000000000..025042660 --- /dev/null +++ b/src/bcbench/evaluate/codereview_judge.py @@ -0,0 +1,117 @@ +"""LLM-based semantic judge for validating structurally matched code review comment pairs. + +After structural matching (file + line proximity), the judge validates whether +each matched pair actually describes the same underlying issue. This filters out +false positives where two comments happen to be near each other but address different concerns. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +from bcbench.config import get_config +from bcbench.dataset.codereview import ReviewComment + +_config = get_config() + +JUDGE_RESULT_FILE = "judge_results.json" + +_JUDGE_PROMPT_TEMPLATE = """\ +You are a code review evaluation judge. Your task is to determine whether pairs of code review \ +comments identify the SAME underlying issue. + +For each pair below, decide if the "Expected" and "Candidate" comments point to the same bug, \ +concern, or code issue. Accept semantic matches — different wording is fine if it's the same problem. + +{pairs_text} + +Write your response as a JSON file at {result_path} with the following format: +[{{"pair": 1, "match": true, "reasoning": "brief explanation"}}, ...] + +You MUST include a result for every pair. Respond with ONLY the JSON file — no other output or tools.\ +""" + + +def _format_pair(index: int, expected: ReviewComment, generated: ReviewComment) -> str: + return ( + f"Pair {index}:\n" + f" Expected: [{expected.severity}] {expected.file}:{expected.line_start}: {expected.body}\n" + f" Candidate: [{generated.severity}] {generated.file}:{generated.line_start}: {generated.body}" + ) + + +def _build_judge_prompt(pairs: list[tuple[ReviewComment, ReviewComment]], result_path: str) -> str: + pairs_text = "\n\n".join(_format_pair(i + 1, exp, gen) for i, (exp, gen) in enumerate(pairs)) + return _JUDGE_PROMPT_TEMPLATE.format(pairs_text=pairs_text, result_path=result_path) + + +def _parse_judge_results(result_path: Path, num_pairs: int) -> list[bool]: + if not result_path.exists(): + return [True] * num_pairs + + try: + raw = json.loads(result_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return [True] * num_pairs + + if not isinstance(raw, list): + return [True] * num_pairs + + results_by_pair: dict[int, bool] = {} + for item in raw: + if isinstance(item, dict) and "pair" in item and "match" in item: + results_by_pair[item["pair"]] = bool(item["match"]) + + return [results_by_pair.get(i + 1, True) for i in range(num_pairs)] + + +def _find_copilot() -> str | None: + return shutil.which("copilot.exe") or shutil.which("copilot.cmd") or shutil.which("copilot") + + +def judge_comment_matches( + matched_pairs: list[tuple[ReviewComment, ReviewComment]], + model: str, + work_dir: Path, +) -> list[tuple[ReviewComment, ReviewComment]]: + """Validate structurally matched comment pairs using an LLM semantic judge. + + Args: + matched_pairs: Pairs from structural matching (expected, generated). + model: Model name to use for the judge. + work_dir: Directory to write judge results to. + + Returns: + Filtered list of pairs where the judge confirmed a semantic match. + """ + if not matched_pairs: + return [] + + copilot_cmd = _find_copilot() + if not copilot_cmd: + return matched_pairs + + result_path = work_dir / JUDGE_RESULT_FILE + prompt = _build_judge_prompt(matched_pairs, JUDGE_RESULT_FILE) + + try: + subprocess.run( + [ + copilot_cmd, + "--allow-all-tools", + "--disable-builtin-mcps", + "--no-custom-instructions", + f"--model={model}", + f"--prompt={prompt.replace(chr(10), ' ')}", + ], + cwd=str(work_dir), + capture_output=True, + timeout=_config.timeout.agent_execution, + check=True, + ) + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): + return matched_pairs + + verdicts = _parse_judge_results(result_path, len(matched_pairs)) + return [pair for pair, is_match in zip(matched_pairs, verdicts, strict=True) if is_match] diff --git a/src/bcbench/evaluate/review_parsing.py b/src/bcbench/evaluate/review_parsing.py new file mode 100644 index 000000000..4dea995ae --- /dev/null +++ b/src/bcbench/evaluate/review_parsing.py @@ -0,0 +1,113 @@ +import json +import re +from typing import Any + +from bcbench.dataset.codereview import ReviewComment, Severity +from bcbench.logger import get_logger + +logger = get_logger(__name__) + +__all__ = ["parse_review_output"] + + +def _extract_json_candidate(raw_output: str) -> str: + stripped = raw_output.strip() + if not stripped: + return "" + + if stripped.startswith(("[", "{")): + return stripped + + block_match = re.search(r"```json\s*([\s\S]*?)\s*```", raw_output, re.IGNORECASE) + if block_match: + return block_match.group(1).strip() + + generic_block_match = re.search(r"```\s*([\s\S]*?)\s*```", raw_output) + if generic_block_match: + return generic_block_match.group(1).strip() + + return stripped + + +def _to_int(value: object) -> int | None: + if value is None: + return None + if isinstance(value, bool): + return None + try: + parsed = int(str(value)) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _normalize_comment(item: dict[Any, Any]) -> ReviewComment | None: + file_path = item.get("file") or item.get("filePath") or item.get("path") + line_start = _to_int(item.get("line_start") or item.get("lineNumber") or item.get("line")) + line_end = _to_int(item.get("line_end") or item.get("lineEnd") or item.get("endLine")) + domain = item.get("domain") + body = item.get("body") or item.get("issue") or item.get("comment") + severity = Severity.from_input(str(item.get("severity", "medium"))) + + if not isinstance(file_path, str) or not file_path.strip(): + return None + if line_start is None: + return None + if not isinstance(body, str) or not body.strip(): + return None + + try: + return ReviewComment( + file=file_path.strip(), + line_start=line_start, + line_end=line_end, + domain=domain.strip() if isinstance(domain, str) and domain.strip() else None, + body=body.strip(), + severity=severity, + ) + except Exception: + return None + + +def parse_review_output(raw_output: str) -> list[ReviewComment] | None: + """Parse raw agent output into review comments. + + Returns ``None`` when the output is not a valid review (empty or unparseable), + or a (possibly empty) list when it parses — an empty list means the model + legitimately reported no findings. + """ + if not raw_output.strip(): + return None + + candidate = _extract_json_candidate(raw_output) + if not candidate: + return None + + try: + raw = json.loads(candidate) + except json.JSONDecodeError: + logger.warning("Failed to parse review output as JSON") + return None + + raw_items: list[object] + if isinstance(raw, list): + raw_items = raw + elif isinstance(raw, dict) and isinstance(raw.get("findings"), list): + raw_items = raw["findings"] + elif isinstance(raw, dict) and any(key in raw for key in ("file", "filePath", "path")): + raw_items = [raw] + else: + logger.warning(f"Expected JSON array or object with findings[], got {type(raw).__name__}") + return None + + comments: list[ReviewComment] = [] + for item in raw_items: + if not isinstance(item, dict): + continue + normalized = _normalize_comment(item) + if normalized is not None: + comments.append(normalized) + else: + logger.debug(f"Skipping malformed comment: {item}") + + return comments diff --git a/src/bcbench/operations/hooks_operations.py b/src/bcbench/operations/hooks_operations.py index ce9be8a8f..478e05f76 100644 --- a/src/bcbench/operations/hooks_operations.py +++ b/src/bcbench/operations/hooks_operations.py @@ -13,13 +13,14 @@ def setup_hooks(repo_path: Path, agent_type: AgentType, output_dir: Path) -> Path: tool_log_path = output_dir / _config.file_patterns.tool_usage_log tool_log_path.unlink(missing_ok=True) - script_path = str(_config.paths.hook_script_path.resolve()) + ps_script = str(_config.paths.hook_script_path.resolve()) + py_script = str(_config.paths.hook_script_path_py.resolve()) match agent_type: case AgentType.COPILOT: - _setup_copilot_hooks(repo_path, script_path, tool_log_path) + _setup_copilot_hooks(repo_path, ps_script, py_script, tool_log_path) case AgentType.CLAUDE: - _setup_claude_hooks(repo_path, script_path, tool_log_path) + _setup_claude_hooks(repo_path, ps_script, tool_log_path) case _: raise ValueError(f"Unknown AgentType: {agent_type}") @@ -27,17 +28,22 @@ def setup_hooks(repo_path: Path, agent_type: AgentType, output_dir: Path) -> Pat return tool_log_path -def _setup_copilot_hooks(repo_path: Path, script_path: str, tool_log_path: Path) -> None: +def _setup_copilot_hooks(repo_path: Path, ps_script: str, py_script: str, tool_log_path: Path) -> None: hooks_dir = repo_path / ".github" / "hooks" hooks_dir.mkdir(parents=True, exist_ok=True) + # Per https://docs.github.com/en/copilot/reference/hooks-reference, only the field matching + # the runner OS is honored: `bash` on Linux/macOS and `powershell` on Windows. We use Python + # on Linux (preinstalled, no pwsh/stdin quirks) and keep the .ps1 path on Windows where it + # already works for bug-fix/test-gen runs on self-hosted runners. hooks_config = { "version": 1, "hooks": { "preToolUse": [ { "type": "command", - "powershell": f'powershell -ExecutionPolicy Bypass -File "{script_path}"', + "bash": f'python3 "{py_script}"', + "powershell": f'powershell -ExecutionPolicy Bypass -File "{ps_script}"', "env": {"BCBENCH_TOOL_LOG": str(tool_log_path.resolve())}, "timeoutSec": 5, } diff --git a/src/bcbench/results/__init__.py b/src/bcbench/results/__init__.py index 888dded44..11de8f933 100644 --- a/src/bcbench/results/__init__.py +++ b/src/bcbench/results/__init__.py @@ -1,12 +1,14 @@ from bcbench.results.base import ExecutionBasedEvaluationResult, JudgeBasedEvaluationResult from bcbench.results.bceval_export import write_bceval_results +from bcbench.results.codereview import CodeReviewResult, CodeReviewResultSummary from bcbench.results.display import create_console_summary, create_github_job_summary from bcbench.results.leaderboard import ( + CodeReviewLeaderboardAggregate, ExecutionBasedLeaderboardAggregate, Leaderboard, LeaderboardAggregate, ) -from bcbench.results.metrics import bootstrap_ci, pass_at_k, pass_hat_k +from bcbench.results.metrics import bootstrap_ci, f_beta_score, pass_at_k, pass_hat_k from bcbench.results.summary import ( BaseEvaluationResult, EvaluationResultSummary, @@ -16,6 +18,9 @@ __all__ = [ "BaseEvaluationResult", + "CodeReviewLeaderboardAggregate", + "CodeReviewResult", + "CodeReviewResultSummary", "EvaluationResultSummary", "ExecutionBasedEvaluationResult", "ExecutionBasedEvaluationResultSummary", @@ -27,6 +32,7 @@ "bootstrap_ci", "create_console_summary", "create_github_job_summary", + "f_beta_score", "pass_at_k", "pass_hat_k", "write_bceval_results", diff --git a/src/bcbench/results/base.py b/src/bcbench/results/base.py index da4c05b42..998839f79 100644 --- a/src/bcbench/results/base.py +++ b/src/bcbench/results/base.py @@ -1,6 +1,7 @@ """Base evaluation result class with shared metrics across all evaluation categories.""" import json +import re from pathlib import Path from typing import Any, Self @@ -12,6 +13,15 @@ logger = get_logger(__name__) +def natural_sort_key(value: str) -> tuple[object, ...]: + """Split a string into a tuple suitable for human-friendly sorting. + + "test__name-2" sorts before "test__name-10" because numeric runs are compared as integers. + Non-numeric segments are lowercased for case-insensitive ordering. + """ + return tuple(int(segment) if segment.isdigit() else segment.lower() for segment in re.split(r"(\d+)", value)) + + class BaseEvaluationResult(BaseModel): """Base class for all evaluation results with shared metrics across categories.""" @@ -87,6 +97,15 @@ def display_row(self) -> dict[str, str]: """ return {} + @property + def sort_key(self) -> tuple[object, ...]: + """Sort key for the detailed results table. + + Default orders by natural-sorted instance_id (so ``-002`` precedes ``-010``). + Subclasses can prepend category-specific keys (e.g. domain for code-review). + """ + return (natural_sort_key(self.instance_id),) + @classmethod def from_json(cls, payload: dict[str, Any]) -> "BaseEvaluationResult": category = EvaluationCategory(payload["category"]) diff --git a/src/bcbench/results/codereview.py b/src/bcbench/results/codereview.py new file mode 100644 index 000000000..e46f85465 --- /dev/null +++ b/src/bcbench/results/codereview.py @@ -0,0 +1,433 @@ +from collections.abc import Sequence +from typing import Self + +from pydantic import Field +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from bcbench.dataset import ReviewComment +from bcbench.results.base import BaseEvaluationResult, natural_sort_key +from bcbench.results.metrics import f1_score, f_beta_score, precision_recall +from bcbench.results.summary import EvaluationResultSummary +from bcbench.types import EvaluationContext + + +def _resolve_domain(context: "EvaluationContext") -> str: + entry = context.entry + domain = getattr(entry, "domain", None) or entry.metadata.area + return domain if isinstance(domain, str) and domain else "unknown" + + +_METRIC_EXPLANATIONS = """\ +
+📖 How to read these metrics + +- **Micro** — sums matched/generated/expected across all tasks and computes one score; tasks with many comments dominate. +- **Macro** — computes P/R/F1 per task and averages the scores; every task counts equally regardless of comment volume. +- **Matched comment** — a generated comment paired with an expected one by file and line proximity (within the configured tolerance), then confirmed by an LLM judge to describe the same underlying issue. +- **F1** — harmonic mean of precision and recall; balances both equally. (Special case of Fβ at β=1.) +- **Fβ** — generalized F-score with a tunable precision/recall trade-off: + + ``` + F_β = (1 + β²) · (P · R) / (β² · P + R) + ``` + + where *P* = precision, *R* = recall. β < 1 favors precision; β > 1 favors recall. +- **Fβ (β=0.5)** — precision-leaning; use when false positives are costly (noisy reviews waste reviewer time). +- **Fβ (β=2)** — recall-leaning; use when missing issues is costly. +- **Severity MAE** — mean absolute error between generated and expected severity levels (matched comments only). Lower is better; `0` = exact match. +- **Valid review output rate** — fraction of runs whose output parsed into a structured review. Failures score 0 on every other metric. + +
+""" + + +_CONSOLE_METRIC_EXPLANATIONS = ( + "[bold]Micro[/bold] — volume-weighted across all comments; tasks with many comments dominate.\n" + "[bold]Macro[/bold] — per-task P/R/F1 averaged equally; every task counts the same.\n" + "[bold]Matched comment[/bold] — paired by file + line proximity, then confirmed by an LLM judge to describe the same underlying issue.\n" + "[bold]F1[/bold] — harmonic mean of precision and recall (special case of Fβ at β=1).\n" + "[bold]Fβ[/bold] — F_β = (1 + β²) · (P · R) / (β² · P + R); β<1 favors precision, β>1 favors recall.\n" + "[bold]Fβ (β=0.5)[/bold] — precision-leaning; use when false positives are costly.\n" + "[bold]Fβ (β=2)[/bold] — recall-leaning; use when missing issues is costly.\n" + "[bold]Severity MAE[/bold] — mean absolute error of severity levels for matched comments; lower is better, 0 = exact match.\n" + "[bold]Valid review output rate[/bold] — fraction of runs whose output parsed into a structured review." +) + + +def _build_console_table(title: str, columns: list[str], row: list[str]) -> Table: + table = Table(title=title, title_justify="left", title_style="bold cyan", show_header=True, header_style="bold") + for column in columns: + table.add_column(column, justify="right") + table.add_row(*row) + return table + + +def _with_comment_domains(generated_comments: list[ReviewComment], domain: str) -> list[ReviewComment]: + """Stamp the entry domain onto comments that have no explicit domain. All comments are kept.""" + return [comment if comment.domain else comment.model_copy(update={"domain": domain}) for comment in generated_comments] + + +def _normalize_path(path: str) -> str: + return path.replace("\\", "/").lstrip("./").lstrip("/") + + +def _line_distance(line: int, start: int, end: int | None) -> int: + effective_end = end if end is not None else start + if start <= line <= effective_end: + return 0 + if line < start: + return start - line + return line - effective_end + + +def match_comments( + expected_comments: list[ReviewComment], + generated_comments: list[ReviewComment], + line_tolerance: int, +) -> list[tuple[ReviewComment, ReviewComment]]: + """Greedily pair each expected comment with the nearest unused generated comment in the same file.""" + matched: list[tuple[ReviewComment, ReviewComment]] = [] + used_generated: set[int] = set() + + for expected in expected_comments: + expected_file = _normalize_path(expected.file) + best_index: int | None = None + best_distance: int | None = None + + for index, generated in enumerate(generated_comments): + if index in used_generated or _normalize_path(generated.file) != expected_file: + continue + + distance: int = _line_distance(generated.line_start, expected.line_start, expected.line_end) + if distance > line_tolerance: + continue + + if best_distance is None or distance < best_distance: + best_distance = distance + best_index = index + + if best_index is not None: + used_generated.add(best_index) + matched.append((expected, generated_comments[best_index])) + + return matched + + +def _severity_mae(matched_pairs: list[tuple[ReviewComment, ReviewComment]]) -> float: + if not matched_pairs: + return 0.0 + total_error: int = sum(abs(expected.severity.level - generated.severity.level) for expected, generated in matched_pairs) + return total_error / len(matched_pairs) + + +class CodeReviewResult(BaseEvaluationResult): + """Result for the code-review category.""" + + domain: str = "unknown" + generated_comments: list[ReviewComment] = Field(default_factory=list) + expected_comments: list[ReviewComment] = Field(default_factory=list) + line_tolerance: int = Field(ge=0) + valid_review_output: bool = False + + matched_comment_count: int = Field(default=0, ge=0) + missed_comment_count: int = Field(default=0, ge=0) + incorrect_comment_count: int = Field(default=0, ge=0) + + precision: float = Field(default=0.0, ge=0.0, le=1.0) + recall: float = Field(default=0.0, ge=0.0, le=1.0) + f1: float = Field(default=0.0, ge=0.0, le=1.0) + f_beta_05: float = Field(default=0.0, ge=0.0, le=1.0) + f_beta_2: float = Field(default=0.0, ge=0.0, le=1.0) + severity_mae: float = 0.0 + + @classmethod + def create( + cls, + context: "EvaluationContext", + output: str, + expected_comments: list[ReviewComment], + generated_comments: list[ReviewComment], + line_tolerance: int, + matched_pairs: list[tuple[ReviewComment, ReviewComment]] | None = None, + ) -> Self: + domain = _resolve_domain(context) + generated_comments = _with_comment_domains(generated_comments, domain) + if matched_pairs is None: + matched_pairs = match_comments(expected_comments, generated_comments, line_tolerance) + matched_count = len(matched_pairs) + precision, recall = precision_recall(matched_count, len(generated_comments), len(expected_comments)) + + return cls( + **cls._base_fields(context), + domain=domain, + output=output, + expected_comments=expected_comments, + generated_comments=generated_comments, + line_tolerance=line_tolerance, + valid_review_output=True, + matched_comment_count=matched_count, + incorrect_comment_count=max(0, len(generated_comments) - matched_count), + missed_comment_count=max(0, len(expected_comments) - matched_count), + precision=precision, + recall=recall, + f1=f1_score(precision, recall), + f_beta_05=f_beta_score(precision, recall, beta=0.5), + f_beta_2=f_beta_score(precision, recall, beta=2.0), + severity_mae=_severity_mae(matched_pairs), + ) + + @classmethod + def create_invalid( + cls, + context: "EvaluationContext", + output: str, + expected_comments: list[ReviewComment], + ) -> Self: + """Result for output that could not be parsed into a review — scored zero.""" + return cls( + **cls._base_fields(context), + domain=_resolve_domain(context), + output=output, + expected_comments=expected_comments, + line_tolerance=0, + valid_review_output=False, + ) + + @property + def category_metrics(self) -> dict[str, int | float | bool]: + return { + "generated_comment_count": len(self.generated_comments), + "expected_comment_count": len(self.expected_comments), + "matched_comment_count": self.matched_comment_count, + "incorrect_comment_count": self.incorrect_comment_count, + "missed_comment_count": self.missed_comment_count, + "precision": round(self.precision, 3), + "recall": round(self.recall, 3), + "f1": round(self.f1, 3), + "f_beta_05": round(self.f_beta_05, 3), + "f_beta_2": round(self.f_beta_2, 3), + "severity_mae": round(self.severity_mae, 3), + "valid_review_output": self.valid_review_output, + } + + @property + def display_row(self) -> dict[str, str]: + return { + "Domain": self.domain, + "Generated": str(len(self.generated_comments)), + "Matched": str(self.matched_comment_count), + "Expected": str(len(self.expected_comments)), + "Precision": f"{self.precision:.2f}", + "Recall": f"{self.recall:.2f}", + "F1": f"{self.f1:.2f}", + } + + @property + def sort_key(self) -> tuple[object, ...]: + return (self.domain.lower(), natural_sort_key(self.instance_id)) + + +class CodeReviewResultSummary(EvaluationResultSummary): + """ + Summary for the code-review category. + + Micro metrics aggregate matched/expected/generated comment counts across all results (volume-weighted). + Macro metrics average per-task scores (each task weighted equally). + """ + + generated_comment_count: int = Field(default=0, ge=0) + expected_comment_count: int = Field(default=0, ge=0) + matched_comment_count: int = Field(default=0, ge=0) + incorrect_comment_count: int = Field(default=0, ge=0) + missed_comment_count: int = Field(default=0, ge=0) + + precision: float = Field(default=0.0, ge=0.0, le=1.0) + recall: float = Field(default=0.0, ge=0.0, le=1.0) + f1: float = Field(default=0.0, ge=0.0, le=1.0) + f_beta_05: float = Field(default=0.0, ge=0.0, le=1.0) + f_beta_2: float = Field(default=0.0, ge=0.0, le=1.0) + + macro_precision: float = Field(default=0.0, ge=0.0, le=1.0) + macro_recall: float = Field(default=0.0, ge=0.0, le=1.0) + macro_f1: float = Field(default=0.0, ge=0.0, le=1.0) + macro_f_beta_05: float = Field(default=0.0, ge=0.0, le=1.0) + macro_f_beta_2: float = Field(default=0.0, ge=0.0, le=1.0) + + severity_mae: float = 0.0 + valid_review_output_rate: float = Field(default=0.0, ge=0.0, le=1.0) + + def display_summary(self) -> dict[str, int | float]: + return { + "generated_comment_count": self.generated_comment_count, + "expected_comment_count": self.expected_comment_count, + "matched_comment_count": self.matched_comment_count, + "incorrect_comment_count": self.incorrect_comment_count, + "missed_comment_count": self.missed_comment_count, + "micro_precision": round(self.precision * 100, 1), + "micro_recall": round(self.recall * 100, 1), + "micro_f1": round(self.f1 * 100, 1), + "micro_f_beta_05": round(self.f_beta_05 * 100, 1), + "micro_f_beta_2": round(self.f_beta_2 * 100, 1), + "macro_precision": round(self.macro_precision * 100, 1), + "macro_recall": round(self.macro_recall * 100, 1), + "macro_f1": round(self.macro_f1 * 100, 1), + "macro_f_beta_05": round(self.macro_f_beta_05 * 100, 1), + "macro_f_beta_2": round(self.macro_f_beta_2 * 100, 1), + "severity_mae": round(self.severity_mae, 3), + "valid_review_output_rate": round(self.valid_review_output_rate * 100, 1), + } + + def render_github_metrics_markdown(self) -> str: + micro_p = self.precision * 100 + micro_r = self.recall * 100 + micro_f1 = self.f1 * 100 + micro_f05 = self.f_beta_05 * 100 + micro_f2 = self.f_beta_2 * 100 + macro_p = self.macro_precision * 100 + macro_r = self.macro_recall * 100 + macro_f1 = self.macro_f1 * 100 + macro_f05 = self.macro_f_beta_05 * 100 + macro_f2 = self.macro_f_beta_2 * 100 + valid_rate = self.valid_review_output_rate * 100 + return ( + "## Comment counts\n" + "\n" + "| Generated | Expected | Matched | Incorrect | Missed |\n" + "|----------:|---------:|--------:|----------:|-------:|\n" + f"| {self.generated_comment_count} | {self.expected_comment_count} | {self.matched_comment_count} | {self.incorrect_comment_count} | {self.missed_comment_count} |\n" + "\n" + "## Micro metrics (volume-weighted across all comments)\n" + "\n" + "| Precision | Recall | F1 | Fβ (β=0.5) | Fβ (β=2) |\n" + "|----------:|-------:|---:|-----------:|---------:|\n" + f"| {micro_p:.1f}% | {micro_r:.1f}% | {micro_f1:.1f}% | {micro_f05:.1f}% | {micro_f2:.1f}% |\n" + "\n" + "## Macro metrics (averaged per task)\n" + "\n" + "| Precision | Recall | F1 | Fβ (β=0.5) | Fβ (β=2) |\n" + "|----------:|-------:|---:|-----------:|---------:|\n" + f"| {macro_p:.1f}% | {macro_r:.1f}% | {macro_f1:.1f}% | {macro_f05:.1f}% | {macro_f2:.1f}% |\n" + "\n" + "## Quality\n" + "\n" + "| Severity MAE | Valid review output rate |\n" + "|-------------:|-------------------------:|\n" + f"| {self.severity_mae:.3f} | {valid_rate:.1f}% |\n" + "\n" + f"{_METRIC_EXPLANATIONS}" + ) + + def render_console_metrics(self, console: Console) -> None: + metric_columns = ["Precision", "Recall", "F1", "Fβ (β=0.5)", "Fβ (β=2)"] + + console.print( + _build_console_table( + "Comment counts", + ["Generated", "Expected", "Matched", "Incorrect", "Missed"], + [ + str(self.generated_comment_count), + str(self.expected_comment_count), + str(self.matched_comment_count), + str(self.incorrect_comment_count), + str(self.missed_comment_count), + ], + ) + ) + console.print( + _build_console_table( + "Micro metrics (volume-weighted across all comments)", + metric_columns, + [ + f"{self.precision * 100:.1f}%", + f"{self.recall * 100:.1f}%", + f"{self.f1 * 100:.1f}%", + f"{self.f_beta_05 * 100:.1f}%", + f"{self.f_beta_2 * 100:.1f}%", + ], + ) + ) + console.print( + _build_console_table( + "Macro metrics (averaged per task)", + metric_columns, + [ + f"{self.macro_precision * 100:.1f}%", + f"{self.macro_recall * 100:.1f}%", + f"{self.macro_f1 * 100:.1f}%", + f"{self.macro_f_beta_05 * 100:.1f}%", + f"{self.macro_f_beta_2 * 100:.1f}%", + ], + ) + ) + console.print( + _build_console_table( + "Quality", + ["Severity MAE", "Valid review output rate"], + [f"{self.severity_mae:.3f}", f"{self.valid_review_output_rate * 100:.1f}%"], + ) + ) + console.print( + Panel( + _CONSOLE_METRIC_EXPLANATIONS, + title="📖 How to read these metrics", + title_align="left", + border_style="dim", + padding=(1, 2), + ) + ) + + @classmethod + def from_results(cls, results: Sequence[BaseEvaluationResult], run_id: str) -> "CodeReviewResultSummary": + summary = super().from_results(results, run_id) + assert isinstance(summary, CodeReviewResultSummary) + + code_review_results: list[CodeReviewResult] = [r for r in results if isinstance(r, CodeReviewResult)] + total_results: int = len(code_review_results) + + generated_total: int = sum(len(r.generated_comments) for r in code_review_results) + expected_total: int = sum(len(r.expected_comments) for r in code_review_results) + matched_total: int = sum(r.matched_comment_count for r in code_review_results) + incorrect_total: int = sum(r.incorrect_comment_count for r in code_review_results) + missed_total: int = sum(r.missed_comment_count for r in code_review_results) + + precision, recall = precision_recall(matched_total, generated_total, expected_total) + f1: float = f1_score(precision, recall) + f_beta_05: float = f_beta_score(precision, recall, beta=0.5) + f_beta_2: float = f_beta_score(precision, recall, beta=2.0) + + macro_precision: float = sum(r.precision for r in code_review_results) / total_results + macro_recall: float = sum(r.recall for r in code_review_results) / total_results + macro_f1: float = sum(r.f1 for r in code_review_results) / total_results + macro_f_beta_05: float = sum(r.f_beta_05 for r in code_review_results) / total_results + macro_f_beta_2: float = sum(r.f_beta_2 for r in code_review_results) / total_results + + weighted_mae_numerator: float = sum(r.severity_mae * r.matched_comment_count for r in code_review_results) + weighted_mae_denominator: int = sum(r.matched_comment_count for r in code_review_results) + severity_mae: float = weighted_mae_numerator / weighted_mae_denominator if weighted_mae_denominator > 0 else 0.0 + + valid_output_count: int = sum(1 for r in code_review_results if r.valid_review_output) + valid_output_rate: float = valid_output_count / total_results + + return summary.model_copy( + update={ + "generated_comment_count": generated_total, + "expected_comment_count": expected_total, + "matched_comment_count": matched_total, + "incorrect_comment_count": incorrect_total, + "missed_comment_count": missed_total, + "precision": round(precision, 3), + "recall": round(recall, 3), + "f1": round(f1, 3), + "f_beta_05": round(f_beta_05, 3), + "f_beta_2": round(f_beta_2, 3), + "macro_precision": round(macro_precision, 3), + "macro_recall": round(macro_recall, 3), + "macro_f1": round(macro_f1, 3), + "macro_f_beta_05": round(macro_f_beta_05, 3), + "macro_f_beta_2": round(macro_f_beta_2, 3), + "severity_mae": round(severity_mae, 3), + "valid_review_output_rate": round(valid_output_rate, 3), + } + ) diff --git a/src/bcbench/results/display.py b/src/bcbench/results/display.py index 16e534a7c..b02a8f6d9 100644 --- a/src/bcbench/results/display.py +++ b/src/bcbench/results/display.py @@ -13,7 +13,6 @@ def _status_style(status_label: str) -> tuple[str, str]: - """Return (rich_color, github_emoji) for a status label.""" if status_label in ("Timeout", "Error", "Failed"): return "red", ":x:" if status_label == "Unscored": @@ -23,13 +22,11 @@ def _status_style(status_label: str) -> tuple[str, str]: def create_console_summary(results: Sequence[BaseEvaluationResult], summary: EvaluationResultSummary) -> None: total = len(results) - display_metrics: dict[str, int | float | bool] = summary.display_summary() console.print("\n[bold cyan]Evaluation Results Summary[/bold cyan]") console.print(f"Total Processed: [bold]{total}[/bold], using [bold]{results[0].agent_name}({results[0].model})[/bold]") console.print(f"Category: [bold]{results[0].category.value}[/bold]") - for key, value in display_metrics.items(): - console.print(f"{key.replace('_', ' ').title()}: [bold]{value}[/bold]") + summary.render_console_metrics(console) # Display average tool usage if available tool_usages = [r.metrics.tool_usage for r in results if r.metrics and r.metrics.tool_usage is not None] @@ -51,29 +48,27 @@ def create_console_summary(results: Sequence[BaseEvaluationResult], summary: Eva for col_name in extra_columns: table.add_column(col_name, style="yellow") - table.add_column("Error Message", style="dim") + table.add_column("MCP Servers", style="yellow") + table.add_column("Custom Instructions", style="yellow") + table.add_column("Skills", style="yellow") + table.add_column("Custom Agent", style="yellow") - for result in results: + for result in sorted(results, key=lambda r: r.sort_key): color, _ = _status_style(result.status_label) status = f"[{color}]{result.status_label}[/{color}]" + mcp_servers = ", ".join(result.experiment.mcp_servers) if result.experiment and result.experiment.mcp_servers else "N/A" + custom_instructions = "Yes" if result.experiment and result.experiment.custom_instructions else "No" + skills = "Yes" if result.experiment and result.experiment.skills_enabled else "No" + custom_agent = result.experiment.custom_agent if result.experiment and result.experiment.custom_agent else "N/A" extra_values = list(result.display_row.values()) - table.add_row(result.instance_id, result.project, status, *extra_values, result.error_message or "") + table.add_row(result.instance_id, result.project, status, *extra_values, mcp_servers, custom_instructions, skills, custom_agent) console.print(table) console.print() -def _get_short_error_message(error_message: str | None) -> str: - """Extract the first line of an error message for summary display.""" - if not error_message: - return "" - first_line = error_message.split("\n")[0].rstrip(":") - return first_line.replace("|", "\\|") - - def create_github_job_summary(results: Sequence[BaseEvaluationResult], summary: EvaluationResultSummary) -> None: total = len(results) - display_metrics: dict[str, int | float | bool] = summary.display_summary() mcp_servers = ", ".join(results[0].experiment.mcp_servers) if results[0].experiment and results[0].experiment.mcp_servers else "None" al_lsp_enabled = "Yes" if results[0].experiment and results[0].experiment.al_lsp_enabled else "No" @@ -91,24 +86,23 @@ def create_github_job_summary(results: Sequence[BaseEvaluationResult], summary: tool_lines = [f" - `{tool}`: {count}" for tool, count in sorted_tools] tool_usage_section = "\n\n## Average Tool Usage\n" + "\n".join(tool_lines) - # Only render "## Result Summary" when the category has aggregates to show. - result_summary_section = "" - if display_metrics: - display_lines = "\n".join(f"- {key.replace('_', ' ').title()}: {value}" for key, value in display_metrics.items()) - result_summary_section = f"\n## Result Summary\n{display_lines}\n" - - markdown_summary = ( - f"Total entries processed: {total}, using **{results[0].agent_name} ({results[0].model})**\n" - f"- Category: `{results[0].category.value}`\n" - f"- MCP Servers used: {mcp_servers}\n" - f"- AL LSP: {al_lsp_enabled}\n" - f"- Custom Instructions: {custom_instructions}\n" - f"- Skills: {skills}\n" - f"- Custom Agent: {custom_agent}\n" - f"{result_summary_section}" - f"{tool_usage_section}\n\n" - f"## Detailed Results\n\n" - ) + metrics_section = summary.render_github_metrics_markdown() + + markdown_summary = f"""Total entries processed: {total}, using **{results[0].agent_name} ({results[0].model})** +- Category: `{results[0].category.value}` +- MCP Servers used: {mcp_servers} +- AL LSP: {al_lsp_enabled} +- Custom Instructions: {custom_instructions} +- Skills: {skills} +- Custom Agent: {custom_agent} + +{metrics_section} + +{tool_usage_section} + +## Detailed Results + +""" # Dynamic columns from display_row() extra_columns = list(results[0].display_row.keys()) if results else [] @@ -116,21 +110,20 @@ def create_github_job_summary(results: Sequence[BaseEvaluationResult], summary: extra_separator = " | ".join("------" for _ in extra_columns) if extra_columns: - markdown_summary += f"| Instance ID | Project | Status | {extra_headers} | Error Message |\n" - markdown_summary += f"|-------------|---------|--------|{extra_separator}|---------------|\n" + markdown_summary += f"| Instance ID | Project | Status | {extra_headers} |\n" + markdown_summary += f"|-------------|---------|--------|{extra_separator}|\n" else: - markdown_summary += "| Instance ID | Project | Status | Error Message |\n" - markdown_summary += "|-------------|---------|--------|---------------|\n" + markdown_summary += "| Instance ID | Project | Status |\n" + markdown_summary += "|-------------|---------|--------|\n" - for result in results: + for result in sorted(results, key=lambda r: r.sort_key): _, status_icon = _status_style(result.status_label) status_text = f"{status_icon} {result.status_label}" - error_msg = _get_short_error_message(result.error_message) extra_values = " | ".join(result.display_row.values()) if extra_columns: - markdown_summary += f"| `{result.instance_id}` | `{result.project}` | {status_text} | {extra_values} | {error_msg} |\n" + markdown_summary += f"| `{result.instance_id}` | `{result.project}` | {status_text} | {extra_values} |\n" else: - markdown_summary += f"| `{result.instance_id}` | `{result.project}` | {status_text} | {error_msg} |\n" + markdown_summary += f"| `{result.instance_id}` | `{result.project}` | {status_text} |\n" _write_github_step_summary(markdown_summary) diff --git a/src/bcbench/results/leaderboard.py b/src/bcbench/results/leaderboard.py index 5bb87d123..fddf54bcb 100644 --- a/src/bcbench/results/leaderboard.py +++ b/src/bcbench/results/leaderboard.py @@ -114,6 +114,58 @@ def from_runs(cls, runs: Sequence[EvaluationResultSummary]) -> "ExecutionBasedLe ) +class CodeReviewLeaderboardAggregate(LeaderboardAggregate): + """Aggregate for the code-review category: mean F1 across runs with bootstrap CI.""" + + f1: float = 0.0 + f1_ci_low: float | None = None + f1_ci_high: float | None = None + f_beta_05: float = 0.0 + f_beta_2: float = 0.0 + precision: float = 0.0 + recall: float = 0.0 + + macro_f1: float = 0.0 + macro_f1_ci_low: float | None = None + macro_f1_ci_high: float | None = None + macro_f_beta_05: float = 0.0 + macro_f_beta_2: float = 0.0 + macro_precision: float = 0.0 + macro_recall: float = 0.0 + + @classmethod + def from_runs(cls, runs: Sequence[EvaluationResultSummary]) -> "CodeReviewLeaderboardAggregate": + from bcbench.results.codereview import CodeReviewResultSummary + + base = super().from_runs(runs) + assert isinstance(base, CodeReviewLeaderboardAggregate) + + cr_runs: list[CodeReviewResultSummary] = [run for run in runs if isinstance(run, CodeReviewResultSummary)] + n = len(cr_runs) + + f1_ci = bootstrap_ci([r.f1 for r in cr_runs]) + macro_f1_ci = bootstrap_ci([r.macro_f1 for r in cr_runs]) + + return base.model_copy( + update={ + "f1": round(f1_ci["mean"], 3) if f1_ci["mean"] is not None else 0.0, + "f1_ci_low": round(f1_ci["ci_low"], 3) if f1_ci["ci_low"] is not None else None, + "f1_ci_high": round(f1_ci["ci_high"], 3) if f1_ci["ci_high"] is not None else None, + "f_beta_05": sum(r.f_beta_05 for r in cr_runs) / n, + "f_beta_2": sum(r.f_beta_2 for r in cr_runs) / n, + "precision": sum(r.precision for r in cr_runs) / n, + "recall": sum(r.recall for r in cr_runs) / n, + "macro_f1": round(macro_f1_ci["mean"], 3) if macro_f1_ci["mean"] is not None else 0.0, + "macro_f1_ci_low": round(macro_f1_ci["ci_low"], 3) if macro_f1_ci["ci_low"] is not None else None, + "macro_f1_ci_high": round(macro_f1_ci["ci_high"], 3) if macro_f1_ci["ci_high"] is not None else None, + "macro_f_beta_05": sum(r.macro_f_beta_05 for r in cr_runs) / n, + "macro_f_beta_2": sum(r.macro_f_beta_2 for r in cr_runs) / n, + "macro_precision": sum(r.macro_precision for r in cr_runs) / n, + "macro_recall": sum(r.macro_recall for r in cr_runs) / n, + } + ) + + class Leaderboard(BaseModel): """Leaderboard holding per-run summaries and their multi-run aggregates for a category. diff --git a/src/bcbench/results/metrics.py b/src/bcbench/results/metrics.py index 9c5571889..5f08a3b22 100644 --- a/src/bcbench/results/metrics.py +++ b/src/bcbench/results/metrics.py @@ -27,6 +27,28 @@ def bootstrap_ci(values: list[float] | np.ndarray, n_bootstrap: int = 10000, ci_ } +def precision_recall(matched_count: int, generated_count: int, expected_count: int) -> tuple[float, float]: + """Precision and recall for a set-matching task. + + An empty generated or expected set yields perfect precision or recall respectively, + so a model that correctly produces no output is not penalized. + """ + precision = matched_count / generated_count if generated_count else 1.0 + recall = matched_count / expected_count if expected_count else 1.0 + return precision, recall + + +def f_beta_score(precision: float, recall: float, beta: float = 1.0) -> float: + if precision + recall == 0: + return 0.0 + beta_sq = beta**2 + return (1 + beta_sq) * precision * recall / (beta_sq * precision + recall) + + +def f1_score(precision: float, recall: float) -> float: + return f_beta_score(precision, recall, beta=1.0) + + def pass_hat_k(num_trials: int, success_count: int, k: int) -> float: """Measures the probability that all k trials succeed diff --git a/src/bcbench/results/summary.py b/src/bcbench/results/summary.py index 44c76fb51..38b09a977 100644 --- a/src/bcbench/results/summary.py +++ b/src/bcbench/results/summary.py @@ -5,7 +5,7 @@ from collections.abc import Sequence from datetime import date from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field @@ -13,6 +13,9 @@ from bcbench.results.base import BaseEvaluationResult from bcbench.types import EvaluationCategory, ExperimentConfiguration +if TYPE_CHECKING: + from rich.console import Console + logger = get_logger(__name__) @@ -62,6 +65,24 @@ def display_summary(self) -> dict[str, int | float]: Subclasses must override. Keys become display labels (underscores replaced with spaces and title-cased). Values are shown as-is. """ + def render_github_metrics_markdown(self) -> str: + """Markdown for the metrics block between the run header and the Detailed Results table. + + Default renders ``display_summary()`` as a bullet list under "## Result Summary". + Subclasses can override for richer layouts (tables, grouped sections, explanations). + """ + display_metrics = self.display_summary() + lines = "\n".join(f"- {key.replace('_', ' ').title()}: {value}" for key, value in display_metrics.items()) + return f"## Result Summary\n{lines}" + + def render_console_metrics(self, console: "Console") -> None: + """Print the metrics block to a Rich console between the run header and the Detailed Results table. + + Default renders ``display_summary()`` as a bullet list. Subclasses can override for grouped tables and explanations. + """ + for key, value in self.display_summary().items(): + console.print(f"{key.replace('_', ' ').title()}: [bold]{value}[/bold]") + @classmethod def from_results(cls, results: Sequence[BaseEvaluationResult], run_id: str) -> "EvaluationResultSummary": """Create a summary from a list of per-instance results. diff --git a/src/bcbench/types.py b/src/bcbench/types.py index 7f8c03e19..7decc932e 100644 --- a/src/bcbench/types.py +++ b/src/bcbench/types.py @@ -129,7 +129,7 @@ def get_target_dir(self, repo_path: Path) -> Path: class EvaluationCategory(StrEnum): BUG_FIX = "bug-fix" TEST_GENERATION = "test-generation" - # CODE_REVIEW = "code-review" + CODE_REVIEW = "code-review" # EVENT_REQUEST = "event-request" @property @@ -141,24 +141,29 @@ def dataset_path(self) -> Path: return get_config().paths.dataset_dir / "bcbench.jsonl" case EvaluationCategory.TEST_GENERATION: return get_config().paths.dataset_dir / "bcbench.jsonl" + case EvaluationCategory.CODE_REVIEW: + return get_config().paths.dataset_dir / "codereview.jsonl" raise ValueError(f"Unknown evaluation category: {self}") @property def entry_class(self) -> type[BaseDatasetEntry]: - from bcbench.dataset import BugFixEntry, TestGenEntry + from bcbench.dataset import BugFixEntry, CodeReviewEntry, TestGenEntry match self: case EvaluationCategory.BUG_FIX: return BugFixEntry case EvaluationCategory.TEST_GENERATION: return TestGenEntry + case EvaluationCategory.CODE_REVIEW: + return CodeReviewEntry raise ValueError(f"Unknown evaluation category: {self}") @property def result_class(self) -> type[BaseEvaluationResult]: from bcbench.results.bugfix import BugFixResult + from bcbench.results.codereview import CodeReviewResult from bcbench.results.testgeneration import TestGenerationResult match self: @@ -166,12 +171,15 @@ def result_class(self) -> type[BaseEvaluationResult]: return BugFixResult case EvaluationCategory.TEST_GENERATION: return TestGenerationResult + case EvaluationCategory.CODE_REVIEW: + return CodeReviewResult raise ValueError(f"Unknown evaluation category: {self}") @property def summary_class(self) -> type[EvaluationResultSummary]: """Returns the EvaluationResultSummary subclass for this category.""" + from bcbench.results.codereview import CodeReviewResultSummary from bcbench.results.summary import ExecutionBasedEvaluationResultSummary match self: @@ -179,31 +187,37 @@ def summary_class(self) -> type[EvaluationResultSummary]: return ExecutionBasedEvaluationResultSummary case EvaluationCategory.TEST_GENERATION: return ExecutionBasedEvaluationResultSummary + case EvaluationCategory.CODE_REVIEW: + return CodeReviewResultSummary raise ValueError(f"Unknown evaluation category: {self}") @property def aggregate_class(self) -> type[LeaderboardAggregate]: """Returns the LeaderboardAggregate subclass for this category, used for aggregating multiple runs on the same benchmark/model/agent combination.""" - from bcbench.results.leaderboard import ExecutionBasedLeaderboardAggregate + from bcbench.results.leaderboard import CodeReviewLeaderboardAggregate, ExecutionBasedLeaderboardAggregate match self: case EvaluationCategory.BUG_FIX: return ExecutionBasedLeaderboardAggregate case EvaluationCategory.TEST_GENERATION: return ExecutionBasedLeaderboardAggregate + case EvaluationCategory.CODE_REVIEW: + return CodeReviewLeaderboardAggregate raise ValueError(f"Unknown evaluation category: {self}") @property def pipeline(self) -> EvaluationPipeline: - from bcbench.evaluate import BugFixPipeline, TestGenerationPipeline + from bcbench.evaluate import BugFixPipeline, CodeReviewPipeline, TestGenerationPipeline match self: case EvaluationCategory.BUG_FIX: return BugFixPipeline() case EvaluationCategory.TEST_GENERATION: return TestGenerationPipeline() + case EvaluationCategory.CODE_REVIEW: + return CodeReviewPipeline() raise ValueError(f"Unknown evaluation category: {self}") @@ -219,6 +233,8 @@ def evaluators(self) -> list[str]: return ["resolution_rate", "build_rate"] case EvaluationCategory.TEST_GENERATION: return ["resolution_rate", "build_rate", "pre_patch_failed_rate", "post_patch_passed_rate"] + case EvaluationCategory.CODE_REVIEW: + return ["precision_score", "recall_score", "f1_score", "valid_review_output"] raise ValueError(f"Unknown evaluation category: {self}") @@ -228,6 +244,8 @@ def core_score(self) -> str: match self: case EvaluationCategory.BUG_FIX | EvaluationCategory.TEST_GENERATION: return "ResolutionRate" + case EvaluationCategory.CODE_REVIEW: + return "F1Score" raise ValueError(f"Unknown evaluation category: {self}") @@ -237,6 +255,8 @@ def requires_container(self) -> bool: match self: case EvaluationCategory.BUG_FIX | EvaluationCategory.TEST_GENERATION: return True + case EvaluationCategory.CODE_REVIEW: + return False raise ValueError(f"Unknown evaluation category: {self}") @@ -249,6 +269,8 @@ def runner(self) -> str: match self: case EvaluationCategory.BUG_FIX | EvaluationCategory.TEST_GENERATION: return "GitHub-BCBench" + case EvaluationCategory.CODE_REVIEW: + return "ubuntu-latest" raise ValueError(f"Unknown evaluation category: {self}") diff --git a/tests/conftest.py b/tests/conftest.py index ce2dfaf34..04cdd135e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,13 +8,17 @@ import json from collections.abc import Generator from pathlib import Path +from typing import cast from unittest.mock import patch import pytest -from bcbench.dataset import BugFixEntry, TestEntry +from bcbench.dataset import BaseDatasetEntry, BugFixEntry, TestEntry +from bcbench.dataset.codereview import CodeReviewEntry, ReviewComment, Severity from bcbench.dataset.dataset_entry import _BugFixTestGenBase +from bcbench.evaluate.review_parsing import parse_review_output from bcbench.results.bugfix import BugFixResult +from bcbench.results.codereview import CodeReviewResult from bcbench.results.testgeneration import TestGenerationResult from bcbench.types import AgentMetrics, ContainerConfig, EvaluationCategory, EvaluationContext @@ -72,21 +76,20 @@ def create_dataset_entry( ) -def create_evaluation_context( +def create_evaluation_context[EntryT: BaseDatasetEntry]( tmp_path: Path, - entry: BugFixEntry | None = None, + entry: EntryT | None = None, agent_name: str = "test-agent", model: str = "test-model", category: EvaluationCategory = EvaluationCategory.BUG_FIX, container_name: str = "test-container", password: str = "test-password", username: str = "test-user", -) -> EvaluationContext[BugFixEntry]: - if entry is None: - entry = create_dataset_entry() +) -> EvaluationContext[EntryT]: + resolved_entry = entry if entry is not None else cast(EntryT, create_dataset_entry()) - return EvaluationContext[BugFixEntry]( - entry=entry, + return EvaluationContext[EntryT]( + entry=resolved_entry, repo_path=tmp_path / "repo", result_dir=tmp_path / "results", container=ContainerConfig(name=container_name, username=username, password=password), @@ -150,6 +153,77 @@ def create_testgen_result( ) +def create_codereview_entry( + instance_id: str = VALID_INSTANCE_ID, + repo: str = VALID_REPO, + base_commit: str = VALID_BASE_COMMIT, + environment_setup_version: str = VALID_ENVIRONMENT_VERSION, + project_paths: list[str] | None = None, + patch: str = VALID_PATCH, + created_at: str = VALID_CREATED_AT, + domain: str | None = None, + expected_comments: list[ReviewComment] | None = None, +) -> CodeReviewEntry: + if project_paths is None: + project_paths = VALID_PROJECT_PATHS.copy() + if expected_comments is None: + expected_comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix this", severity=Severity.MEDIUM), + ReviewComment(file="src/app.al", line_start=20, body="Consider that", severity=Severity.LOW), + ] + + return CodeReviewEntry( + instance_id=instance_id, + repo=repo, + base_commit=base_commit, + environment_setup_version=environment_setup_version, + project_paths=project_paths, + patch=patch, + created_at=created_at, + domain=domain, + expected_comments=expected_comments, + ) + + +def create_codereview_result( + instance_id: str = VALID_INSTANCE_ID, + model: str = "gpt-4o", + agent_name: str = "copilot-cli", + output: str = '[{"file": "test.al", "line_start": 5, "body": "Good catch"}]', + expected_comments: list[ReviewComment] | None = None, + line_tolerance: int = 5, + metrics: AgentMetrics | None = None, + domain: str | None = None, + metadata_area: str | None = None, +) -> CodeReviewResult: + if expected_comments is None: + expected_comments = [] + entry = create_codereview_entry(instance_id=instance_id, expected_comments=expected_comments, domain=domain) + if metadata_area: + entry = entry.model_copy(update={"metadata": entry.metadata.model_copy(update={"area": metadata_area})}) + context = EvaluationContext[CodeReviewEntry]( + entry=entry, + repo_path=Path(), + result_dir=Path(), + container=ContainerConfig(name="t", username="t", password="t"), + agent_name=agent_name, + model=model, + category=EvaluationCategory.CODE_REVIEW, + ) + context.metrics = metrics + + generated_comments = parse_review_output(output) + if generated_comments is None: + return CodeReviewResult.create_invalid(context, output, expected_comments) + return CodeReviewResult.create( + context, + output=output, + expected_comments=expected_comments, + generated_comments=generated_comments, + line_tolerance=line_tolerance, + ) + + def create_dataset_file(tmp_path: Path, entries: list[BugFixEntry] | None = None) -> Path: if entries is None: entries = [create_dataset_entry()] @@ -192,7 +266,7 @@ def sample_dataset_entry() -> BugFixEntry: @pytest.fixture -def sample_evaluation_context(tmp_path: Path) -> EvaluationContext[BugFixEntry]: +def sample_evaluation_context(tmp_path: Path) -> EvaluationContext[BaseDatasetEntry]: return create_evaluation_context(tmp_path) diff --git a/tests/test_category_command.py b/tests/test_category_command.py index ab6e89bfe..6961ed07e 100644 --- a/tests/test_category_command.py +++ b/tests/test_category_command.py @@ -68,3 +68,15 @@ def test_runtime_config_supports_every_category(tmp_path, monkeypatch): contents = output_file.read_text(encoding="utf-8") assert f"runner={category.runner}" in contents assert f"requires-container={str(category.requires_container).lower()}" in contents + + +def test_runtime_config_marks_code_review_as_containerless_on_hosted_runner(tmp_path, monkeypatch): + output_file = tmp_path / "gh_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + result = runner.invoke(app, ["category", "runtime-config", "--category", "code-review"]) + + assert result.exit_code == 0 + contents = output_file.read_text(encoding="utf-8") + assert "requires-container=false" in contents diff --git a/tests/test_codereview.py b/tests/test_codereview.py new file mode 100644 index 000000000..e70b11373 --- /dev/null +++ b/tests/test_codereview.py @@ -0,0 +1,573 @@ +import json +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest + +from bcbench.dataset import CodeReviewEntry +from bcbench.dataset.codereview import ReviewComment, Severity +from bcbench.evaluate.codereview import CodeReviewPipeline +from bcbench.exceptions import PatchApplicationError +from bcbench.results.base import BaseEvaluationResult +from bcbench.results.codereview import CodeReviewResult, CodeReviewResultSummary +from bcbench.types import EvaluationCategory +from tests.conftest import create_codereview_entry, create_codereview_result, create_evaluation_context + + +class TestSeverity: + def test_canonical_values_parse_directly(self): + assert Severity.from_input("Critical") is Severity.CRITICAL + assert Severity.from_input(" high ") is Severity.HIGH + + def test_aliases_map_to_canonical_severities(self): + assert Severity.from_input("error") is Severity.HIGH + assert Severity.from_input("warning") is Severity.MEDIUM + assert Severity.from_input("suggestion") is Severity.LOW + assert Severity.from_input("info") is Severity.LOW + + def test_unknown_severity_defaults_to_medium(self): + assert Severity.from_input("bogus") is Severity.MEDIUM + + def test_levels_are_strictly_ordered(self): + assert Severity.CRITICAL.level > Severity.HIGH.level > Severity.MEDIUM.level > Severity.LOW.level + + def test_review_comment_normalizes_severity_on_construction(self): + comment = ReviewComment.model_validate({"file": "src/app.al", "line_start": 1, "body": "x", "severity": "warning"}) + assert comment.severity is Severity.MEDIUM + + +class TestCodeReviewEntry: + def test_get_task_returns_patch(self): + entry = create_codereview_entry(patch="diff --git a/test.al b/test.al\n+new line") + assert entry.get_task() == "diff --git a/test.al b/test.al\n+new line" + + def test_get_expected_output_formats_comments(self): + comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix this", severity=Severity.MEDIUM), + ReviewComment(file="src/app.al", line_start=20, body="Consider that", severity=Severity.LOW), + ] + entry = create_codereview_entry(expected_comments=comments) + output = entry.get_expected_output() + assert "[medium] src/app.al:10: Fix this" in output + assert "[low] src/app.al:20: Consider that" in output + + def test_entry_does_not_require_test_fields(self): + entry = create_codereview_entry() + assert not hasattr(entry, "fail_to_pass") + assert not hasattr(entry, "test_patch") + + def test_load_from_jsonl(self, tmp_path): + entry = create_codereview_entry() + dataset_path = tmp_path / "codereview.jsonl" + entry.save_to_file(dataset_path) + + loaded = CodeReviewEntry.load(dataset_path) + assert len(loaded) == 1 + assert loaded[0].instance_id == entry.instance_id + assert len(loaded[0].expected_comments) == len(entry.expected_comments) + + def test_empty_expected_comments_is_valid(self): + entry = create_codereview_entry(expected_comments=[]) + assert entry.expected_comments == [] + assert entry.get_expected_output() == "" + + +class TestCodeReviewResult: + def test_create_result(self): + result = create_codereview_result() + assert result.category == EvaluationCategory.CODE_REVIEW + assert len(result.generated_comments) == 1 + + def test_round_trip_serialization(self, tmp_path): + output = json.dumps([{"file": "test.al", "line_start": 5, "body": "Good catch"}]) + original = create_codereview_result( + instance_id="test__rt-1", + output=output, + ) + + original.save(tmp_path, "test.jsonl") + + with open(tmp_path / "test.jsonl") as f: + data = json.loads(f.readline()) + + loaded = BaseEvaluationResult.from_json(data) + assert isinstance(loaded, CodeReviewResult) + assert loaded.category == EvaluationCategory.CODE_REVIEW + assert len(loaded.generated_comments) == 1 + + def test_category_loads_from_string(self): + payload = { + "instance_id": "test__instance", + "project": "app", + "model": "gpt-4o", + "agent_name": "copilot-cli", + "category": "code-review", + "output": "", + "line_tolerance": 5, + } + + result = BaseEvaluationResult.from_json(payload) + assert result.category == EvaluationCategory.CODE_REVIEW + assert isinstance(result, CodeReviewResult) + + def test_parses_skill_style_output_schema(self): + output = json.dumps( + { + "findings": [ + { + "filePath": "src/app.al", + "lineNumber": 12, + "severity": "High", + "issue": "Potential SQL injection risk", + "recommendation": "Use parameterized queries", + "suggestedCode": "DoSafeThing();", + } + ] + } + ) + + result = create_codereview_result(output=output) + + assert result.valid_review_output is True + assert len(result.generated_comments) == 1 + assert result.generated_comments[0].file == "src/app.al" + assert result.generated_comments[0].line_start == 12 + assert result.generated_comments[0].body == "Potential SQL injection risk" + + def test_parses_single_finding_object_output(self): + output = json.dumps( + { + "filePath": "src/app.al", + "lineNumber": 42, + "severity": "Medium", + "issue": "Potential issue in single-object response", + "recommendation": "Fix it", + "suggestedCode": "", + } + ) + + result = create_codereview_result(output=output) + + assert result.valid_review_output is True + assert len(result.generated_comments) == 1 + assert result.generated_comments[0].file == "src/app.al" + assert result.generated_comments[0].line_start == 42 + + def test_metrics_match_expected_comments_with_tolerance(self): + expected_comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix null check", severity=Severity.MEDIUM), + ReviewComment(file="src/app.al", line_start=40, body="Validate input", severity=Severity.HIGH), + ] + generated_output = json.dumps( + [ + { + "file": "src/app.al", + "line_start": 12, + "body": "Potential null reference", + "severity": "warning", + }, + { + "file": "src/other.al", + "line_start": 99, + "body": "Unrelated finding", + "severity": "low", + }, + ] + ) + + result = create_codereview_result(output=generated_output, expected_comments=expected_comments, line_tolerance=5) + + assert result.matched_comment_count == 1 + assert result.missed_comment_count == 1 + assert result.incorrect_comment_count == 1 + assert result.precision == 0.5 + assert result.recall == 0.5 + assert result.f1 == 0.5 + assert result.severity_mae == 0.0 + + def test_severity_aliases_normalize_to_skill_levels(self): + result = create_codereview_result( + output=json.dumps( + [ + {"file": "src/app.al", "line_start": 1, "body": "a", "severity": "warning"}, + {"file": "src/app.al", "line_start": 2, "body": "b", "severity": "suggestion"}, + {"file": "src/app.al", "line_start": 3, "body": "c", "severity": "error"}, + ] + ) + ) + + severities = [comment.severity for comment in result.generated_comments] + assert severities == ["medium", "low", "high"] + + def test_display_row_splits_comment_counts(self): + expected_comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix null check", severity=Severity.MEDIUM), + ReviewComment(file="src/app.al", line_start=40, body="Validate input", severity=Severity.HIGH), + ] + generated_output = json.dumps( + [ + { + "file": "src/app.al", + "line_start": 12, + "body": "Potential null reference", + "severity": "warning", + }, + { + "file": "src/other.al", + "line_start": 99, + "body": "Unrelated finding", + "severity": "low", + }, + ] + ) + + result = create_codereview_result(output=generated_output, expected_comments=expected_comments, line_tolerance=5) + + assert result.display_row == { + "Domain": "unknown", + "Generated": "2", + "Matched": "1", + "Expected": "2", + "Precision": "0.50", + "Recall": "0.50", + "F1": "0.50", + } + + def test_result_uses_explicit_domain_from_entry(self): + result = create_codereview_result(output="[]", expected_comments=[], domain="performance") + + assert result.domain == "performance" + + def test_result_falls_back_to_metadata_area_domain(self): + result = create_codereview_result(output="not-json", expected_comments=[], metadata_area="security") + + assert result.domain == "security" + + def test_result_stamps_domain_on_generated_comments(self): + result = create_codereview_result( + output='[{"file": "src/app.al", "line_start": 5, "body": "Issue", "severity": "medium"}]', + expected_comments=[], + domain="performance", + ) + + assert len(result.generated_comments) == 1 + assert result.generated_comments[0].domain == "performance" + + def test_result_keeps_generated_comments_with_mismatched_domain(self): + result = create_codereview_result( + output='[{"file": "src/app.al", "line_start": 5, "domain": "security", "body": "Issue", "severity": "medium"}]', + expected_comments=[], + domain="performance", + ) + + assert len(result.generated_comments) == 1 + assert result.generated_comments[0].domain == "security" + + def test_result_preserves_explicit_generated_comment_domain_when_matching(self): + result = create_codereview_result( + output='[{"file": "src/app.al", "line_start": 5, "domain": "performance", "body": "Issue", "severity": "medium"}]', + expected_comments=[], + domain="performance", + ) + + assert len(result.generated_comments) == 1 + assert result.generated_comments[0].domain == "performance" + + def test_sort_key_groups_by_domain_then_natural_instance_id(self): + unsorted_results = [ + create_codereview_result(instance_id="repo__feature-a-10", domain="performance"), + create_codereview_result(instance_id="repo__feature-a-2", domain="performance"), + create_codereview_result(instance_id="repo__feature-a-1", domain="performance"), + create_codereview_result(instance_id="repo__feature-b-11", domain="upgrade"), + create_codereview_result(instance_id="repo__feature-b-3", domain="upgrade"), + create_codereview_result(instance_id="repo__feature-c-2", domain="upgrade"), + ] + + ordered_ids = [r.instance_id for r in sorted(unsorted_results, key=lambda r: r.sort_key)] + + assert ordered_ids == [ + "repo__feature-a-1", + "repo__feature-a-2", + "repo__feature-a-10", + "repo__feature-b-3", + "repo__feature-b-11", + "repo__feature-c-2", + ] + + +class TestCodeReviewSummary: + def test_summary_aggregates_precision_recall_and_f1(self): + expected_comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix null check", severity=Severity.MEDIUM), + ReviewComment(file="src/app.al", line_start=30, body="Fix auth check", severity=Severity.HIGH), + ] + + result_1 = create_codereview_result( + instance_id="test__a-1", + output=json.dumps( + [ + {"file": "src/app.al", "line_start": 10, "body": "Issue A", "severity": "warning"}, + {"file": "src/other.al", "line_start": 80, "body": "Issue B", "severity": "low"}, + ] + ), + expected_comments=expected_comments, + ) + result_2 = create_codereview_result( + instance_id="test__a-2", + output="[]", + expected_comments=expected_comments, + ) + + summary = CodeReviewResultSummary.from_results([result_1, result_2], run_id="run-1") + + assert summary.generated_comment_count == 2 + assert summary.expected_comment_count == 4 + assert summary.matched_comment_count == 1 + assert summary.incorrect_comment_count == 1 + assert summary.missed_comment_count == 3 + assert summary.precision == 0.5 + assert summary.recall == 0.25 + assert summary.f1 == 0.333 + + def test_render_github_metrics_markdown_has_grouped_sections(self): + expected_comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix null check", severity=Severity.MEDIUM), + ] + result = create_codereview_result( + instance_id="test__render-1", + output=json.dumps([{"file": "src/app.al", "line_start": 10, "body": "Issue A", "severity": "warning"}]), + expected_comments=expected_comments, + ) + + summary = CodeReviewResultSummary.from_results([result], run_id="run-1") + markdown = summary.render_github_metrics_markdown() + + # Section headers replace the old flat bullet list. + assert "## Comment counts" in markdown + assert "## Micro metrics (volume-weighted across all comments)" in markdown + assert "## Macro metrics (averaged per task)" in markdown + assert "## Quality" in markdown + assert "## Result Summary" not in markdown + + # Percent units applied to rate-style metrics. + assert "100.0%" in markdown + + # Beta renders as the Greek symbol, not the LaTeX escape sequence. + assert "β" in markdown + assert "\\beta" not in markdown + + # Collapsible explanations are present. + assert "
" in markdown + assert "How to read these metrics" in markdown + assert "LLM judge" in markdown + assert "F_β = (1 + β²)" in markdown + + def test_render_console_metrics_uses_grouped_rich_tables(self): + from io import StringIO + + from rich.console import Console + + expected_comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix null check", severity=Severity.MEDIUM), + ] + result = create_codereview_result( + instance_id="test__render-1", + output=json.dumps([{"file": "src/app.al", "line_start": 10, "body": "Issue A", "severity": "warning"}]), + expected_comments=expected_comments, + ) + + summary = CodeReviewResultSummary.from_results([result], run_id="run-1") + buffer = StringIO() + # Force a wide terminal so Rich does not truncate column headers in the captured output. + console = Console(file=buffer, force_terminal=False, width=200) + summary.render_console_metrics(console) + output = buffer.getvalue() + + # Grouped section titles appear instead of the old flat key/value bullets. + assert "Comment counts" in output + assert "Micro metrics" in output + assert "Macro metrics" in output + assert "Quality" in output + + # Percent units on rate-style metrics; Greek beta, not LaTeX. + assert "100.0%" in output + assert "β" in output + assert "\\beta" not in output + + # Explanations panel rendered. + assert "How to read these metrics" in output + assert "LLM judge" in output + + +class TestCodeReviewLeaderboardAggregate: + def _make_summary(self, expected_comments, output: str, run_id: str) -> CodeReviewResultSummary: + result = create_codereview_result( + instance_id="test__a-1", + output=output, + expected_comments=expected_comments, + ) + return CodeReviewResultSummary.from_results([result], run_id=run_id) + + def test_aggregate_uses_f1_as_average_and_has_no_pass_hat_5(self): + from bcbench.results.leaderboard import CodeReviewLeaderboardAggregate, LeaderboardAggregate + + expected_comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix null check", severity=Severity.MEDIUM), + ] + output = json.dumps([{"file": "src/app.al", "line_start": 10, "body": "Issue A", "severity": "warning"}]) + + run = self._make_summary(expected_comments, output, run_id="run-1") + + agg = LeaderboardAggregate.from_runs([run]) + + assert isinstance(agg, CodeReviewLeaderboardAggregate) + assert agg.category == EvaluationCategory.CODE_REVIEW + assert agg.num_runs == 1 + assert agg.f1 == run.f1 + assert not hasattr(agg, "pass_hat_5") + + def test_aggregate_serialization_excludes_pass_hat_5(self): + from bcbench.results.leaderboard import Leaderboard, LeaderboardAggregate + + expected_comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix null check", severity=Severity.MEDIUM), + ] + output = json.dumps([{"file": "src/app.al", "line_start": 10, "body": "Issue A", "severity": "warning"}]) + + run = self._make_summary(expected_comments, output, run_id="run-1") + agg = LeaderboardAggregate.from_runs([run]) + + leaderboard = Leaderboard(runs=[run], aggregate=[agg]) + data = leaderboard.to_dict() + + assert "pass_hat_5" not in data["aggregate"][0] + assert data["aggregate"][0]["f1"] == run.f1 + + def test_round_trip_preserves_codereview_subclasses(self): + from bcbench.results.codereview import CodeReviewResultSummary as CRSummary + from bcbench.results.leaderboard import CodeReviewLeaderboardAggregate, Leaderboard, LeaderboardAggregate + + expected_comments = [ + ReviewComment(file="src/app.al", line_start=10, body="Fix null check", severity=Severity.MEDIUM), + ] + output = json.dumps([{"file": "src/app.al", "line_start": 10, "body": "Issue A", "severity": "warning"}]) + + run = self._make_summary(expected_comments, output, run_id="run-1") + agg = LeaderboardAggregate.from_runs([run]) + leaderboard = Leaderboard(runs=[run], aggregate=[agg]) + + restored = Leaderboard.model_validate(leaderboard.to_dict()) + + assert isinstance(restored.runs[0], CRSummary) + assert isinstance(restored.aggregate[0], CodeReviewLeaderboardAggregate) + + +class TestCodeReviewPipeline: + def test_pipeline_instantiates(self): + pipeline = EvaluationCategory.CODE_REVIEW.pipeline + assert pipeline is not None + + def test_entry_class_is_codereview(self): + assert EvaluationCategory.CODE_REVIEW.entry_class == CodeReviewEntry + + def test_context_does_not_require_container(self, tmp_path): + entry = create_codereview_entry() + context = create_evaluation_context(tmp_path, entry=entry, category=EvaluationCategory.CODE_REVIEW) + # Container is passed but pipeline doesn't use it — this is fine + assert context.category == EvaluationCategory.CODE_REVIEW + + def test_setup_workspace_applies_entry_patch(self, tmp_path): + entry = create_codereview_entry(patch="diff --git a/a.al b/a.al\n+new line\n") + pipeline = CodeReviewPipeline() + + with ( + patch("bcbench.evaluate.codereview.setup_repo_prebuild") as mock_setup, + patch("bcbench.evaluate.codereview.apply_patch") as mock_apply, + ): + pipeline.setup_workspace(entry, Path(tmp_path)) + + mock_setup.assert_called_once() + mock_apply.assert_called_once() + + def test_setup_workspace_marks_new_files_as_intent_to_add(self, tmp_path): + entry = create_codereview_entry( + patch=( + "diff --git a/src/NewObject.Codeunit.al b/src/NewObject.Codeunit.al\n" + "new file mode 100644\n" + "--- /dev/null\n" + "+++ b/src/NewObject.Codeunit.al\n" + "@@ -0,0 +1,3 @@\n" + "+codeunit 50100 NewObject\n" + "+{\n" + "+}\n" + ) + ) + pipeline = CodeReviewPipeline() + subprocess.run(["git", "init"], cwd=tmp_path, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + subprocess.run( + ["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "init"], + cwd=tmp_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + + with patch("bcbench.evaluate.codereview.setup_repo_prebuild") as mock_setup: + pipeline.setup_workspace(entry, Path(tmp_path)) + + mock_setup.assert_called_once() + new_file = Path(tmp_path) / "src" / "NewObject.Codeunit.al" + assert new_file.exists() + assert "codeunit 50100 NewObject" in new_file.read_text(encoding="utf-8") + diff = subprocess.run( + ["git", "diff", "HEAD"], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ).stdout + assert "src/NewObject.Codeunit.al" in diff + + def test_setup_workspace_materializes_simplified_patch_when_git_apply_fails(self, tmp_path): + entry = create_codereview_entry(patch="--- src/NewObject.Codeunit.al\n+++ src/NewObject.Codeunit.al\n+codeunit 50100 NewObject\n+{\n+}\n") + pipeline = CodeReviewPipeline() + + subprocess.run(["git", "init"], cwd=tmp_path, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + subprocess.run( + ["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "init"], + cwd=tmp_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + + with ( + patch("bcbench.evaluate.codereview.setup_repo_prebuild") as mock_setup, + patch( + "bcbench.evaluate.codereview.apply_patch", + side_effect=PatchApplicationError("test", "error: No valid patches in input"), + ), + ): + pipeline.setup_workspace(entry, Path(tmp_path)) + + mock_setup.assert_called_once() + materialized_file = Path(tmp_path) / "src" / "NewObject.Codeunit.al" + assert materialized_file.exists() + assert "codeunit 50100 NewObject" in materialized_file.read_text(encoding="utf-8") + diff = subprocess.run( + ["git", "diff", "HEAD"], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ).stdout + assert "src/NewObject.Codeunit.al" in diff + + def test_evaluate_raises_when_no_review_generated(self, tmp_path): + entry = create_codereview_entry() + context = create_evaluation_context(tmp_path, entry=entry, category=EvaluationCategory.CODE_REVIEW) + pipeline = CodeReviewPipeline() + + with pytest.raises(RuntimeError, match="No review generated"): + pipeline.evaluate(context) diff --git a/tests/test_copilot_metrics_parsing.py b/tests/test_copilot_metrics_parsing.py index 09588005c..76c489fae 100644 --- a/tests/test_copilot_metrics_parsing.py +++ b/tests/test_copilot_metrics_parsing.py @@ -239,6 +239,47 @@ def test_parse_metrics_new_format_tokens_with_m(): assert result.completion_tokens == 11600 +def test_parse_metrics_v1_0_61_format_full(): + output_lines = [ + "Changes +23 -0\n", + "AI Credits 58.4 (1m 14s)\n", + "Tokens ↑ 413.9k (368.1k cached) • ↓ 4.5k (500 reasoning)\n", + ] + + result = parse_metrics(output_lines) + + assert result is not None + assert result.execution_time == 74.0 + assert result.prompt_tokens == 413900 + assert result.completion_tokens == 4500 + + +def test_parse_metrics_v1_0_61_ai_credits_seconds_only(): + output_lines = [ + "AI Credits 12.3 (45s)\n", + "Tokens ↑ 125.5k (10k cached) • ↓ 3.6k\n", + ] + + result = parse_metrics(output_lines) + + assert result is not None + assert result.execution_time == 45.0 + assert result.prompt_tokens == 125500 + assert result.completion_tokens == 3600 + + +def test_parse_metrics_v1_0_61_tokens_with_only_cached_annotation(): + output_lines = [ + "Tokens ↑ 200k (180k cached) • ↓ 5k\n", + ] + + result = parse_metrics(output_lines) + + assert result is not None + assert result.prompt_tokens == 200000 + assert result.completion_tokens == 5000 + + def test_parse_turn_count_from_log(): log_content = """ 2026-01-20T08:55:10.767Z [INFO] --- Start of group: Sending request to the AI model --- diff --git a/tests/test_evaluation_summary.py b/tests/test_evaluation_summary.py index 5f0954ab3..6ebc92310 100644 --- a/tests/test_evaluation_summary.py +++ b/tests/test_evaluation_summary.py @@ -733,6 +733,84 @@ def test_aggregate_allows_same_benchmark_versions(self): assert agg.benchmark_version == "0.1.0" + def test_aggregate_rejects_runs_from_different_combinations(self): + from bcbench.results.leaderboard import LeaderboardAggregate + + run1 = ExecutionBasedEvaluationResultSummary( + total=3, + resolved=2, + failed=1, + build=3, + percentage=66.7, + instance_results={"test__1": True, "test__2": True, "test__3": False}, + date=date.today(), + model="gpt-4o", + agent_name="copilot", + category=EvaluationCategory.BUG_FIX, + average_duration=100.0, + average_prompt_tokens=1000.0, + average_completion_tokens=500.0, + benchmark_version="0.1.0", + ) + run2 = ExecutionBasedEvaluationResultSummary( + total=3, + resolved=1, + failed=2, + build=3, + percentage=33.3, + instance_results={"test__1": False, "test__2": True, "test__3": False}, + date=date.today(), + model="claude-3", # Different model + agent_name="copilot", + category=EvaluationCategory.BUG_FIX, + average_duration=100.0, + average_prompt_tokens=1000.0, + average_completion_tokens=500.0, + benchmark_version="0.1.0", + ) + + with pytest.raises(ValueError, match="different combinations"): + LeaderboardAggregate.from_runs([run1, run2]) + + def test_aggregate_rejects_runs_with_different_totals(self): + from bcbench.results.leaderboard import LeaderboardAggregate + + run1 = ExecutionBasedEvaluationResultSummary( + total=3, + resolved=2, + failed=1, + build=3, + percentage=66.7, + instance_results={"test__1": True, "test__2": True, "test__3": False}, + date=date.today(), + model="gpt-4o", + agent_name="copilot", + category=EvaluationCategory.BUG_FIX, + average_duration=100.0, + average_prompt_tokens=1000.0, + average_completion_tokens=500.0, + benchmark_version="0.1.0", + ) + run2 = ExecutionBasedEvaluationResultSummary( + total=2, # Different total + resolved=1, + failed=1, + build=2, + percentage=50.0, + instance_results={"test__1": False, "test__2": True}, + date=date.today(), + model="gpt-4o", + agent_name="copilot", + category=EvaluationCategory.BUG_FIX, + average_duration=100.0, + average_prompt_tokens=1000.0, + average_completion_tokens=500.0, + benchmark_version="0.1.0", + ) + + with pytest.raises(ValueError, match="different totals"): + LeaderboardAggregate.from_runs([run1, run2]) + def test_load_empty_leaderboard_file(self, tmp_path): from bcbench.results.leaderboard import Leaderboard diff --git a/tests/test_hooks_operations.py b/tests/test_hooks_operations.py index 9dad99396..a0f1174d4 100644 --- a/tests/test_hooks_operations.py +++ b/tests/test_hooks_operations.py @@ -25,6 +25,9 @@ def test_copilot_creates_hooks_json(self, tmp_path: Path): hook = hooks_config["hooks"]["preToolUse"][0] assert hook["type"] == "command" assert "powershell" in hook + assert "bash" in hook + assert "python3" in hook["bash"] + assert "log_tool_usage.py" in hook["bash"] assert "BCBENCH_TOOL_LOG" in hook["env"] assert hook["timeoutSec"] == 5 diff --git a/tests/test_log_tool_usage_hook.py b/tests/test_log_tool_usage_hook.py new file mode 100644 index 000000000..a3fb89609 --- /dev/null +++ b/tests/test_log_tool_usage_hook.py @@ -0,0 +1,73 @@ +import io +import json +import runpy +import sys +from pathlib import Path + +import pytest + +HOOK_SCRIPT = Path(__file__).resolve().parents[1] / "src" / "bcbench" / "agent" / "shared" / "hooks" / "log_tool_usage.py" + + +def _run_hook(stdin_payload: str, monkeypatch: pytest.MonkeyPatch, tool_log: Path) -> None: + monkeypatch.setenv("BCBENCH_TOOL_LOG", str(tool_log)) + monkeypatch.setattr(sys, "stdin", io.StringIO(stdin_payload)) + with pytest.raises(SystemExit) as exc: + runpy.run_path(str(HOOK_SCRIPT), run_name="__main__") + assert exc.value.code == 0 + + +def test_hook_writes_tool_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + tool_log = tmp_path / "tool_usage.jsonl" + _run_hook(json.dumps({"tool_name": "view", "timestamp": "2026-01-01T00:00:00Z"}), monkeypatch, tool_log) + lines = tool_log.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + assert json.loads(lines[0]) == {"tool_name": "view", "timestamp": "2026-01-01T00:00:00Z"} + + +def test_hook_accepts_camelcase_tool_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + tool_log = tmp_path / "tool_usage.jsonl" + _run_hook(json.dumps({"toolName": "edit"}), monkeypatch, tool_log) + assert json.loads(tool_log.read_text(encoding="utf-8").strip())["tool_name"] == "edit" + + +def test_hook_expands_lsp_with_operation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + tool_log = tmp_path / "tool_usage.jsonl" + payload = {"tool_name": "lsp", "toolArgs": {"operation": "findReferences"}} + _run_hook(json.dumps(payload), monkeypatch, tool_log) + assert json.loads(tool_log.read_text(encoding="utf-8").strip())["tool_name"] == "lsp:findReferences" + + +def test_hook_accepts_lsp_args_as_json_string(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + tool_log = tmp_path / "tool_usage.jsonl" + payload = {"tool_name": "lsp", "tool_input": json.dumps({"operation": "hover"})} + _run_hook(json.dumps(payload), monkeypatch, tool_log) + assert json.loads(tool_log.read_text(encoding="utf-8").strip())["tool_name"] == "lsp:hover" + + +def test_hook_lsp_without_operation_keeps_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + tool_log = tmp_path / "tool_usage.jsonl" + _run_hook(json.dumps({"tool_name": "lsp"}), monkeypatch, tool_log) + assert json.loads(tool_log.read_text(encoding="utf-8").strip())["tool_name"] == "lsp" + + +def test_hook_no_tool_name_writes_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + tool_log = tmp_path / "tool_usage.jsonl" + _run_hook(json.dumps({"other_field": "x"}), monkeypatch, tool_log) + assert not tool_log.exists() + + +def test_hook_missing_env_writes_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + tool_log = tmp_path / "tool_usage.jsonl" + monkeypatch.delenv("BCBENCH_TOOL_LOG", raising=False) + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({"tool_name": "view"}))) + with pytest.raises(SystemExit) as exc: + runpy.run_path(str(HOOK_SCRIPT), run_name="__main__") + assert exc.value.code == 0 + assert not tool_log.exists() + + +def test_hook_malformed_stdin_does_not_crash(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + tool_log = tmp_path / "tool_usage.jsonl" + _run_hook("not valid json", monkeypatch, tool_log) + assert not tool_log.exists() diff --git a/tests/test_result_hierarchy.py b/tests/test_result_hierarchy.py index a0daecb2f..133a0a575 100644 --- a/tests/test_result_hierarchy.py +++ b/tests/test_result_hierarchy.py @@ -136,6 +136,32 @@ def test_testgen_display_row_no_flags(self): assert row["Post-Patch Passed"] == "No" +class TestSortKey: + def test_bugfix_sort_key_is_natural_instance_id(self): + unsorted = [ + create_bugfix_result(instance_id="repo__feature-10"), + create_bugfix_result(instance_id="repo__feature-2"), + create_bugfix_result(instance_id="repo__feature-1"), + ] + ordered_ids = [r.instance_id for r in sorted(unsorted, key=lambda r: r.sort_key)] + assert ordered_ids == ["repo__feature-1", "repo__feature-2", "repo__feature-10"] + + def test_zero_padded_ids_sort_correctly(self): + unsorted = [ + create_bugfix_result(instance_id="repo__feature-011"), + create_bugfix_result(instance_id="repo__feature-002"), + create_bugfix_result(instance_id="repo__feature-010"), + create_bugfix_result(instance_id="repo__feature-001"), + ] + ordered_ids = [r.instance_id for r in sorted(unsorted, key=lambda r: r.sort_key)] + assert ordered_ids == [ + "repo__feature-001", + "repo__feature-002", + "repo__feature-010", + "repo__feature-011", + ] + + # --------------------------------------------------------------------------- # from_json dispatch # --------------------------------------------------------------------------- diff --git a/tests/test_type_exhaustiveness.py b/tests/test_type_exhaustiveness.py index 95b9d2457..2b63b9148 100644 --- a/tests/test_type_exhaustiveness.py +++ b/tests/test_type_exhaustiveness.py @@ -1,6 +1,7 @@ from pathlib import Path -from bcbench.dataset import BugFixEntry +from bcbench.dataset import BugFixEntry, CodeReviewEntry +from bcbench.dataset.codereview import ReviewComment, Severity from bcbench.types import AgentType, EvaluationCategory @@ -42,8 +43,20 @@ def test_all_categories_have_aggregate_classes(): def test_all_categories_handled_in_get_expected_output(sample_dataset_entry_with_problem_statement: BugFixEntry): for category in EvaluationCategory: entry_cls = category.entry_class - # Reconstruct entry as the category-specific type so get_expected_output() works - entry = entry_cls.model_validate(sample_dataset_entry_with_problem_statement.model_dump(by_alias=True)) + if entry_cls == CodeReviewEntry: + # CodeReviewEntry has a different schema — test separately + entry = CodeReviewEntry( + instance_id=sample_dataset_entry_with_problem_statement.instance_id, + repo=sample_dataset_entry_with_problem_statement.repo, + base_commit=sample_dataset_entry_with_problem_statement.base_commit, + created_at=sample_dataset_entry_with_problem_statement.created_at, + environment_setup_version=sample_dataset_entry_with_problem_statement.environment_setup_version, + patch=sample_dataset_entry_with_problem_statement.patch, + expected_comments=[ReviewComment(file="test.al", line_start=1, body="Test comment", severity=Severity.MEDIUM)], + ) + else: + # Reconstruct entry as the category-specific type so get_expected_output() works + entry = entry_cls.model_validate(sample_dataset_entry_with_problem_statement.model_dump(by_alias=True)) input_text = entry.get_task() expected_output = entry.get_expected_output() assert isinstance(input_text, str) diff --git a/tools/apply_enrichment.py b/tools/apply_enrichment.py new file mode 100644 index 000000000..e951d26e3 --- /dev/null +++ b/tools/apply_enrichment.py @@ -0,0 +1,111 @@ +"""Apply enrichment designs to dataset/codereview.jsonl. + +Reads tmp/enrichment-design-{security,privacy,style,upgrade}.json files. +For each entry: + - Appends a new-file diff block to the entry's existing patch + - Adds the designed expected_comments to entry.expected_comments + +Writes the modified dataset back in place. Run probe afterwards to verify. + +Usage: + uv run python tools/apply_enrichment.py + uv run python tools/apply_enrichment.py --domain security + uv run python tools/apply_enrichment.py --dry-run +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DATASET = REPO_ROOT / "dataset" / "codereview.jsonl" +DESIGN_DIR = REPO_ROOT / "tmp" +DOMAINS = ("security", "privacy", "style", "upgrade") + + +def build_new_file_diff(file_path: str, content: str) -> str: + """Render a new-file unified diff block.""" + lines = content.split("\n") + if lines and lines[-1] == "": + # remove trailing empty (file ends with newline) — count actual content lines + lines = lines[:-1] + n = len(lines) + diff = [ + f"diff --git a/{file_path} b/{file_path}", + "new file mode 100644", + "--- /dev/null", + f"+++ b/{file_path}", + f"@@ -0,0 +1,{n} @@", + ] + diff.extend(f"+{line}" for line in lines) + return "\n".join(diff) + "\n" + + +def load_designs(domain: str) -> list[dict]: + path = DESIGN_DIR / f"enrichment-design-{domain}.json" + if not path.exists(): + raise FileNotFoundError(f"missing design file: {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def apply(domains: list[str], dry_run: bool = False) -> None: + designs_by_iid: dict[str, dict] = {} + for d in domains: + for design in load_designs(d): + iid = design["instance_id"] + if iid in designs_by_iid: + raise ValueError(f"duplicate design for {iid}") + designs_by_iid[iid] = design + + print(f"Loaded {len(designs_by_iid)} designs from {len(domains)} domain(s)") + + out_lines: list[str] = [] + applied = 0 + with DATASET.open(encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.rstrip("\n") + if not line.strip(): + out_lines.append(raw_line) + continue + entry = json.loads(line) + iid = entry["instance_id"] + if iid not in designs_by_iid: + out_lines.append(raw_line) + continue + + design = designs_by_iid[iid] + existing_patch = entry.get("patch", "") + new_file_diff = build_new_file_diff(design["new_file_path"], design["new_file_content"]) + if not existing_patch.endswith("\n"): + existing_patch = existing_patch + "\n" + entry["patch"] = existing_patch + new_file_diff + + expected = list(entry.get("expected_comments") or []) + expected.extend(design["expected_comments"]) + entry["expected_comments"] = expected + + out_lines.append(json.dumps(entry, ensure_ascii=False) + "\n") + applied += 1 + print(f" [+] {iid} -> {design['new_file_path']} (+{len(design['expected_comments'])} expected)") + + if dry_run: + print(f"\nDRY RUN: would apply {applied} enrichments; dataset NOT modified") + return + + DATASET.write_text("".join(out_lines), encoding="utf-8") + print(f"\nApplied {applied} enrichments. Dataset rewritten.") + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--domain", choices=DOMAINS, action="append", default=None) + p.add_argument("--dry-run", action="store_true") + args = p.parse_args() + domains = args.domain or list(DOMAINS) + apply(domains, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/tools/code-review/run_all_evals.ps1 b/tools/code-review/run_all_evals.ps1 new file mode 100644 index 000000000..2a0e4564c --- /dev/null +++ b/tools/code-review/run_all_evals.ps1 @@ -0,0 +1,66 @@ +# Runs all code-review evaluations in dataset/codereview.jsonl. +# +# Keeps each entry result in its own run folder to avoid overwriting previous results. +# +# Usage: +# pwsh tools/code-review/run_all_evals.ps1 +# pwsh tools/code-review/run_all_evals.ps1 -RepoPath "C:\repos\evals\BCApps\" + +param( + [string]$Dataset = "dataset/codereview.jsonl", + [string]$RepoPath = "C:\repos\evals\BCApps\", + [string]$OutputDir = "evaluation_results", + [string]$RunPrefix = "copilot_codereview" +) + +if (-not (Test-Path $Dataset)) { + throw "Dataset file not found: $Dataset" +} + +$batchId = Get-Date -Format "yyyyMMdd-HHmmss" +$batchOutputDir = Join-Path $OutputDir "$RunPrefix-$batchId" +New-Item -ItemType Directory -Path $batchOutputDir -Force | Out-Null + +$ids = Get-Content $Dataset | ForEach-Object { + try { + ($_.Trim() | ConvertFrom-Json).instance_id + } + catch { + # skip invalid lines + } +} | Where-Object { $_ } + +Write-Host "Found $($ids.Count) code-review entries." +Write-Host "Batch output root: $batchOutputDir" + +$succeeded = 0 +$failed = @() + +foreach ($id in $ids) { + $entryRunId = $id + + Write-Host "`n=== Running evaluation for: $id ===" + uv run bcbench -v evaluate copilot $id --category code-review --repo-path $RepoPath --output-dir $batchOutputDir --run-id $entryRunId + + if ($LASTEXITCODE -ne 0) { + $failed += [PSCustomObject]@{ + InstanceId = $id + ExitCode = $LASTEXITCODE + } + + Write-Host "[ERROR] Evaluation failed for $id (exit code $LASTEXITCODE)" -ForegroundColor Red + } + else { + $succeeded += 1 + } +} + +Write-Host "`nAll code-review evaluations complete." +Write-Host "Succeeded: $succeeded" +Write-Host "Failed: $($failed.Count)" +Write-Host "Results root: $batchOutputDir" + +if ($failed.Count -gt 0) { + Write-Host "`nFailed entries:" -ForegroundColor Yellow + $failed | Format-Table -AutoSize +} diff --git a/tools/dump_entries.py b/tools/dump_entries.py new file mode 100644 index 000000000..04dc3c012 --- /dev/null +++ b/tools/dump_entries.py @@ -0,0 +1,48 @@ +import contextlib +import json +import sys +from pathlib import Path + +BASE = Path("evaluation_results/gh_run_27240290541") +DATASET = Path("dataset/codereview.jsonl") + + +def load_ood(iid: str) -> tuple[str, list[dict]]: + hits = list(BASE.rglob(f"{iid}.jsonl")) + if not hits: + return "", [] + r = json.loads(hits[0].read_text(encoding="utf-8").strip().splitlines()[0]) + edom = (r.get("domain") or "").lower() + findings: list = [] + with contextlib.suppress(Exception): + findings = json.loads(r.get("output", "")).get("findings", []) + ood = [x for x in findings if isinstance(x, dict) and (x.get("domain") or "").lower() not in ("", edom)] + return edom, ood + + +def main() -> None: + domain = sys.argv[1] + nums = sys.argv[2:] if len(sys.argv) > 2 else None + with DATASET.open(encoding="utf-8") as fh: + rows = {x["instance_id"]: x for x in (json.loads(line) for line in fh if line.strip())} + ids = sorted(k for k in rows if k.startswith(f"synthetic__{domain}-")) + if nums: + ids = [f"synthetic__{domain}-{n}" for n in nums] + for iid in ids: + e = rows[iid] + edom, ood = load_ood(iid) + print("\n" + "#" * 80) + print(f"# {iid} domain={edom} exp={len(e['expected_comments'])} ood={len(ood)}") + print("# EXPECTED:") + for c in e["expected_comments"]: + print(f"# {c.get('file')}:{c.get('line_start')} [{c.get('domain')}/{c.get('severity')}] {c.get('body', '')[:90]}") + print("# OOD:") + for x in ood: + fp = (x.get("filePath") or "?").split("/")[-1] + print(f"# {x.get('domain')}/{x.get('severity')} {fp}:{x.get('lineNumber')} | {' '.join(str(x.get('issue', '')).split())[:95]}") + print("# PATCH:") + print(e["patch"]) + + +if __name__ == "__main__": + main() diff --git a/tools/fix_enrichment_iteration_1.py b/tools/fix_enrichment_iteration_1.py new file mode 100644 index 000000000..be19ea0be --- /dev/null +++ b/tools/fix_enrichment_iteration_1.py @@ -0,0 +1,181 @@ +"""Apply targeted design fixes after initial probe revealed OOD/missed issues. + +Fixes: +- security-002: Drop expected[0] (line 3 SecretText param), keep expected[1] (line 10 concat) +- privacy-003: Use Label format string in new file (eliminates style OOD); update expected to line 7 +- privacy-008: Use JsonObject in new file (eliminates security OOD); keep expected at line 10 +- style-002: Replace expected to point at pre-existing PostingHelper.Codeunit.al line 3 (4-space indent) +- upgrade-001: Change expected line_start to 5 (trigger header where skill flags it) +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DATASET = REPO_ROOT / "dataset" / "codereview.jsonl" + + +def build_new_file_diff(file_path: str, content: str) -> str: + lines = content.split("\n") + if lines and lines[-1] == "": + lines = lines[:-1] + n = len(lines) + out = [ + f"diff --git a/{file_path} b/{file_path}", + "new file mode 100644", + "--- /dev/null", + f"+++ b/{file_path}", + f"@@ -0,0 +1,{n} @@", + ] + out.extend(f"+{ln}" for ln in lines) + return "\n".join(out) + "\n" + + +def replace_file_block(patch: str, file_path: str, new_block: str) -> str: + marker = f"diff --git a/{file_path} b/{file_path}\n" + start = patch.find(marker) + if start == -1: + raise ValueError(f"file block {file_path} not found in patch") + end = patch.find("\ndiff --git ", start + 1) + if end == -1: + return patch[:start] + new_block + return patch[:start] + new_block + patch[end + 1 :] + + +PRIVACY_003_CONTENT = """codeunit 50323 "Customer Email Validator" +{ + procedure RejectEmail(CustomerName: Text[100]; EmailAddress: Text[80]) + var + InvalidEmailErr: Label 'Customer %1 has invalid email %2.'; + begin + Error(InvalidEmailErr, CustomerName, EmailAddress); + end; + + procedure Check(CustomerName: Text[100]; EmailAddress: Text[80]) + begin + this.RejectEmail(CustomerName, EmailAddress); + end; +} +""" + +PRIVACY_008_CONTENT = """codeunit 50327 "Customer Sync Dispatcher" +{ + procedure SendCustomer(Customer: Record Customer) + var + HttpClient: HttpClient; + HttpContent: HttpContent; + HttpResponse: HttpResponseMessage; + begin + HttpContent.WriteFrom(this.BuildPayload(Customer)); + HttpClient.Post('https://api.contoso.example/customers', HttpContent, HttpResponse); + end; + + local procedure BuildPayload(Customer: Record Customer): Text + var + PayloadJson: JsonObject; + PayloadText: Text; + begin + PayloadJson.Add('email', Customer."E-Mail"); + PayloadJson.WriteTo(PayloadText); + exit(PayloadText); + end; +} +""" + + +FIXES: dict[str, Callable[[dict], None]] = {} + + +def fix_security_002(entry: dict) -> None: + # Drop expected[0] which expected line 3 (skill consolidates into line 10 finding) + ec = entry["expected_comments"] + if len(ec) >= 2: + # Keep only the line 10 expected + entry["expected_comments"] = [c for c in ec if c.get("line_start") == 10] + + +def fix_privacy_003(entry: dict) -> None: + entry["patch"] = replace_file_block( + entry["patch"], + "src/CustomerEmailValidator.Codeunit.al", + build_new_file_diff("src/CustomerEmailValidator.Codeunit.al", PRIVACY_003_CONTENT), + ) + for c in entry["expected_comments"]: + if c["file"] == "src/CustomerEmailValidator.Codeunit.al": + c["line_start"] = 7 + c["line_end"] = 7 + c["body"] = ( + "Error embeds the customer name and email address as substitution parameters, " + "so telemetry captures the formatted message containing PII. Use a generic error message " + "without customer data, or surface the PII through a privacy-compliant channel." + ) + + +def fix_privacy_008(entry: dict) -> None: + entry["patch"] = replace_file_block( + entry["patch"], + "src/CustomerSyncDispatcher.Codeunit.al", + build_new_file_diff("src/CustomerSyncDispatcher.Codeunit.al", PRIVACY_008_CONTENT), + ) + # Keep expected at line 10 (HttpClient.Post) + + +def fix_style_002(entry: dict) -> None: + # Replace the SelfReferenceStyle expected with a PostingHelper 4-space indent expected + entry["expected_comments"] = [ + { + "file": "src/PostingHelper.Codeunit.al", + "line_start": 3, + "line_end": 33, + "severity": "low", + "domain": "style", + "body": ("The codeunit body uses 4-space indentation for nested AL blocks. Project style requires 2-space indentation consistently throughout."), + } + ] + + +def fix_upgrade_001(entry: dict) -> None: + for c in entry["expected_comments"]: + if c["file"] == "src/InlineUpgradeSteps.Codeunit.al": + c["line_start"] = 5 + c["line_end"] = 5 + c["body"] = ( + "OnUpgradePerCompany trigger contains the upgrade implementation inline. " + "Trigger bodies should delegate to a local procedure so upgrade orchestration " + "and implementation remain separable and testable." + ) + + +FIXES["synthetic__security-002"] = fix_security_002 +FIXES["synthetic__privacy-003"] = fix_privacy_003 +FIXES["synthetic__privacy-008"] = fix_privacy_008 +FIXES["synthetic__style-002"] = fix_style_002 +FIXES["synthetic__upgrade-001"] = fix_upgrade_001 + + +def main() -> None: + out_lines: list[str] = [] + applied = 0 + with DATASET.open(encoding="utf-8") as fh: + for raw in fh: + line = raw.rstrip("\n") + if not line.strip(): + out_lines.append(raw) + continue + entry = json.loads(line) + iid = entry["instance_id"] + if iid in FIXES: + FIXES[iid](entry) + applied += 1 + print(f" [+] fixed {iid}") + out_lines.append(json.dumps(entry, ensure_ascii=False) + "\n") + + DATASET.write_text("".join(out_lines), encoding="utf-8") + print(f"\nApplied {applied} fixes.") + + +if __name__ == "__main__": + main() diff --git a/tools/fix_enrichment_iteration_2.py b/tools/fix_enrichment_iteration_2.py new file mode 100644 index 000000000..95cff0141 --- /dev/null +++ b/tools/fix_enrichment_iteration_2.py @@ -0,0 +1,139 @@ +"""Iteration 2 fixes for the 2 remaining probe failures. + +privacy-003: Switch to StrSubstNo-Label-then-Error pattern. The previous Label-only design +removed the PII pre-baking that the skill reliably catches. The new design uses a Label +(to avoid the AA0216 style finding from a hardcoded format string) but still pre-bakes +PII into a Text variable via StrSubstNo before Error, which is the privacy violation. + +style-002: Keep both expected (PostingHelper 4-space + SelfReferenceStyle missing this.). +Both are real in-domain style violations. Skill surfaces either on any given run. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DATASET = REPO_ROOT / "dataset" / "codereview.jsonl" + + +def build_new_file_diff(file_path: str, content: str) -> str: + lines = content.split("\n") + if lines and lines[-1] == "": + lines = lines[:-1] + n = len(lines) + out = [ + f"diff --git a/{file_path} b/{file_path}", + "new file mode 100644", + "--- /dev/null", + f"+++ b/{file_path}", + f"@@ -0,0 +1,{n} @@", + ] + out.extend(f"+{ln}" for ln in lines) + return "\n".join(out) + "\n" + + +def replace_file_block(patch: str, file_path: str, new_block: str) -> str: + marker = f"diff --git a/{file_path} b/{file_path}\n" + start = patch.find(marker) + if start == -1: + raise ValueError(f"file block {file_path} not found") + end = patch.find("\ndiff --git ", start + 1) + if end == -1: + return patch[:start] + new_block + return patch[:start] + new_block + patch[end + 1 :] + + +PRIVACY_003_CONTENT = """codeunit 50323 "Customer Email Validator" +{ + procedure RejectEmail(CustomerName: Text[100]; EmailAddress: Text[80]) + var + InvalidEmailErr: Label 'Customer %1 has invalid email %2.'; + ErrorMessage: Text; + begin + ErrorMessage := StrSubstNo(InvalidEmailErr, CustomerName, EmailAddress); + Error(ErrorMessage); + end; + + procedure Check(CustomerName: Text[100]; EmailAddress: Text[80]) + begin + this.RejectEmail(CustomerName, EmailAddress); + end; +} +""" + + +def fix_privacy_003(entry: dict) -> None: + entry["patch"] = replace_file_block( + entry["patch"], + "src/CustomerEmailValidator.Codeunit.al", + build_new_file_diff("src/CustomerEmailValidator.Codeunit.al", PRIVACY_003_CONTENT), + ) + entry["expected_comments"] = [ + { + "file": "src/CustomerEmailValidator.Codeunit.al", + "line_start": 8, + "line_end": 9, + "severity": "high", + "domain": "privacy", + "body": ( + "PII (customer name and email) is pre-built into a Text variable via StrSubstNo " + "and then passed to Error. The resulting message is logged to telemetry with the " + "PII inlined. Avoid pre-baking customer data into error messages; surface generic " + "errors and report PII through a privacy-compliant channel." + ), + } + ] + + +def fix_style_002(entry: dict) -> None: + entry["expected_comments"] = [ + { + "file": "src/PostingHelper.Codeunit.al", + "line_start": 3, + "line_end": 33, + "severity": "low", + "domain": "style", + "body": ("The codeunit body uses 4-space indentation for nested AL blocks. Project style requires 2-space indentation consistently throughout."), + }, + { + "file": "src/SelfReferenceStyle.Codeunit.al", + "line_start": 5, + "line_end": 5, + "severity": "low", + "domain": "style", + "body": ("Self-references inside codeunits should be qualified with this. Use this.IsCustomerNoFilled(CustomerNo) for clarity."), + }, + ] + + +FIXES = { + "synthetic__privacy-003": fix_privacy_003, + "synthetic__style-002": fix_style_002, +} + + +def main() -> None: + out_lines: list[str] = [] + applied = 0 + with DATASET.open(encoding="utf-8") as fh: + for raw in fh: + line = raw.rstrip("\n") + if not line.strip(): + out_lines.append(raw) + continue + entry = json.loads(line) + iid = entry["instance_id"] + if iid in FIXES: + FIXES[iid](entry) + applied += 1 + print(f" [+] fixed {iid}") + out_lines.append(json.dumps(entry, ensure_ascii=False) + "\n") + + DATASET.write_text("".join(out_lines), encoding="utf-8") + print(f"\nApplied {applied} iteration-2 fixes.") + + +if __name__ == "__main__": + main() diff --git a/tools/ood_worklist.py b/tools/ood_worklist.py new file mode 100644 index 000000000..ca5156f4b --- /dev/null +++ b/tools/ood_worklist.py @@ -0,0 +1,43 @@ +"""Generate out-of-domain (OOD) worklist for a domain from the latest gh run artifacts.""" + +import contextlib +import json +import sys +from pathlib import Path + +BASE = Path("evaluation_results/gh_run_27240290541") +VALID = {"security", "performance", "style", "accessibility", "upgrade", "privacy"} + + +def load_entry(iid: str) -> dict: + hits = list(BASE.rglob(f"{iid}.jsonl")) + if not hits: + return {} + text = hits[0].read_text(encoding="utf-8").strip() + if not text: + return {} + return json.loads(text.splitlines()[0]) + + +def main() -> None: + domain = sys.argv[1] + ids = sorted({p.stem for p in BASE.rglob(f"synthetic__{domain}-*.jsonl")}) + print(f"{domain} entries: {len(ids)}") + for iid in ids: + r = load_entry(iid) + if not r: + print(iid, "NO_DATA") + continue + edom = (r.get("domain") or "").lower() + findings: list = [] + with contextlib.suppress(Exception): + findings = json.loads(r.get("output", "")).get("findings", []) + ood = [f for f in findings if isinstance(f, dict) and (f.get("domain") or "").lower() not in ("", edom)] + doms = sorted({(f.get("domain") or "").lower() for f in ood}) + exp = len(r.get("expected_comments", [])) + f1 = r.get("f1") + print(f"{iid} exp={exp} ood={len(ood)} f1={f1} oodDoms={doms}") + + +if __name__ == "__main__": + main() diff --git a/tools/probe_codereview_batch.py b/tools/probe_codereview_batch.py new file mode 100644 index 000000000..65dd5850c --- /dev/null +++ b/tools/probe_codereview_batch.py @@ -0,0 +1,121 @@ +"""Parallel batch runner for probe_codereview_case.py. + +Spawns N concurrent probe subprocesses, one per instance_id. Writes one +report JSON per entry under tmp/cr-probe-reports/. Prints a final aggregate. + +Usage: + uv run python tools/probe_codereview_batch.py --all-zero --concurrency 4 + uv run python tools/probe_codereview_batch.py --domain security --concurrency 4 +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DATASET = REPO_ROOT / "dataset" / "codereview.jsonl" +REPORT_ROOT = REPO_ROOT / "tmp" / "cr-probe-reports" +PROBE = REPO_ROOT / "tools" / "probe_codereview_case.py" + + +def select_ids(only: list[str] | None, zero_only: bool, domain: str | None) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + with DATASET.open(encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.strip() + if not line: + continue + raw = json.loads(line) + iid = raw["instance_id"] + ed = raw["metadata"]["area"] + if only and iid not in only: + continue + if domain and ed != domain: + continue + if zero_only and raw["expected_comments"]: + continue + out.append((iid, ed)) + return out + + +def run_probe(iid: str, model: str) -> tuple[str, int, str]: + log_path = REPORT_ROOT / f"{iid}.stdout.log" + REPORT_ROOT.mkdir(parents=True, exist_ok=True) + t0 = time.time() + with log_path.open("w", encoding="utf-8") as out: + proc = subprocess.run( + ["uv", "run", "python", str(PROBE), iid, "--model", model], + cwd=REPO_ROOT, + stdout=out, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + elapsed = time.time() - t0 + return (iid, proc.returncode, f"{elapsed:6.1f}s") + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("ids", nargs="*") + p.add_argument("--all-zero", action="store_true") + p.add_argument("--domain", default=None) + p.add_argument("--model", default="claude-opus-4.8") + p.add_argument("--concurrency", type=int, default=4) + args = p.parse_args() + + targets = select_ids(only=args.ids or None, zero_only=args.all_zero, domain=args.domain) + if not targets: + print("no entries matched") + return + + print(f"Probing {len(targets)} entries with model={args.model} concurrency={args.concurrency}", flush=True) + for iid, dom in targets: + print(f" - {iid} ({dom})") + print(flush=True) + + REPORT_ROOT.mkdir(parents=True, exist_ok=True) + done = 0 + failed: list[str] = [] + t_start = time.time() + with ThreadPoolExecutor(max_workers=args.concurrency) as ex: + futures = {ex.submit(run_probe, iid, args.model): iid for iid, _ in targets} + for fut in as_completed(futures): + iid, rc, elapsed = fut.result() + done += 1 + tag = "OK" if rc == 0 else f"ERR rc={rc}" + print(f"[{done}/{len(targets)}] {iid:35} {elapsed} {tag}", flush=True) + if rc != 0: + failed.append(iid) + total = time.time() - t_start + print(f"\nBatch done in {total / 60:.1f} min. {len(failed)} failures.", flush=True) + if failed: + for iid in failed: + print(f" FAIL {iid} see tmp/cr-probe-reports/{iid}.stdout.log", flush=True) + + print("\n===== AGGREGATE =====") + for iid, _dom in targets: + rp = REPORT_ROOT / f"{iid}.json" + if not rp.exists(): + print(f" ? {iid:35} (no report)") + continue + try: + r = json.loads(rp.read_text(encoding="utf-8")) + except json.JSONDecodeError: + print(f" ERR {iid:35} (invalid report)") + continue + if "error" in r: + print(f" ERR {iid:35} {r['error']}") + else: + n_in = len(r["in_domain_findings"]) if isinstance(r["in_domain_findings"], list) else r["in_domain_findings"] + tag = "OK " if (not r["missed"] and not r["ood"]) else "FAIL" + print(f" {tag} {iid:35} expected={r['expected']} matched={r['matched']} missed={len(r['missed'])} ood={len(r['ood'])} in_domain={n_in}") + + +if __name__ == "__main__": + main() diff --git a/tools/probe_codereview_case.py b/tools/probe_codereview_case.py new file mode 100644 index 000000000..3f821d947 --- /dev/null +++ b/tools/probe_codereview_case.py @@ -0,0 +1,282 @@ +"""Local probe for a single code-review dataset entry. + +Materializes the entry's patch into a throwaway folder, runs `copilot` with the +al-code-review skill, parses review.json, and validates OOD discipline + +expected-comment recall. + +Usage: + uv run python tools/probe_codereview_case.py synthetic__security-001 + uv run python tools/probe_codereview_case.py --all-zero + uv run python tools/probe_codereview_case.py --all-zero --domain security +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import re +import shutil +import stat +import subprocess +import sys +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +def _force_remove(func: Callable[[str], Any], path: str, exc: BaseException) -> None: + Path(path).chmod(stat.S_IWRITE) + func(path) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DATASET = REPO_ROOT / "dataset" / "codereview.jsonl" +SKILLS_SRC = REPO_ROOT / "src" / "bcbench" / "agent" / "shared" / "instructions" / "microsoft-BCApps" +PROBE_ROOT = REPO_ROOT / "tmp" / "cr-probe" +REPORT_ROOT = REPO_ROOT / "tmp" / "cr-probe-reports" +DEFAULT_MODEL = "claude-opus-4.8" + +PROMPT_TEMPLATE = """/al-code-review + +Review ONLY the current working-tree AL file changes for this evaluation entry. +Use the working tree diff only (git diff HEAD), and focus on changed *.al files. +Do NOT review committed history or the HEAD commit, and do NOT compare commits (for example, do NOT use HEAD~1..HEAD or origin/main comparisons). + +Save findings to a file named "review.json" in the repository root. +The file must contain valid JSON with a top-level object named findings. +Each finding must include: filePath, lineNumber, severity, issue, recommendation, domain, suggestedCode +Allowed severity values are: critical, high, medium, low. +If there are no findings, write an empty findings list. +""" + + +@dataclass +class Entry: + instance_id: str + domain: str + patch: str + expected_comments: list[dict] + match_line_tolerance: int + + +def load_entries(only: list[str] | None = None, zero_only: bool = False, domain: str | None = None) -> list[Entry]: + out: list[Entry] = [] + with DATASET.open(encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.strip() + if not line: + continue + raw = json.loads(line) + iid = raw["instance_id"] + ed = raw["metadata"]["area"] + if only and iid not in only: + continue + if domain and ed != domain: + continue + if zero_only and raw["expected_comments"]: + continue + out.append( + Entry( + instance_id=iid, + domain=ed, + patch=raw["patch"], + expected_comments=raw["expected_comments"], + match_line_tolerance=raw.get("match_line_tolerance", 2), + ) + ) + return out + + +def materialize_patch(repo_path: Path, patch: str) -> list[str]: + """Write '+' lines from a new-file diff into repo_path. Returns list of paths.""" + materialized: list[str] = [] + current_path: Path | None = None + current_content: list[str] = [] + + def flush() -> None: + nonlocal current_path, current_content + if current_path is None: + return + current_path.parent.mkdir(parents=True, exist_ok=True) + current_path.write_text("\n".join(current_content) + "\n", encoding="utf-8") + materialized.append(current_path.relative_to(repo_path).as_posix()) + current_path = None + current_content = [] + + for line in patch.splitlines(): + if line.startswith("diff --git "): + flush() + continue + if line.startswith(("--- ", "new file mode", "index ")): + continue + if line.startswith("+++ "): + rel = re.sub(r"^[ab]/", "", line[4:].strip()) + current_path = repo_path / rel + current_content = [] + continue + if line.startswith("@@"): + continue + if current_path is None: + continue + if line.startswith(("+", " ")): + current_content.append(line[1:]) + flush() + return materialized + + +def setup_workspace(entry: Entry) -> Path: + repo_path = PROBE_ROOT / entry.instance_id + if repo_path.exists(): + shutil.rmtree(repo_path, onexc=_force_remove) + repo_path.mkdir(parents=True) + + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo_path, check=True) + subprocess.run(["git", "config", "user.email", "probe@example.com"], cwd=repo_path, check=True) + subprocess.run(["git", "config", "user.name", "probe"], cwd=repo_path, check=True) + (repo_path / "README.md").write_text("probe scratch\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo_path, check=True) + subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=repo_path, check=True) + + github_dst = repo_path / ".github" + shutil.copytree(SKILLS_SRC, github_dst) + + paths = materialize_patch(repo_path, entry.patch) + if paths: + subprocess.run(["git", "add", "-N", "--", *paths], cwd=repo_path, check=True) + return repo_path + + +def run_copilot(repo_path: Path, model: str, log_dir: Path) -> subprocess.CompletedProcess: + copilot = shutil.which("copilot.exe") or shutil.which("copilot.cmd") or shutil.which("copilot") + if not copilot: + raise RuntimeError("copilot CLI not in PATH") + cmd = [ + copilot, + "--allow-all-tools", + "--disable-builtin-mcps", + f"--model={model}", + "--log-level=debug", + f"--log-dir={log_dir.resolve()}", + f"--prompt={PROMPT_TEMPLATE.replace(chr(10), ' ')}", + ] + return subprocess.run(cmd, cwd=repo_path, stderr=subprocess.PIPE, timeout=900, check=False) + + +def parse_findings(review_json_path: Path) -> list[dict] | None: + if not review_json_path.exists(): + return None + try: + data = json.loads(review_json_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + if isinstance(data, dict) and "findings" in data: + return data["findings"] + if isinstance(data, list): + return data + return None + + +def evaluate(entry: Entry, findings: list[dict]) -> dict: + ood: list[dict] = [] + in_domain: list[dict] = [] + for f in findings: + d = (f.get("domain") or "").strip().lower() + if d and d != entry.domain.lower(): + ood.append(f) + else: + in_domain.append(f) + + matched: list[dict] = [] + missed: list[dict] = [] + for exp in entry.expected_comments: + exp_file = exp["file"].lower() + exp_lo = exp["line_start"] - entry.match_line_tolerance + exp_hi = exp["line_end"] + entry.match_line_tolerance + found = False + for f in in_domain: + fp = (f.get("filePath") or "").lower().replace("\\", "/") + ln = f.get("lineNumber") or 0 + if exp_file in fp and exp_lo <= ln <= exp_hi: + matched.append({"expected": exp, "finding": f}) + found = True + break + if not found: + missed.append(exp) + + return { + "entry": entry.instance_id, + "domain": entry.domain, + "expected": len(entry.expected_comments), + "matched": len(matched), + "missed": [{"file": m["file"], "line": m["line_start"], "issue": (m.get("body") or m.get("issue") or "")[:80]} for m in missed], + "ood": [{"file": f.get("filePath"), "line": f.get("lineNumber"), "domain": f.get("domain"), "severity": f.get("severity"), "issue": (f.get("issue") or "")[:160]} for f in ood], + "in_domain_findings": [ + {"file": f.get("filePath"), "line": f.get("lineNumber"), "severity": f.get("severity"), "issue": (f.get("issue") or "")[:200], "recommendation": (f.get("recommendation") or "")[:160]} + for f in in_domain + ], + "total_findings": len(findings), + } + + +def probe_one(entry: Entry, model: str, keep: bool = False) -> dict: + print(f" [setup] {entry.instance_id}", flush=True) + repo_path = setup_workspace(entry) + log_dir = repo_path / ".copilot-logs" + log_dir.mkdir(exist_ok=True) + print(f" [run] copilot {model} (cwd={repo_path})", flush=True) + proc = run_copilot(repo_path, model, log_dir) + if proc.returncode != 0: + sys.stderr.write(proc.stderr.decode("utf-8", errors="replace")[-2000:]) + return {"entry": entry.instance_id, "error": f"copilot exit {proc.returncode}"} + + findings = parse_findings(repo_path / "review.json") + if findings is None: + return {"entry": entry.instance_id, "error": "no review.json or invalid JSON"} + + report = evaluate(entry, findings) + if not keep: + # leave repo for inspection if errors, else trim heavy logs + for p in log_dir.glob("*.log"): + with contextlib.suppress(OSError): + p.unlink() + return report + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("ids", nargs="*", help="instance_id(s) to probe") + p.add_argument("--all-zero", action="store_true", help="probe all entries with empty expected_comments") + p.add_argument("--domain", help="filter by domain (security/performance/style/upgrade/privacy)") + p.add_argument("--model", default=DEFAULT_MODEL) + p.add_argument("--keep", action="store_true", help="don't delete log files after run") + args = p.parse_args() + + entries = load_entries(only=args.ids or None, zero_only=args.all_zero, domain=args.domain) + if not entries: + print("no entries matched") + return + + PROBE_ROOT.mkdir(parents=True, exist_ok=True) + REPORT_ROOT.mkdir(parents=True, exist_ok=True) + summary: list[dict] = [] + for e in entries: + print(f"\n== {e.instance_id} ({e.domain}) ==", flush=True) + report = probe_one(e, args.model, keep=args.keep) + report_path = REPORT_ROOT / f"{e.instance_id}.json" + report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + print(f" [report] wrote {report_path.relative_to(REPO_ROOT)}", flush=True) + summary.append(report) + + print("\n===== SUMMARY =====") + for r in summary: + if "error" in r: + print(f" ERR {r['entry']}: {r['error']}") + else: + tag = "OK " if (not r["missed"] and not r["ood"]) else "FAIL" + print(f" {tag} {r['entry']}: expected={r['expected']} matched={r['matched']} missed={len(r['missed'])} ood={len(r['ood'])}") + + +if __name__ == "__main__": + main() diff --git a/tools/run_entry.py b/tools/run_entry.py new file mode 100644 index 000000000..d948def77 --- /dev/null +++ b/tools/run_entry.py @@ -0,0 +1,79 @@ +"""Run a single code-review entry locally and print a compact result summary. + +Usage: uv run python tools/run_entry.py +Prints: metrics line + each generated finding's domain/severity/file:line + short issue. +Designed for the iterate-until-clean workflow. +""" + +import datetime +import json +import pathlib +import subprocess +import sys + +REPO = r"C:\repos\evals\BCApps" + + +def main() -> None: + iid = sys.argv[1] + stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + out_dir = pathlib.Path(f"evaluation_results/iter/{iid}_{stamp}") + out_dir.mkdir(parents=True, exist_ok=True) + + cmd = [ + "uv", + "run", + "bcbench", + "-v", + "evaluate", + "copilot", + iid, + "--category", + "code-review", + "--model", + "claude-opus-4.7", + "--repo-path", + REPO, + "--output-dir", + str(out_dir), + "--run-id", + f"run_{stamp}", + "--al-mcp", + ] + subprocess.run(cmd, check=False) + + result_files = list(out_dir.rglob(f"{iid}.jsonl")) + if not result_files: + print("NO_RESULT") + return + r = json.loads(result_files[0].read_text(encoding="utf-8")) + edom = (r.get("domain") or "").strip().lower() + + try: + findings = json.loads(r.get("output", "")).get("findings", []) + except (json.JSONDecodeError, TypeError, AttributeError): + findings = [] + + print("=" * 70) + print(f"ENTRY {iid} domain={edom}") + print( + f"METRICS matched={r.get('matched_comment_count')} missed={r.get('missed_comment_count')} " + f"incorrect={r.get('incorrect_comment_count')} precision={r.get('precision'):.3f} " + f"recall={r.get('recall'):.3f} f1={r.get('f1'):.3f}" + ) + ood = [] + for f in findings: + if not isinstance(f, dict): + continue + d = (f.get("domain") or "").strip().lower() + fp = (f.get("filePath") or "?").split("/")[-1] + tag = "OOD" if (d and d != edom) else "in " + line = f" [{tag}] {d}/{f.get('severity')} {fp}:{f.get('lineNumber')} {' '.join(str(f.get('issue', '')).split())[:90]}" + print(line) + if d and d != edom: + ood.append(line) + print(f"OOD_COUNT={len(ood)}") + + +if __name__ == "__main__": + main() diff --git a/tools/unindent_bait_files.py b/tools/unindent_bait_files.py new file mode 100644 index 000000000..a38be6304 --- /dev/null +++ b/tools/unindent_bait_files.py @@ -0,0 +1,121 @@ +"""Reformat pre-existing FP-bait files in dataset patches from 4-space to 2-space indent. + +These trivial helper files use 4-space indent which the skill correctly flags as +style-domain violations, producing OOD findings on non-style entries. Converting +to 2-space matches the project style standard and eliminates the OOD source. + +Only modifies the listed (instance_id, file) pairs and only the leading whitespace +on '+' lines within each matching diff block. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DATASET = REPO_ROOT / "dataset" / "codereview.jsonl" + +# (instance_id, file_path) pairs whose pre-existing diff block needs unindenting +TARGETS = [ + ("synthetic__security-002", "src/SecureKeyManager.Codeunit.al"), + ("synthetic__security-003", "src/SafeErrorHandler.Codeunit.al"), + ("synthetic__security-004", "src/AppConstants.Codeunit.al"), + ("synthetic__privacy-004", "src/SystemErrorHandler.Codeunit.al"), + ("synthetic__privacy-004", "src/ErrorLogEntry.Table.al"), + ("synthetic__privacy-008", "src/BusinessEntityRegistry.Table.al"), + ("synthetic__privacy-009", "src/TaxDataMigrationHelper.Codeunit.al"), + ("synthetic__upgrade-001", "src/CustomerCardCreditLimitExt.PageExt.al"), + ("synthetic__upgrade-002", "src/EnumConversionHelper.Codeunit.al"), + ("synthetic__upgrade-002", "src/PaymentMethodType.Enum.al"), + ("synthetic__upgrade-003", "src/CustomerListEnhancements.PageExt.al"), + ("synthetic__upgrade-003", "src/ModernAPIHelper.Codeunit.al"), + ("synthetic__upgrade-004", "src/GenericUpgradeHandler.Codeunit.al"), + ("synthetic__upgrade-004", "src/MigrationStatusTracker.Table.al"), +] + + +def unindent_plus_line(line: str) -> str: + if not line.startswith("+"): + return line + payload = line[1:] + # match leading 4-space runs, replace each with 2 spaces + m = re.match(r"^( +)", payload) + if not m: + return line + indent = m.group(1) + if len(indent) % 4 != 0: + return line # not pure 4-space; leave alone + new_indent = " " * (len(indent) // 2) + return "+" + new_indent + payload[len(indent) :] + + +def reindent_file_block(patch: str, file_path: str) -> tuple[str, int]: + """Reformat leading 4-space indent to 2-space on '+' lines inside the diff block for file_path.""" + marker = f"diff --git a/{file_path} b/{file_path}\n" + start = patch.find(marker) + if start == -1: + return patch, 0 + # find end = next 'diff --git' or end of patch + end = patch.find("\ndiff --git ", start + len(marker)) + if end == -1: + block = patch[start:] + tail = "" + else: + block = patch[start:end] + "\n" # ensure block ends with newline + tail = patch[end + 1 :] # skip the leading '\n' we put on block + if end == -1: + head = patch[:start] + tail = "" + else: + head = patch[:start] + + lines = block.split("\n") + new_lines = [unindent_plus_line(ln) for ln in lines] + changes = sum(1 for a, b in zip(lines, new_lines, strict=True) if a != b) + new_block = "\n".join(new_lines) + return head + new_block + tail, changes + + +def main() -> None: + by_iid: dict[str, list[str]] = {} + for iid, fp in TARGETS: + by_iid.setdefault(iid, []).append(fp) + + out_lines: list[str] = [] + total_changes = 0 + touched_entries = 0 + with DATASET.open(encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.rstrip("\n") + if not line.strip(): + out_lines.append(raw_line) + continue + entry = json.loads(line) + iid = entry["instance_id"] + if iid not in by_iid: + out_lines.append(raw_line) + continue + + patch = entry["patch"] + entry_changes = 0 + for fp in by_iid[iid]: + patch, n = reindent_file_block(patch, fp) + entry_changes += n + if n == 0: + print(f" ! {iid}: no changes for {fp} (block not found or already 2-space)") + else: + print(f" [+] {iid}: {fp} ({n} lines reindented)") + entry["patch"] = patch + out_lines.append(json.dumps(entry, ensure_ascii=False) + "\n") + total_changes += entry_changes + if entry_changes: + touched_entries += 1 + + DATASET.write_text("".join(out_lines), encoding="utf-8") + print(f"\nReformatted {touched_entries} entries; {total_changes} lines reindented total.") + + +if __name__ == "__main__": + main()